diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 4bbf5ab..840eed3 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -69,6 +69,8 @@ import { SaveOutfitV4Response, SubscriptionResponse, UNAUTHORIZED_RESPONSE, + UpdateObjectiveRequest, + UpdateObjectiveResponse, } from './openapi' import { getOutfits, setOutfit } from './outfit-db' @@ -500,6 +502,37 @@ const app = new Hono({ strict: false }) (c) => c.json([]) ) + // Report one objective's progress. The client posts the whole objective as it now + // sees it (Index/Group identify it within `myprogress`) and reads back the state of + // the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it + // posted. Stubbed: with no objectives store yet we persist nothing, echo the group + // back and never complete it, so the reward-claim flow isn't triggered. `clearedAt` + // is the clear time, which for a group we didn't clear is just now. + .post( + '/api/objectives/v1/updateobjective', + describeRoute({ + tags: ['Econ'], + summary: 'Report objective progress', + description: [ + 'Stubbed: with no objectives store we persist nothing and never complete a group.', + 'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client', + 'gets a well-formed body.', + ].join(' '), + requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'), + responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') }, + }), + async (c) => { + const body = await c.req + .json<{ Group?: string | number }>() + .catch(() => ({}) as Record) + return c.json({ + group: Number(body.Group) || 0, + isCompleted: false, + clearedAt: new Date().toISOString(), + }) + } + ) + // The player's avatar, stored as a JSON blob on their account row. Falls back // to the default outfit when they haven't saved one — the client's parser NREs // on an empty OutfitSelections (real RecNet never returns one). diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 8fa7c1a..cd8a5bc 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -112,6 +112,17 @@ export const ChallengeProgressResponse = z.object({ Complete: z.boolean().describe('Always false — no challenge-progress store yet'), }) +/** + * `POST /api/objectives/v1/updateobjective` — the group the objective belongs to, after + * the update. camelCase, unlike the PascalCase body the client posts and the PascalCase + * `ObjectiveGroups` entries `myprogress` serves — three spellings of the same group. + */ +export const UpdateObjectiveResponse = z.object({ + group: z.int().describe('Echoed back from the request'), + isCompleted: z.boolean().describe('Always false — no objectives store yet'), + clearedAt: z.string().describe('When the group was cleared — now, since nothing persists'), +}) + /** * `POST /api/storefronts/v2/buyItem` — the purchase result. `Balance` is the CHANGE * applied (the negated price), not the resulting total; the client reads its new total @@ -198,6 +209,20 @@ export const ChallengeProgressRequest = z.object({ Config: z.string().optional().describe('The client-evaluated rule tree'), }) +/** + * `POST /api/objectives/v1/updateobjective` JSON body — one objective's state as the + * client now sees it. `Index`/`Group` identify it within `myprogress`; the rest is the + * progress it wants persisted. + */ +export const UpdateObjectiveRequest = z.object({ + Index: z.int().describe('Which objective within the group'), + Group: z.int().describe('Which objective group'), + Progress: z.int().optional(), + VisualProgress: z.int().optional().describe('What the client animates towards'), + IsCompleted: z.boolean().optional(), + HasClaimedReward: z.boolean().optional(), +}) + /** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */ export const SaveOutfitRequest = z .object({ Slot: z.int().describe('Which slot to overwrite; a non-integer is 400') }) diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 71fc0c0..f694b3c 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -342,6 +342,37 @@ describe('econ endpoints', () => { } }) + test('POST /api/objectives/v1/updateobjective echoes the group, never completed', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + Index: 2, + Group: 3, + Progress: 1, + VisualProgress: 0, + IsCompleted: true, + HasClaimedReward: false, + }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { group: number; isCompleted: boolean; clearedAt: string } + expect(body.group).toBe(3) + expect(body.isCompleted).toBe(false) + expect(Number.isNaN(Date.parse(body.clearedAt))).toBe(false) + }) + + test('POST /api/objectives/v1/updateobjective tolerates a non-JSON body', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, { + method: 'POST', + body: 'not json', + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { group: number; isCompleted: boolean } + expect(body.group).toBe(0) + expect(body.isCompleted).toBe(false) + }) + test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`) expect(anon.status).toBe(401) @@ -1425,6 +1456,7 @@ describe('econ endpoints', () => { 'POST /api/consumables/v1/consume', 'POST /api/gamerewards/v1/request', 'POST /api/objectives/v1/cleargroup', + 'POST /api/objectives/v1/updateobjective', 'POST /api/storefronts/v2/buyItem', 'PUT /api/equipment/v1/update', ])