mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[econ] reward scaffolding
This commit is contained in:
@@ -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
@@ -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 challenge’s `Complete` stamped from the',
|
||||
'caller’s 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 "[]".
|
||||
|
||||
@@ -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 client’s verdict — sent as .NET’s `"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'),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user