mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -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
|
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).
|
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;
|
Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge
|
||||||
storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the
|
progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static
|
||||||
ASSETS binding. Several routes are still empty-list stubs.
|
assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still
|
||||||
|
empty-list stubs.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
|
`✓` = 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 |
|
| Method | Path | Auth | Description |
|
||||||
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
||||||
@@ -42,10 +43,10 @@ missing/invalid).
|
|||||||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||||
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
||||||
| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) |
|
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
|
||||||
| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) |
|
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
|
||||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
| 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/mine` | | The player's room keys (stub `[]`) |
|
||||||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
|
| 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`)
|
## Weekly challenge (`static/weekly-challenge.json`)
|
||||||
|
|
||||||
Served verbatim by `GET /api/challenge/v2/getCurrent`. The server never evaluates it: the
|
Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete`
|
||||||
client reads the rule tree in each challenge's `Config`, watches its own gameplay, and
|
stamped in — see Progress below). The server never evaluates the rules: the client reads
|
||||||
posts the tree back to `/api/challenge/v2/updateProgress` with its verdict. So this file
|
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree
|
||||||
is the entire definition of a week's challenges — ids, display strings, matching rules and
|
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire
|
||||||
the reward preview.
|
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.
|
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
|
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
|
### 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. |
|
| `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"`. |
|
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
|
||||||
| `Tooltip` | The longer hint under it. |
|
| `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` 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
|
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
|
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).
|
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
|
On `updateProgress` the client posts the same tree back with its own progress written into
|
||||||
node — its current count (`…,"t":5,"cc":1`). `cc` never appears in this file; it is
|
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
|
||||||
progress, not definition. Since the server persists nothing, that count lives only in the
|
marks a node it now considers satisfied. Neither appears in this file — they are progress,
|
||||||
client.
|
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
|
**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
|
VR twin share ids and both count: the captured "Complete 10 games in Paintball" listed six
|
||||||
the subrooms of _both_ `Paintball` and `PaintballVR`, and each also exists as a standalone
|
scenes, which are the subrooms of _both_ `Paintball` and `PaintballVR` — and each is also a
|
||||||
base room (`River`, `Clearcut`, …). One list covers every way in. How the rotation's five
|
standalone base room (`River`, `Clearcut`, …). One list covers every way in. Resolve a guid
|
||||||
challenges resolve:
|
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
|
||||||
| Challenge | Scenes |
|
this creates: `Soccer / Home` and `Stadium / Home` are the same scene, so a soccer challenge
|
||||||
| --------- | ----------------------------------------------------------------- |
|
also completes in the Stadium.
|
||||||
| `37` | TheRiseofJumbotron / Home |
|
|
||||||
| `38` | Crescendo / Home |
|
|
||||||
| `44` | Paintball: River, Homestead, Quarry, Clearcut, Spillway, Drive-in |
|
|
||||||
| `49` | 3DCharades / InkSpaceHome + Legacy3DCharades / Home |
|
|
||||||
| `63` | Clearcut only |
|
|
||||||
|
|
||||||
### The `Gift` block
|
### 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
|
`sf3.json` as `2121` ("Camera Skin (Comic)"). Nothing grants it — the reward is preview
|
||||||
only (see Known gaps).
|
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
|
## Bindings
|
||||||
|
|
||||||
| Binding | Type | Notes |
|
| 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.
|
- 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.
|
- `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.
|
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies, game
|
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||||||
rewards) are empty-list stubs pending their own stores.
|
empty-list stubs pending their own stores.
|
||||||
- Weekly-challenge progress is never persisted and the rotation's `Gift` is never granted:
|
- Game rewards gate correctly but pay nothing out — see the `reward_status` section.
|
||||||
`updateProgress` echoes `Complete: false`, so nothing ever completes.
|
- 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,
|
isSpendable,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from './balance-db'
|
} from './balance-db'
|
||||||
|
import { getCompletedChallengeIds, recordChallengeProgress } from './challenge-db'
|
||||||
import {
|
import {
|
||||||
consumeConsumable,
|
consumeConsumable,
|
||||||
countConsumable,
|
countConsumable,
|
||||||
@@ -60,11 +61,13 @@ import {
|
|||||||
EquipmentUpdateRequest,
|
EquipmentUpdateRequest,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
|
GameRewardRequest,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
OpaqueJsonBody,
|
OpaqueJsonBody,
|
||||||
|
OPTIONAL_AUTHED,
|
||||||
SaveOutfitRequest,
|
SaveOutfitRequest,
|
||||||
SaveOutfitV4Response,
|
SaveOutfitV4Response,
|
||||||
SubscriptionResponse,
|
SubscriptionResponse,
|
||||||
@@ -73,6 +76,7 @@ import {
|
|||||||
UpdateObjectiveResponse,
|
UpdateObjectiveResponse,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
import { getOutfits, setOutfit } from './outfit-db'
|
import { getOutfits, setOutfit } from './outfit-db'
|
||||||
|
import { claimReward } from './reward-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { GiftContent, StoredGift } from '@repo/domain'
|
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
|
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||||
* the `econ` service (these are separate from the main `api` worker). Balances,
|
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||||||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
* 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
|
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
* 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)
|
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
|
* 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
|
* 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)
|
(c) => c.json(adCarouselItems)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Current weekly challenge. Served from the bundled static JSON until
|
// Current weekly challenge. The rotation itself is the bundled static JSON (its format
|
||||||
// per-rotation challenge data is wired up.
|
// 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(
|
.get(
|
||||||
'/api/challenge/v2/getCurrent',
|
'/api/challenge/v2/getCurrent',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Current weekly challenge',
|
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') },
|
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
|
// Report progress on a weekly challenge. [Authorize]. The client evaluates the
|
||||||
// tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and
|
// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
|
||||||
// whether it now considers the challenge `Complete`. Stubbed: with no challenge-
|
// `Config`, and whether it now considers the challenge `Complete`. Only the
|
||||||
// progress DB yet we persist nothing and never mark a challenge complete (so the
|
// completion is persisted (keyed by account + challenge); `Config` is the catalog's
|
||||||
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
|
// own definition plus the client's running count, so storing it would duplicate
|
||||||
// so the client gets a well-formed, non-null body to deserialize.
|
// 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(
|
.post(
|
||||||
'/api/challenge/v2/updateProgress',
|
'/api/challenge/v2/updateProgress',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Report weekly-challenge progress',
|
summary: 'Report weekly-challenge progress',
|
||||||
description: [
|
description: [
|
||||||
'Stubbed: with no challenge-progress store we persist nothing and never mark a',
|
'Persists the reported completion into `challenge_status`, keyed by account +',
|
||||||
'challenge complete. Echoes the identifying fields back with `Complete: false` so the',
|
'challenge. `Config` is accepted and echoed but not stored. Completion latches within',
|
||||||
'client gets a well-formed body.',
|
'a rotation, so the echoed `Complete` is the stored value, not the posted one.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
|
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) => {
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
const body = await c.req
|
const body = await c.req
|
||||||
.json<{
|
.json<{
|
||||||
ChallengeMapId?: string | number
|
ChallengeMapId?: string | number
|
||||||
ChallengeId?: string | number
|
ChallengeId?: string | number
|
||||||
Config?: string
|
Config?: string
|
||||||
|
Complete?: string | boolean
|
||||||
}>()
|
}>()
|
||||||
.catch(() => ({}) as Record<string, never>)
|
.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({
|
return c.json({
|
||||||
ChallengeMapId: Number(body.ChallengeMapId) || 0,
|
ChallengeMapId: challengeMapId,
|
||||||
ChallengeId: Number(body.ChallengeId) || 0,
|
ChallengeId: challengeId,
|
||||||
Config: typeof body.Config === 'string' ? body.Config : '',
|
Config: typeof body.Config === 'string' ? body.Config : '',
|
||||||
Complete: false,
|
Complete: complete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1435,13 +1493,56 @@ const app = new Hono<App>({ strict: false })
|
|||||||
c.json([])
|
c.json([])
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request a game reward (client posts `rewardType`/`Message`, e.g.
|
// Request a game reward. [Authorize]. The client asks whenever it thinks one is due,
|
||||||
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
|
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
||||||
// empty list of rewards — matching the `pending` shape so the client deserializes it.
|
// 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(
|
.post(
|
||||||
'/api/gamerewards/v1/request',
|
'/api/gamerewards/v1/request',
|
||||||
listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'),
|
describeRoute({
|
||||||
(c) => c.json([])
|
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 "[]".
|
// 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. */
|
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||||
export const AUTHED = [{ bearerAuth: [] }]
|
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 ----------------------------------------------------------
|
// ---- Loose shapes ----------------------------------------------------------
|
||||||
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
|
// 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
|
// 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({
|
export const ChallengeProgressResponse = z.object({
|
||||||
ChallengeMapId: z.int(),
|
ChallengeMapId: z.int(),
|
||||||
ChallengeId: z.int(),
|
ChallengeId: z.int(),
|
||||||
Config: z.string(),
|
Config: z.string().describe('Echoed back verbatim; not stored'),
|
||||||
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
|
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({
|
export const ChallengeProgressRequest = z.object({
|
||||||
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
|
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
|
||||||
ChallengeId: 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
|
// 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).
|
// 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'
|
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 { SCHEMA_DDL } from '../../avatar-db'
|
||||||
import {
|
import {
|
||||||
BALANCE_SCHEMA_DDL,
|
BALANCE_SCHEMA_DDL,
|
||||||
@@ -21,10 +24,12 @@ import {
|
|||||||
getBalance,
|
getBalance,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from '../../balance-db'
|
} from '../../balance-db'
|
||||||
|
import { CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
|
||||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||||
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
|
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
|
||||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||||
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
||||||
|
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -34,6 +39,9 @@ declare module 'cloudflare:test' {
|
|||||||
|
|
||||||
const ORIGIN = 'https://example.com'
|
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)
|
// 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.
|
// so avatar reads/writes have a row to attach to.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -42,6 +50,8 @@ beforeAll(async () => {
|
|||||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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 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 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 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 CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of EQUIPMENT_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([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/challenge/v2/updateProgress echoes the challenge, never complete (stub)', async () => {
|
test('POST /api/challenge/v2/updateProgress echoes the challenge and its stored completion', async () => {
|
||||||
const config =
|
// Post the live rotation's own challenge and rule tree — what the client actually
|
||||||
'{"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}'
|
// 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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ChallengeMapId: '17',
|
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||||
ChallengeId: '49',
|
ChallengeId: String(challenge.ChallengeId),
|
||||||
Config: config,
|
Config: challenge.Config,
|
||||||
|
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
|
||||||
|
// would read as complete.
|
||||||
Complete: 'False',
|
Complete: 'False',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
ChallengeMapId: 17,
|
ChallengeMapId: weeklyChallenge.ChallengeMapId,
|
||||||
ChallengeId: 49,
|
ChallengeId: challenge.ChallengeId,
|
||||||
Config: config,
|
Config: challenge.Config,
|
||||||
Complete: false,
|
Complete: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/gamerewards/v1/request returns [] (stub)', async () => {
|
test('POST /api/challenge/v2/updateProgress is 401 without a token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day',
|
body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day',
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(anon.status).toBe(401)
|
||||||
expect(await res.json()).toEqual([])
|
|
||||||
|
// 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 () => {
|
test('GET /api/roomkeys/v1/mine returns []', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user