diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 8b4397f..00c6f1d 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -18,6 +18,7 @@ import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' import { ALL_PLATFORMS, + CurrencyType, DEFAULT_STARTING_TOKENS, getBalance, isSpendable, @@ -40,7 +41,9 @@ import { BuyItemResponse, ChallengeProgressRequest, ChallengeProgressResponse, + ChecklistCompleteResponse, ChecklistEntry, + CompleteChecklistRequest, ConsumeConsumableRequest, ConsumeEnvelope, ConsumeGiftRequest, @@ -363,6 +366,9 @@ const DEFAULT_CHECKLIST = [ { Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer ] +/** The `UpdateResponse` context a checklist reward is reported under. */ +const CHECKLIST_REWARD_CONTEXT = 303 + /** * A concise `describeRoute` spec for a route that serves an opaque JSON array — either * a static catalog served verbatim or an empty-list stub. `auth` adds the bearer @@ -579,6 +585,44 @@ const app = new Hono({ strict: false }) } ) + // Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress + // table to record the completion in, and no reward ledger to make the 25-token grant + // once-only — without one, re-posting the same row would mint tokens indefinitely, so + // we grant nothing and report a change of 0. The envelope is still the balance-update + // shape the client parses, so the flow completes instead of erroring. + .on( + 'POST', + ['/api/checklist/v1/complete', '/api/checklist/v2/complete'], + describeRoute({ + tags: ['Econ'], + summary: 'Complete a checklist row (stub)', + description: + 'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' + + 'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' + + 'tokens, but making that once-only needs a ledger we do not have, and without one ' + + 're-posting the same row would mint tokens indefinitely. The response is still the ' + + 'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.', + security: AUTHED, + requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'), + responses: { + 200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + // The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only + // once there is somewhere to record it. + return c.json({ + BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }], + Balance: 0, + CurrencyType: CurrencyType.RecCenterTokens, + BalanceType: -2, + }) + } + ) + // The player's item wishlist. [Authorize]; empty without a DB binding. .get( '/api/itemWishlists/v1/wishlist/me', diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index d60d284..6beb3ea 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -115,6 +115,28 @@ export const AvatarItemV4Dto = z.object({ isBaseAvatarItem: z.boolean(), }) +/** + * `POST /api/checklist/v1|v2/complete` JSON body — which checklist row was finished. + * The client posts just `{ "ItemIndex": 1 }`; `Id` is the fallback key read when + * `ItemIndex` is absent or 0. + */ +export const CompleteChecklistRequest = z.object({ + ItemIndex: z.int().describe('The row’s index — what the client actually sends'), + Id: z.int().optional().describe('Fallback row id, read when ItemIndex is absent or 0'), +}) + +/** + * `POST /api/checklist/v1|v2/complete` — the balance-update envelope, the same shape + * buyItem answers with. `Balance` is the CHANGE applied, so a stubbed (ungranted) + * completion reports 0. `UpdateResponse` 303 is the checklist-reward context. + */ +export const ChecklistCompleteResponse = z.object({ + BalanceUpdates: z.array(z.object({ UpdateResponse: z.int(), Data: z.array(JsonObject) })), + Balance: z.int().describe('The change applied — 0 while completion is stubbed'), + CurrencyType: z.int(), + BalanceType: z.int().describe('-2 = account-wide'), +}) + /** * One row of the new-user checklist (`GET /api/checklist/v1|v2/current`). `Objective` is * an `ObjectiveType` ordinal the client matches its own progress events against. diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 4744190..7b5df64 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -311,6 +311,37 @@ describe('econ endpoints', () => { } }) + test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => { + for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) { + const anon = await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ItemIndex: 1 }), + }) + expect(anon.status).toBe(401) + + const res = await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { ...(await bearer('33')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ItemIndex: 1 }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + BalanceUpdates: [{ UpdateResponse: 303, Data: [] }], + Balance: 0, + CurrencyType: 2, + BalanceType: -2, + }) + } + + // Stubbed, so completing rows does not move the balance — re-posting cannot farm + // tokens, and the checklist still lists every row. + const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('33'), + }) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }]) + }) + test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/me`) expect(anon.status).toBe(401) @@ -1223,6 +1254,8 @@ describe('econ endpoints', () => { 'POST /api/avatar/v3/saved/set', 'POST /api/avatar/v4/saved/set', 'POST /api/challenge/v2/updateProgress', + 'POST /api/checklist/v1/complete', + 'POST /api/checklist/v2/complete', 'POST /api/consumables/v1/consume', 'POST /api/gamerewards/v1/request', 'POST /api/objectives/v1/cleargroup',