[econ] reward scaffolding

This commit is contained in:
Devin Zuczek
2026-08-10 19:02:40 -04:00
parent 927c6757bb
commit 0b33e0b46f
8 changed files with 632 additions and 74 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* Weekly-challenge progress on the shared `recflare` D1 database — one row per
* (account, challenge), written by `POST /api/challenge/v2/updateProgress` and read back
* by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player `Complete`.
*
* Only the completion flag is stored, not the `Config` rule tree the client posts with it.
* That tree is the challenge's DEFINITION (it comes from static/weekly-challenge.json and
* is identical for everyone), decorated with the client's running count in `cc`; the
* server evaluates none of it, so persisting a per-player copy would only be a second,
* staler copy of the catalog. See the README's weekly-challenge section for the grammar.
*
* Completion LATCHES within a rotation: the client reports progress repeatedly, and a
* report that arrives with the challenge no longer complete (a fresh session, a reordered
* retry) must not un-finish something already finished. A report carrying a different
* `ChallengeMapId` is a new rotation and REPLACES the row instead — challenge ids are only
* unique within a rotation, so a challenge that returns in a later week would otherwise
* start out already complete on the old week's row.
*
* The `econ` worker owns this table and its migration
* (apps/econ/migrations/0009_challenge_status.sql).
*/
/** Schema DDL (mirror of migrations 0009_challenge_status.sql) — also builds the table in tests. */
export const CHALLENGE_STATUS_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS challenge_status (
account_id INTEGER NOT NULL,
challenge_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL,
complete INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_id)
)`,
]
/** One challenge's progress as the client reports it. */
export interface ChallengeProgress {
challengeMapId: number
challengeId: number
complete: boolean
}
/**
* Record a progress report and return the completion the row now holds — which is what the
* response must echo, since it isn't always what was posted: within a rotation `complete`
* only ever goes false → true (see the latching note above), so a `false` report against a
* finished challenge answers `true`.
*
* SQLite evaluates every `DO UPDATE SET` expression against the pre-update row, so the
* `CASE` can compare the stored `challenge_map_id` with the incoming one while the same
* statement overwrites it.
*/
export async function recordChallengeProgress(
db: D1Database,
accountId: number,
progress: ChallengeProgress
): Promise<boolean> {
const row = await db
.prepare(
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (account_id, challenge_id) DO UPDATE SET
complete = CASE
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
THEN MAX(challenge_status.complete, excluded.complete)
ELSE excluded.complete
END,
challenge_map_id = excluded.challenge_map_id,
updated_at = excluded.updated_at
RETURNING complete`
)
.bind(
accountId,
progress.challengeId,
progress.challengeMapId,
progress.complete ? 1 : 0,
new Date().toISOString()
)
.first<{ complete: number }>()
return row?.complete === 1
}
/**
* The ids of the challenges a player has finished in one rotation. Scoped to the rotation
* so a stale row from an earlier week — same challenge id, different `challenge_map_id` —
* doesn't show up pre-completed before the client has reported anything against it.
*/
export async function getCompletedChallengeIds(
db: D1Database,
accountId: number,
challengeMapId: number
): Promise<Set<number>> {
const { results } = await db
.prepare(
`SELECT challenge_id FROM challenge_status
WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1`
)
.bind(accountId, challengeMapId)
.all<{ challenge_id: number }>()
return new Set(results.map((r) => r.challenge_id))
}
+124 -23
View File
@@ -36,6 +36,7 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import { getCompletedChallengeIds, recordChallengeProgress } from './challenge-db'
import {
consumeConsumable,
countConsumable,
@@ -60,11 +61,13 @@ import {
EquipmentUpdateRequest,
ErrorResponse,
form,
GameRewardRequest,
json,
JsonArray,
jsonBody,
JsonObject,
OpaqueJsonBody,
OPTIONAL_AUTHED,
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
@@ -73,6 +76,7 @@ import {
UpdateObjectiveResponse,
} from './openapi'
import { getOutfits, setOutfit } from './outfit-db'
import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, StoredGift } from '@repo/domain'
@@ -87,7 +91,7 @@ import type { Outfit } from './outfit-db'
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
* the `econ` service (these are separate from the main `api` worker). Balances,
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
* avatars and gift boxes are D1-backed;
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
*
@@ -107,6 +111,16 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
* `"False"` is truthy, so a client reporting "not complete" would read as complete.
* Anything unrecognised (missing, `null`, `""`) is false.
*/
function parseBool(value: string | boolean | undefined): boolean {
return typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true'
}
/**
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
@@ -1381,51 +1395,95 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(adCarouselItems)
)
// Current weekly challenge. Served from the bundled static JSON until
// per-rotation challenge data is wired up.
// Current weekly challenge. The rotation itself is the bundled static JSON (its format
// is documented in the README) but each challenge's `Complete` is per-player, so the
// caller's rows from `challenge_status` are stamped over the static `false`s.
// Auth is OPTIONAL: without a valid bearer the static catalog is served unchanged
// rather than 401, since the rotation is public information and a 404/401 on this
// route can stall the client's load orchestration.
.get(
'/api/challenge/v2/getCurrent',
describeRoute({
tags: ['Econ'],
summary: 'Current weekly challenge',
description: 'Served from the bundled static challenge until per-rotation data is wired up.',
description: [
'The bundled static rotation, with each challenges `Complete` stamped from the',
'callers progress rows. Auth is optional — unauthenticated callers get the static',
'catalog with every `Complete` false.',
].join(' '),
security: OPTIONAL_AUTHED,
responses: { 200: json(JsonObject, 'The current weekly challenge') },
}),
(c) => c.json(weeklyChallenge)
async (c) => {
const id = await authedId(c)
if (id === null) return c.json(weeklyChallenge)
const complete = await getCompletedChallengeIds(c.env.DB, id, weeklyChallenge.ChallengeMapId)
if (complete.size === 0) return c.json(weeklyChallenge)
// Rebuild rather than mutate: the static import is module state shared by every
// request this isolate serves, so stamping it in place would leak one player's
// completions to the next caller.
return c.json({
...weeklyChallenge,
Challenges: weeklyChallenge.Challenges.map((challenge) => ({
...challenge,
Complete: complete.has(challenge.ChallengeId),
})),
})
}
)
// Report progress on a weekly challenge. The client evaluates the challenge's rule
// tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and
// whether it now considers the challenge `Complete`. Stubbed: with no challenge-
// progress DB yet we persist nothing and never mark a challenge complete (so the
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
// so the client gets a well-formed, non-null body to deserialize.
// Report progress on a weekly challenge. [Authorize]. The client evaluates the
// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
// `Config`, and whether it now considers the challenge `Complete`. Only the
// completion is persisted (keyed by account + challenge); `Config` is the catalog's
// own definition plus the client's running count, so storing it would duplicate
// static data. Echoes the identifying fields back with the completion the row now
// holds — which is not always what was posted, since completion latches within a
// rotation.
.post(
'/api/challenge/v2/updateProgress',
describeRoute({
tags: ['Econ'],
summary: 'Report weekly-challenge progress',
description: [
'Stubbed: with no challenge-progress store we persist nothing and never mark a',
'challenge complete. Echoes the identifying fields back with `Complete: false` so the',
'client gets a well-formed body.',
'Persists the reported completion into `challenge_status`, keyed by account +',
'challenge. `Config` is accepted and echoed but not stored. Completion latches within',
'a rotation, so the echoed `Complete` is the stored value, not the posted one.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') },
responses: {
200: json(ChallengeProgressResponse, 'Echoed fields with the stored completion'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<{
ChallengeMapId?: string | number
ChallengeId?: string | number
Config?: string
Complete?: string | boolean
}>()
.catch(() => ({}) as Record<string, never>)
const challengeMapId = Number(body.ChallengeMapId) || 0
const challengeId = Number(body.ChallengeId) || 0
// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
const complete =
challengeId === 0
? parseBool(body.Complete)
: await recordChallengeProgress(c.env.DB, id, {
challengeMapId,
challengeId,
complete: parseBool(body.Complete),
})
return c.json({
ChallengeMapId: Number(body.ChallengeMapId) || 0,
ChallengeId: Number(body.ChallengeId) || 0,
ChallengeMapId: challengeMapId,
ChallengeId: challengeId,
Config: typeof body.Config === 'string' ? body.Config : '',
Complete: false,
Complete: complete,
})
}
)
@@ -1435,13 +1493,56 @@ const app = new Hono<App>({ strict: false })
c.json([])
)
// Request a game reward (client posts `rewardType`/`Message`, e.g.
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
// empty list of rewards — matching the `pending` shape so the client deserializes it.
// Request a game reward. [Authorize]. The client asks whenever it thinks one is due,
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
// here, from `reward_status`: one claim per type per hour, atomically.
//
// The reward itself is still a stub: a claim records the cooldown and grants nothing,
// so both outcomes answer the same empty list the client already accepts. Paying one
// out later is the `claimed !== null` branch below — the eligibility half is what has
// to be right first, since that's what stops a repeat ask paying twice.
//
// `giftContext` (the activity, e.g. `Soccer`) is accepted and ignored: the cooldown is
// per reward type, shared across activities.
.post(
'/api/gamerewards/v1/request',
listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'),
(c) => c.json([])
describeRoute({
tags: ['Econ'],
summary: 'Request a game reward',
description: [
'Claims one reward of `rewardType` per hour per player, recorded in `reward_status`.',
'The reward payload is still a stub — a claim grants nothing and both a claim and a',
'rejected (on-cooldown) ask answer `[]`. `giftContext` is accepted and ignored.',
].join(' '),
security: AUTHED,
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
responses: {
200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
// No type, nothing to gate: don't write a row keyed on an empty string.
if (rewardType === '') return c.json([])
const claimed = await claimReward(c.env.DB, id, rewardType)
if (claimed !== null) {
// The reward would be granted here. Logged for now so the faucet is visible in
// production before it pays anything out.
logger.info('game reward claimed', {
accountId: id,
rewardType,
grantCount: claimed,
message: typeof body.Message === 'string' ? body.Message : '',
})
}
return c.json([])
}
)
// The player's room keys. Returns "[]".
+31 -3
View File
@@ -50,6 +50,13 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* Optional bearer JWT — the empty requirement object makes "no credentials" a valid
* alternative. For routes that serve public data but personalise it for a known caller
* (the weekly challenge's per-player `Complete`) instead of 401ing.
*/
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
// ---- Loose shapes ----------------------------------------------------------
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
// empty-list stubs. Modelling every catalog field adds noise without value, so these
@@ -108,8 +115,10 @@ export const SubscriptionResponse = z.object({
export const ChallengeProgressResponse = z.object({
ChallengeMapId: z.int(),
ChallengeId: z.int(),
Config: z.string(),
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
Config: z.string().describe('Echoed back verbatim; not stored'),
Complete: z
.boolean()
.describe('The STORED completion — latches true within a rotation, so it may differ'),
})
/**
@@ -206,7 +215,26 @@ export const ConsumeGiftRequest = z.object({
export const ChallengeProgressRequest = z.object({
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
ChallengeId: z.union([z.string(), z.int()]).optional(),
Config: z.string().optional().describe('The client-evaluated rule tree'),
Config: z
.string()
.optional()
.describe('The client-evaluated rule tree, with its running count in `cc`; not stored'),
Complete: z
.union([z.string(), z.boolean()])
.optional()
.describe('The clients verdict — sent as .NETs `"True"`/`"False"`'),
})
/** `POST /api/gamerewards/v1/request` form body. */
export const GameRewardRequest = z.object({
rewardType: z
.string()
.describe('The reward being asked for, e.g. `FirstActivityOfDay`, `PostGameActivity`'),
Message: z.string().optional().describe('The message to show for the reward'),
giftContext: z
.string()
.optional()
.describe('The activity it came from, e.g. `Soccer` — accepted and ignored'),
})
/**
+69
View File
@@ -0,0 +1,69 @@
/**
* Game-reward eligibility on the shared `recflare` D1 database — one row per (account,
* reward type), written by `POST /api/gamerewards/v1/request`.
*
* The client asks for a reward whenever it thinks one is due ("First Game of the Day"
* after an activity, "Activity completed!" after a match), so the server, not the client,
* has to decide whether one is actually owed: this table is what makes a second ask for
* the same reward a no-op instead of a second payout.
*
* Keyed by reward TYPE only. The client also sends a `giftContext` (the activity, e.g.
* `Soccer`), but it is deliberately not part of the key — one cooldown per type, shared
* across every activity, rather than one per activity.
*
* The `econ` worker owns this table and its migration
* (apps/econ/migrations/0010_reward_status.sql).
*/
/** Schema DDL (mirror of migrations 0010_reward_status.sql) — also builds the table in tests. */
export const REWARD_STATUS_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reward_status (
account_id INTEGER NOT NULL,
reward_type TEXT NOT NULL,
granted_at TEXT NOT NULL,
grant_count INTEGER NOT NULL,
PRIMARY KEY (account_id, reward_type)
)`,
]
/**
* How long a player must wait between rewards of the same type. One hour flat, for every
* type — despite what a name like `FirstActivityOfDay` suggests. Per-type windows would be
* a map keyed by reward type; there's one window until a reward type needs its own.
*/
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
/**
* Claim a reward if the player is due one, returning how many of that type they have now
* claimed — or `null` when the cooldown hasn't elapsed and nothing was claimed.
*
* The check and the claim are ONE statement. The client fires these off after a match, so
* two requests can land together; a read-then-write would let both see the same stale
* `granted_at` and pay out twice. `ON CONFLICT … DO UPDATE … WHERE` gives us the atomic
* version: when the cooldown hasn't elapsed the update is skipped, no row is returned, and
* the stored `granted_at` is left alone (so a rejected claim doesn't extend the cooldown).
*
* `granted_at` holds `toISOString()` output — fixed-width UTC, so the lexical `<=` against
* the cutoff is a chronological comparison with no date parsing in SQL.
*/
export async function claimReward(
db: D1Database,
accountId: number,
rewardType: string,
now: Date = new Date()
): Promise<number | null> {
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
const row = await db
.prepare(
`INSERT INTO reward_status (account_id, reward_type, granted_at, grant_count)
VALUES (?1, ?2, ?3, 1)
ON CONFLICT (account_id, reward_type) DO UPDATE SET
granted_at = excluded.granted_at,
grant_count = reward_status.grant_count + 1
WHERE reward_status.granted_at <= ?4
RETURNING grant_count`
)
.bind(accountId, rewardType, now.toISOString(), cutoff)
.first<{ grant_count: number }>()
return row?.grant_count ?? null
}
+167 -14
View File
@@ -13,6 +13,9 @@ import {
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
// is built here too (see the same cross-worker import in econ.app.ts).
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
// The live weekly rotation, so the challenge tests exercise whatever it currently holds
// instead of hard-coded ids from a rotation that has since been replaced.
import weeklyChallenge from '../../../static/weekly-challenge.json'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
@@ -21,10 +24,12 @@ import {
getBalance,
spendCurrency,
} from '../../balance-db'
import { CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { Env } from '../../context'
@@ -34,6 +39,9 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/** The first challenge of the live rotation — the progress tests report against it. */
const CURRENT_CHALLENGE = weeklyChallenge.Challenges[0]
// Build the accounts table and seed the test player (the default token's sub, 42)
// so avatar reads/writes have a row to attach to.
beforeAll(async () => {
@@ -42,6 +50,8 @@ beforeAll(async () => {
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CHALLENGE_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REWARD_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -1342,36 +1352,179 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([])
})
test('POST /api/challenge/v2/updateProgress echoes the challenge, never complete (stub)', async () => {
const config =
'{"ct":1,"ipc":false,"ctc":[{"ct":0,"ipc":false,"wc":[{"ct":6,"vs":[2]},{"ct":7,"vs":[{"l":"a673712c-877f-4749-b69a-4a4c6310d545"}]}]}],"t":5,"cc":1}'
test('POST /api/challenge/v2/updateProgress echoes the challenge and its stored completion', async () => {
// Post the live rotation's own challenge and rule tree — what the client actually
// sends — so editing static/weekly-challenge.json can't quietly stale this test.
const challenge = CURRENT_CHALLENGE
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: '17',
ChallengeId: '49',
Config: config,
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
Config: challenge.Config,
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
// would read as complete.
Complete: 'False',
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
ChallengeMapId: 17,
ChallengeId: 49,
Config: config,
ChallengeMapId: weeklyChallenge.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: challenge.Config,
Complete: false,
})
})
test('POST /api/gamerewards/v1/request returns [] (stub)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
test('POST /api/challenge/v2/updateProgress is 401 without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ChallengeMapId: '17', ChallengeId: '49', Complete: 'True' }),
})
expect(res.status).toBe(401)
})
test('a completed challenge persists and getCurrent stamps it for that player only', async () => {
const completedId = CURRENT_CHALLENGE.ChallengeId
const bearerHeaders = await bearer('71')
const posted = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeId: completedId,
Complete: 'True',
}),
})
expect(posted.status).toBe(200)
const mine = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
headers: bearerHeaders,
})
const body = (await mine.json()) as {
Challenges: Array<{ ChallengeId: number; Complete: boolean }>
}
// Only the reported one is stamped; the rest of the rotation is untouched.
expect(body.Challenges.filter((ch) => ch.Complete).map((ch) => ch.ChallengeId)).toEqual([
completedId,
])
// A different player, and an anonymous caller, still see the static catalog.
const other = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
headers: await bearer('72'),
})
const otherBody = (await other.json()) as { Challenges: Array<{ Complete: boolean }> }
expect(otherBody.Challenges.some((ch) => ch.Complete)).toBe(false)
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
const anonBody = (await anon.json()) as { Challenges: Array<{ Complete: boolean }> }
expect(anonBody.Challenges.some((ch) => ch.Complete)).toBe(false)
})
test('completion latches within a rotation but resets on a new one', async () => {
const headers = { ...(await bearer('73')), 'Content-Type': 'application/json' }
// A challenge id of its own, so this says nothing about the live rotation.
const post = (ChallengeMapId: string, Complete: string) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({ ChallengeMapId, ChallengeId: '9001', Complete }),
})
const completeOf = async (res: Response) =>
((await res.json()) as { Complete: boolean }).Complete
expect(await completeOf(await post('17', 'True'))).toBe(true)
// A later report that says "not complete" must not un-finish it.
expect(await completeOf(await post('17', 'False'))).toBe(true)
// …but the same challenge id in the NEXT rotation starts over.
expect(await completeOf(await post('18', 'False'))).toBe(false)
expect(await completeOf(await post('18', 'True'))).toBe(true)
})
test('POST /api/gamerewards/v1/request claims once an hour per reward type', async () => {
const headers = {
...(await bearer('80')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const request = (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers,
body,
})
const statusOf = (rewardType: string) =>
env.DB.prepare(
'SELECT granted_at, grant_count FROM reward_status WHERE account_id = 80 AND reward_type = ?1'
)
.bind(rewardType)
.first<{ granted_at: string; grant_count: number }>()
// The payload is stubbed, so a claim still answers the empty list the client accepts.
const first = await request(
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
)
expect(first.status).toBe(200)
expect(await first.json()).toEqual([])
const claimed = await statusOf('FirstActivityOfDay')
expect(claimed?.grant_count).toBe(1)
// Asking again inside the hour claims nothing — and must not push the cooldown out,
// or a client that retries in a loop would never become eligible.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
// A different type has its own cooldown; `giftContext` doesn't split it.
expect(
(
await request(
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer'
)
).status
).toBe(200)
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
expect(
(
await request(
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Paintball'
)
).status
).toBe(200)
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
// Once the hour has passed, the same type claims again.
await env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'"
)
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
.run()
expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200)
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
})
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(anon.status).toBe(401)
// No reward type: nothing to gate, so no row keyed on an empty string.
const typeless = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: {
...(await bearer('81')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'Message=First%20Game%20of%20the%20Day',
})
expect(typeless.status).toBe(200)
expect(await typeless.json()).toEqual([])
const rows = await env.DB.prepare(
'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81'
).first<{ count: number }>()
expect(rows?.count).toBe(0)
})
test('GET /api/roomkeys/v1/mine returns []', async () => {