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:
+96
-34
@@ -4,14 +4,15 @@ Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
|
||||
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
|
||||
main `api` worker, which also serves many of them — the client may call either host).
|
||||
|
||||
Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||
storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the
|
||||
ASSETS binding. Several routes are still empty-list stubs.
|
||||
Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge
|
||||
progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static
|
||||
assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still
|
||||
empty-list stubs.
|
||||
|
||||
## Routes
|
||||
|
||||
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
|
||||
missing/invalid).
|
||||
missing/invalid). `~` = optional auth: served to anyone, personalised for a valid bearer.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
||||
@@ -42,10 +43,10 @@ missing/invalid).
|
||||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
||||
| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) |
|
||||
| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) |
|
||||
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
|
||||
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
|
||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward (hourly, per type) |
|
||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
|
||||
@@ -97,15 +98,18 @@ mismatched call is a harmless no-op (opening _another_ player's box is a 403).
|
||||
|
||||
## Weekly challenge (`static/weekly-challenge.json`)
|
||||
|
||||
Served verbatim by `GET /api/challenge/v2/getCurrent`. The server never evaluates it: the
|
||||
client reads the rule tree in each challenge's `Config`, watches its own gameplay, and
|
||||
posts the tree back to `/api/challenge/v2/updateProgress` with its verdict. So this file
|
||||
is the entire definition of a week's challenges — ids, display strings, matching rules and
|
||||
the reward preview.
|
||||
Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete`
|
||||
stamped in — see Progress below). The server never evaluates the rules: the client reads
|
||||
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree
|
||||
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire
|
||||
definition of a week's challenges — ids, display strings, matching rules and the reward
|
||||
preview.
|
||||
|
||||
Everything below was read off reference data (one captured live rotation), not a spec.
|
||||
Field meanings marked _(inferred)_ are read from how the values line up with the strings
|
||||
the client renders; the rest are pinned by the data itself.
|
||||
the client renders; the rest are pinned by the data itself. The file itself is edited
|
||||
freely as rotations change — the examples here are the captured week, so expect the shipped
|
||||
rotation to differ.
|
||||
|
||||
### Top level
|
||||
|
||||
@@ -134,7 +138,7 @@ rotation with a ~1-day countdown rather than an expired one. If you edit the win
|
||||
| `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. |
|
||||
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
|
||||
| `Tooltip` | The longer hint under it. |
|
||||
| `Complete` | Per-player state, so meaningless in a static catalog: always `false` here, and `updateProgress` is stubbed and never flips it. |
|
||||
| `Complete` | Per-player state, so always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. |
|
||||
|
||||
`^Token` in `Description`/`Tooltip` is a client-side room link: the client resolves the
|
||||
token to a room and renders a tappable name. Subrooms use a dotted path
|
||||
@@ -184,24 +188,20 @@ The two idioms in the file, unescaped:
|
||||
Note the quest challenges have **no `t`** (one qualifying session is the whole goal) and
|
||||
the counted ones have **no `won` predicate** (finishing counts, winning is irrelevant).
|
||||
|
||||
On `updateProgress` the client posts the same tree back with **`cc`** added to the counter
|
||||
node — its current count (`…,"t":5,"cc":1`). `cc` never appears in this file; it is
|
||||
progress, not definition. Since the server persists nothing, that count lives only in the
|
||||
client.
|
||||
On `updateProgress` the client posts the same tree back with its own progress written into
|
||||
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
|
||||
marks a node it now considers satisfied. Neither appears in this file — they are progress,
|
||||
not definition, which is why the tree isn't stored (only the top-level `Complete` is). The
|
||||
count itself lives only in the client.
|
||||
|
||||
**Scene ids, not room ids.** Because `ct: 7` matches `UnitySceneId`, a screens room and its
|
||||
VR twin share ids and both count — the six Paintball scenes listed for challenge `44` are
|
||||
the subrooms of _both_ `Paintball` and `PaintballVR`, and each also exists as a standalone
|
||||
base room (`River`, `Clearcut`, …). One list covers every way in. How the rotation's five
|
||||
challenges resolve:
|
||||
|
||||
| Challenge | Scenes |
|
||||
| --------- | ----------------------------------------------------------------- |
|
||||
| `37` | TheRiseofJumbotron / Home |
|
||||
| `38` | Crescendo / Home |
|
||||
| `44` | Paintball: River, Homestead, Quarry, Clearcut, Spillway, Drive-in |
|
||||
| `49` | 3DCharades / InkSpaceHome + Legacy3DCharades / Home |
|
||||
| `63` | Clearcut only |
|
||||
VR twin share ids and both count: the captured "Complete 10 games in Paintball" listed six
|
||||
scenes, which are the subrooms of _both_ `Paintball` and `PaintballVR` — and each is also a
|
||||
standalone base room (`River`, `Clearcut`, …). One list covers every way in. Resolve a guid
|
||||
against the `SubRooms[].UnitySceneId` values in `apps/rooms/migrations/0002_import_rooms.sql`;
|
||||
a "one map only" challenge is the same shape with a single-entry list. Watch for one trap
|
||||
this creates: `Soccer / Home` and `Stadium / Home` are the same scene, so a soccer challenge
|
||||
also completes in the Stadium.
|
||||
|
||||
### The `Gift` block
|
||||
|
||||
@@ -218,6 +218,67 @@ _not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin
|
||||
`sf3.json` as `2121` ("Camera Skin (Comic)"). Nothing grants it — the reward is preview
|
||||
only (see Known gaps).
|
||||
|
||||
### Progress (`challenge_status`)
|
||||
|
||||
`POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account,
|
||||
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`.
|
||||
The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings**
|
||||
and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads
|
||||
"not complete" as complete (`parseBool` handles both spellings and a real JSON `true`).
|
||||
|
||||
Only the completion is stored. `Config` is the catalog's own rule tree plus the client's
|
||||
running count, so a per-player copy would just be a staler duplicate of static data — it is
|
||||
echoed back untouched but never persisted. The response is the four posted fields, except
|
||||
`Complete` is the **stored** value rather than the posted one, because:
|
||||
|
||||
- **Completion latches within a rotation.** The client reports repeatedly, and a later
|
||||
report saying "not complete" (a fresh session, a retry arriving out of order) must not
|
||||
un-finish something already finished.
|
||||
- **A new rotation resets the row.** Challenge ids are only unique within a rotation, so
|
||||
the same id in a later week would otherwise start out already complete. A report whose
|
||||
`ChallengeMapId` differs from the stored one replaces the row instead of latching; reads
|
||||
are scoped to the rotation for the same reason.
|
||||
|
||||
`getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation
|
||||
with every `Complete` false rather than a 401, since the rotation is public and a failure
|
||||
on this route can stall the client's load. The overlay rebuilds the response object rather
|
||||
than stamping the imported JSON in place: that import is module state shared across every
|
||||
request an isolate serves, so mutating it would leak one player's completions to the next
|
||||
caller.
|
||||
|
||||
## Game rewards (`reward_status`)
|
||||
|
||||
The client asks for a reward whenever it thinks one is due, posting a form body of the type
|
||||
and the message to show for it:
|
||||
|
||||
```
|
||||
rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day
|
||||
rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
|
||||
```
|
||||
|
||||
Since the client asks rather than the server offering, whether a reward is actually **owed**
|
||||
is decided here, from `reward_status` — one row per (account, reward type) holding the last
|
||||
claim and a count. One claim per type per hour (`REWARD_COOLDOWN_MS`), flat for every type
|
||||
despite what a name like `FirstActivityOfDay` suggests; per-type windows would be a map
|
||||
keyed by type.
|
||||
|
||||
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
|
||||
these off right after a match, so two can land together; a read-then-write would let both
|
||||
see the same stale `granted_at` and pay out twice.
|
||||
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
|
||||
forward, a client that retries in a loop would never become eligible.
|
||||
- **`giftContext` (the activity, e.g. `Soccer`) is accepted and ignored** — the cooldown is
|
||||
per type, shared across activities, so it is not part of the key.
|
||||
|
||||
**The reward payload itself is a stub:** a successful claim records the cooldown, logs a
|
||||
`game reward claimed` line, and grants nothing, so a claim and an on-cooldown ask both
|
||||
answer the same empty list the client already accepts. Paying one out is the
|
||||
`claimed !== null` branch in the handler. Getting eligibility right first is the point —
|
||||
it's what stops a repeat ask paying twice once there's something to pay.
|
||||
|
||||
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
|
||||
waiting to be collected.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
@@ -235,7 +296,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
|
||||
- Gifting to another player grants the item and box but does not notify the recipient.
|
||||
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted.
|
||||
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies, game
|
||||
rewards) are empty-list stubs pending their own stores.
|
||||
- Weekly-challenge progress is never persisted and the rotation's `Gift` is never granted:
|
||||
`updateProgress` echoes `Complete: false`, so nothing ever completes.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||||
empty-list stubs pending their own stores.
|
||||
- Game rewards gate correctly but pay nothing out — see the `reward_status` section.
|
||||
- Weekly-challenge completion is persisted, but the rotation's `Gift` is never granted —
|
||||
nothing watches for the last challenge finishing, and there is no claim endpoint.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Weekly-challenge progress, owned by the `econ` worker. One row per (account,
|
||||
-- challenge): the client evaluates a challenge's rule tree locally and posts its verdict
|
||||
-- to `/api/challenge/v2/updateProgress`, which upserts here; `/api/challenge/v2/getCurrent`
|
||||
-- reads the rows back to stamp each challenge's per-player `Complete`.
|
||||
--
|
||||
-- Only the completion flag is stored. The `Config` rule tree posted alongside it is the
|
||||
-- challenge's definition (static/weekly-challenge.json, identical for every player) plus
|
||||
-- the client's running count in `cc`; the server evaluates none of it, so a per-player copy
|
||||
-- would just be a staler duplicate of the catalog.
|
||||
--
|
||||
-- `challenge_map_id` is the rotation the report belongs to. It is not part of the key, but
|
||||
-- it scopes reads and resets the row when a challenge id comes back in a later rotation:
|
||||
-- ids are only unique within one. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in
|
||||
-- src/challenge-db.ts.
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_challenge_status_account_map ON challenge_status (account_id, challenge_map_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Game-reward eligibility, owned by the `econ` worker. One row per (account, reward type):
|
||||
-- the client asks for a reward whenever it thinks one is due (`POST
|
||||
-- /api/gamerewards/v1/request` with `rewardType`/`Message`), so this table is what decides
|
||||
-- whether one is actually owed and keeps a repeat ask from paying out twice.
|
||||
--
|
||||
-- `granted_at` is when the type was last claimed and `grant_count` how many times it has
|
||||
-- been; the claim is a conditional upsert, so the check and the write are one atomic
|
||||
-- statement (the client can fire two requests at once after a match).
|
||||
--
|
||||
-- The reward TYPE is the whole key. The client also sends a `giftContext` (the activity,
|
||||
-- e.g. `Soccer`), deliberately not keyed on: one cooldown per type, shared across
|
||||
-- activities. Kept in sync with REWARD_STATUS_SCHEMA_DDL in src/reward-db.ts.
|
||||
|
||||
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)
|
||||
);
|
||||
@@ -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