mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[econ] auto challenges
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
---
|
||||
name: weekly-challenge-config
|
||||
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — the full node-type enum, event types, event variables, named scene constants, and the shared-scene traps
|
||||
description: Read and author the `Config` rule tree a weekly challenge carries — the full node-type enum, event types, event variables, named scene constants, the shared-scene traps, and where the generator in apps/econ/src/challenge-rotation.ts emits them from
|
||||
---
|
||||
|
||||
# The weekly-challenge `Config` rule tree
|
||||
|
||||
Reference for reading and writing the `Config` field of a challenge in
|
||||
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`).
|
||||
Reference for reading and writing the `Config` field of a weekly challenge (served by
|
||||
`GET /api/challenge/v2/getCurrent`).
|
||||
|
||||
**Rotations are generated, so there are two places a tree comes from.** Normally
|
||||
`apps/econ/src/challenge-rotation.ts` emits it: a week is five (room, kind) pairs drawn from
|
||||
`CHALLENGE_ROOMS` with the week's seed, and the tree is built by `configFor` from one of the
|
||||
three idioms below. Adding variety means adding a room or a kind there, not hand-writing a
|
||||
tree. The other place is `apps/econ/static/weekly-challenge.json`: a non-empty `Challenges`
|
||||
array in that file PINS the week to a hand-authored rotation and skips generation, which is
|
||||
how a one-off debug or event week gets served. Both end up as the same `Config` string on
|
||||
the wire, and everything below applies to both.
|
||||
|
||||
**The server never evaluates these rules.** The client reads the tree, watches its own
|
||||
gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its verdict.
|
||||
@@ -43,13 +52,16 @@ Author the tree as an object and stringify it into the field — don't hand-esca
|
||||
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
|
||||
```
|
||||
|
||||
To read one back:
|
||||
To read this week's back (the generated rotation, or the pinned file if one is in place):
|
||||
|
||||
```sh
|
||||
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
|
||||
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
|
||||
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
|
||||
for (const x of buildRotation(new Date()).Challenges)
|
||||
console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
|
||||
```
|
||||
|
||||
Pass a date to look at any other week — the rotation is a pure function of which week it is.
|
||||
|
||||
## Node types (`ct`) — the full enum
|
||||
|
||||
**(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured
|
||||
@@ -293,7 +305,7 @@ On `updateProgress` the client posts the same tree back with its own progress wr
|
||||
it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
|
||||
marks a node it now considers satisfied.
|
||||
|
||||
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
|
||||
Neither belongs in an authored tree — they are progress, not definition. The server
|
||||
stores the posted tree per player (`challenge_status.config`; see
|
||||
`apps/econ/src/challenge-db.ts`) and `getCurrent` serves it back in place of the authored
|
||||
tree, which is how a half-finished challenge survives a session — but it still evaluates
|
||||
@@ -324,7 +336,30 @@ all worth checking before trusting a lib-only field:
|
||||
- **`SpawnableToolTypes` re-rolls per build**, so `ct:5` and any `t_t` comparison is pinned
|
||||
to one client version.
|
||||
|
||||
## Authoring a new challenge
|
||||
## Adding to the generator
|
||||
|
||||
This is the usual way a new challenge ships: the week picks from `CHALLENGE_ROOMS` in
|
||||
`apps/econ/src/challenge-rotation.ts`, so a room added there starts appearing in rotations
|
||||
on its own.
|
||||
|
||||
1. **A new room** — add an entry with its `UnitySceneId`(s) from
|
||||
`apps/rooms/static/ImportRooms.json`. A scene no room on this server hosts can never be
|
||||
completed and nothing will tell you. Check the shared-scene table above and record the
|
||||
extra rooms in `shares`. Set `kinds` conservatively: `win` reads the `won` variable, so
|
||||
only rooms where winning is a real outcome; `ai` is quests. **Append, never insert** —
|
||||
`ChallengeId` is the candidate's index, so inserting renumbers every challenge after it.
|
||||
2. **A new kind** — add it to `ChallengeKind` and give it a branch in all three of
|
||||
`configFor` (the tree), `copyFor` (the strings) and `nameFor` (the slug). The compiler
|
||||
will point at the two you forget. Build the tree from an idiom below; the copy is
|
||||
generated from the same inputs so it can't drift out of step with the tree.
|
||||
3. Keep the target constants (`GAMES_TARGET`, `AI_TARGET`) as the single source for both the
|
||||
tree and the copy.
|
||||
|
||||
## Authoring a pinned rotation
|
||||
|
||||
For a one-off week: put challenges in `apps/econ/static/weekly-challenge.json` and the file
|
||||
takes over completely — generation is skipped, and its `Gift`, window and `ChallengeMapId`
|
||||
are served as written.
|
||||
|
||||
1. Pick the idiom: one-shot (`ct:0` root, add the `won` predicate if winning is required),
|
||||
counted (`ct:1` root with `t`), or buffered/streak (`ct:2` root, `rc` on the child).
|
||||
@@ -340,21 +375,24 @@ all worth checking before trusting a lib-only field:
|
||||
client renders the strings and evaluates the tree independently, so a mismatch ships a
|
||||
challenge that advances somewhere the text never mentions.
|
||||
6. Leave `Complete: false`; `getCurrent` stamps it per caller.
|
||||
7. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
|
||||
and a new map id is what resets stored completions.
|
||||
7. Set a `ChallengeMapId` that no recent week has used — a new map id is what resets stored
|
||||
completions, and generated weeks are `1000 + weekIndex`, so stay well clear of that range.
|
||||
8. Keep `ServerTime` inside `StartAt`…`EndAt`, or the client renders the rotation as expired.
|
||||
A pinned file is static, so its clock has to be frozen there; a generated week doesn't,
|
||||
because its window is really the current one.
|
||||
|
||||
Sanity check the file parses and every tree parses:
|
||||
Sanity check that every tree in this week's rotation parses, pinned or generated:
|
||||
|
||||
```sh
|
||||
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
|
||||
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)'
|
||||
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
|
||||
const c=buildRotation(new Date());
|
||||
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.ChallengeMapId, c.Challenges.length)'
|
||||
```
|
||||
|
||||
Then `bun vitest run apps/econ` — `src/test/integration/api.test.ts` imports the file and
|
||||
asserts `getCurrent` against it. Note the gift threshold follows the rotation size
|
||||
(`CHALLENGES_REQUIRED_FOR_GIFT` clamps to what you publish), so a rotation of three or fewer
|
||||
asks for all of them.
|
||||
Then `bun vitest run apps/econ` — `src/test/integration/api.test.ts` builds the same rotation
|
||||
and asserts `getCurrent` against it, and walks two years of generated weeks checking every
|
||||
tree. Note the gift threshold follows the rotation size (`CHALLENGES_REQUIRED_FOR_GIFT`
|
||||
clamps to what you publish), so a pinned rotation of three or fewer asks for all of them.
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
+81
-45
@@ -220,50 +220,78 @@ parses it to finish the action, so a bare 200 reads as a failure and the item ne
|
||||
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
|
||||
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
|
||||
|
||||
## Weekly challenge (`static/weekly-challenge.json`)
|
||||
## Weekly challenge (`src/challenge-rotation.ts`)
|
||||
|
||||
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.
|
||||
and `Config` 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 a rotation is the
|
||||
entire definition of a week's challenges — ids, display strings, matching rules and the
|
||||
reward preview.
|
||||
|
||||
**Rotations are generated from the calendar week, not authored.** `buildRotation(now)` in
|
||||
`src/challenge-rotation.ts` derives everything from the week index: five challenges drawn
|
||||
from a pool of rooms crossed with the challenge kinds each room supports, the week's window,
|
||||
and a `ChallengeMapId` of `1000 + weekIndex`. It is a pure function of which week it is, and
|
||||
that is load-bearing rather than tidy — `challenge_status` rows are scoped by
|
||||
`ChallengeMapId` and the gift threshold counts completions against `Challenges`, so two
|
||||
callers who disagreed about what the week holds would disagree about who had finished it.
|
||||
Selection runs off a seeded PRNG (mulberry32 over the week index); nothing calls
|
||||
`Math.random()`.
|
||||
|
||||
The week rolls at **Wednesday 21:00 UTC**, the boundary both captured rotations sit on,
|
||||
counted from an epoch of 2020-01-01. Each week publishes five challenges, no room twice and
|
||||
at most two of any one kind, so a week is never five variations of "finish some games".
|
||||
|
||||
| Kind | Asks for | Target | Rooms |
|
||||
| ------- | ---------------------------------------- | ------ | --------------------------------------- |
|
||||
| `games` | Finished games in one room | 5 | Head-to-head and score-based rooms |
|
||||
| `win` | One finished game, won (or quest closed) | 1 | Quests, plus rooms with a real opponent |
|
||||
| `ai` | Enemies defeated in one room | 10 | Quests — the rooms with enemies in them |
|
||||
|
||||
`static/weekly-challenge.json` still ships and still **wins**: a non-empty `Challenges` array
|
||||
there pins the week to that hand-authored rotation and skips generation entirely, which is
|
||||
how a debug or event week gets served without a code change. Empty (as shipped), it supplies
|
||||
only what generation doesn't own — `CompletedRequired`, `FallbackGiftName` and
|
||||
`ChallengeThemeString`, plus the `Gift` block used as a fallback if the catalog can't be read.
|
||||
|
||||
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 file itself is edited
|
||||
freely as rotations change — the examples here are the captured week, so expect the shipped
|
||||
rotation to differ.
|
||||
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 field notes describe both the
|
||||
generated rotation and the pinned file, since they are the same shape on the wire.
|
||||
|
||||
### Top level
|
||||
|
||||
| Field | Example | Notes |
|
||||
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChallengeMapId` | `17` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`; bump it when you publish a new week. |
|
||||
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
|
||||
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
|
||||
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this, so it is **frozen** — see below. |
|
||||
| `Challenges` | array | The week's challenges, rendered in order. |
|
||||
| `Gift` | object | The reward preview for finishing the set. |
|
||||
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
|
||||
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
|
||||
| Field | Example | Notes |
|
||||
| ---------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChallengeMapId` | `1347` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`. Generated as `1000 + weekIndex`; the four-digit floor keeps it clear of hand-authored ids (17, 19), which would otherwise read a player's old rows as progress against a different set. |
|
||||
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
|
||||
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
|
||||
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this. Generated rotations send the REAL clock — see below. |
|
||||
| `Challenges` | array | The week's challenges, rendered in order. |
|
||||
| `Gift` | object | The reward preview for finishing the set. |
|
||||
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
|
||||
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
|
||||
|
||||
**The frozen clock:** `ServerTime` sits _inside_ `StartAt`…`EndAt`, about a day before the
|
||||
end, and the file is static — so the client always sees an active rotation with a ~1-day
|
||||
countdown rather than an expired one (the captured week: Mar 31 inside Mar 25 → Apr 1). If
|
||||
you edit the window, move `ServerTime` inside the new one too, or the challenges may render
|
||||
as already over.
|
||||
**The clock:** a generated rotation's window is genuinely the current week, so `ServerTime`
|
||||
is simply now and the countdown the client draws is real — the challenges expire on Wednesday
|
||||
at 21:00 UTC and the next week's set replaces them.
|
||||
|
||||
That is the thing generation fixes. A **pinned** rotation is static, so its `ServerTime` has
|
||||
to be _frozen_ inside `StartAt`…`EndAt` — a day before the end is the captured shape (Mar 31
|
||||
inside Mar 25 → Apr 1) — or the client renders the week as already over. Move the window on a
|
||||
pinned rotation and you must move `ServerTime` into it too.
|
||||
|
||||
### A challenge entry
|
||||
|
||||
| Field | Notes |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. |
|
||||
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
|
||||
| `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 always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. |
|
||||
| Field | Notes |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. Generated ids are the index of the (room, kind) pair in the generator's candidate list, so id 12 is always the same challenge — append rooms, never insert. |
|
||||
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
|
||||
| `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 always `false` as generated — `getCurrent` overwrites it per caller from `challenge_status`, along with `Config`. |
|
||||
|
||||
`^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
|
||||
@@ -293,21 +321,29 @@ of avatar-item guids, `AvatarItemType`, `ConsumableItemDesc`, `EquipmentPrefabNa
|
||||
**renamed**: a storefront's `Context`/`Rarity` are `GiftContext`/`GiftRarity` here. Don't
|
||||
feed one shape to the other's reader.
|
||||
|
||||
`EquipmentModificationGuid` is the Rec Room packed guid — 22-char URL-safe base64 of the 16
|
||||
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q` →
|
||||
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid,
|
||||
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in
|
||||
`sf3.json` as `2121` ("Camera Skin (Comic)").
|
||||
**Weekly rewards are equipment**, so a generated week rolls one from sf3 — every item there
|
||||
carrying an `EquipmentModificationGuid` (187 of its 1161), drawn with the week's own seed.
|
||||
Drawing from the live catalog rather than a copied list is what lets the grant path resolve
|
||||
the pick back to the entry selling it, so the player receives a properly named item; the
|
||||
block a generated week emits carries that entry's `GiftDropId` and rarity.
|
||||
|
||||
`EquipmentModificationGuid` is sometimes the Rec Room packed guid — 22-char URL-safe base64
|
||||
of the 16 guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q` →
|
||||
`c1b49b83-4be3-409a-8b79-45c55159fbe1`) — and sometimes a plain guid; sf3 carries both forms
|
||||
and they are matched as opaque strings, never converted. The reward is identified by prefab +
|
||||
that guid, _not_ by `GiftDropId`: the captured block's `GiftDropId` is `3994`, while the same
|
||||
skin sells in `sf3.json` as `2121` ("Camera Skin (Comic)").
|
||||
|
||||
**Granted when the set is finished** — see below. The grant path is `buyItem`'s, so the
|
||||
block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
|
||||
`GiftContext`/`GiftRarity` are exactly what that translation is for.
|
||||
|
||||
The block carries no display strings and a `GiftRarity` of `0` for an item that sells at
|
||||
rarity `5`, so both are taken from the catalog entry selling the same item (matched on
|
||||
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it
|
||||
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the
|
||||
catalog if a rotation we publish sets them; neither is present in the captured one.
|
||||
The block carries no display strings (and the captured one carries a `GiftRarity` of `0` for
|
||||
an item that sells at rarity `5`), so both are taken from the catalog entry selling the same
|
||||
item, matched on equipment guid / avatar desc — the reward reads as "Camera Skin (Comic)",
|
||||
not as the box it might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block
|
||||
wins over the catalog if a pinned rotation sets them; neither is present in the captured one
|
||||
or in a generated block.
|
||||
|
||||
**`FallbackGiftName` is the other half of the reward, not just a label.** "4-Star Box" is
|
||||
what the player gets _instead_ when they already own the item — the real game phrased it
|
||||
@@ -319,8 +355,8 @@ consolation tier with no code change; a name that doesn't parse falls back to 4
|
||||
|
||||
There is no claim endpoint and the client never asks: the reward is handed out from the
|
||||
`updateProgress` call that reaches the threshold. Every completing report on the **live**
|
||||
rotation re-reads the caller's completions and, once enough of `weekly-challenge.json`'s
|
||||
challenges are there, grants the `Gift` the way a purchase grants a drop — the item into
|
||||
rotation re-reads the caller's completions and, once enough of the week's own challenges are
|
||||
there, grants the `Gift` the way a purchase grants a drop — the item into
|
||||
`inventory`/`equipment`/`consumable`, plus a gift box (message
|
||||
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
|
||||
|
||||
|
||||
@@ -116,9 +116,9 @@ export async function recordChallengeProgress(
|
||||
* reported anything against it.
|
||||
*
|
||||
* Read by `getCurrent` to overwrite the static rotation, and by the gift path: the `Gift` is
|
||||
* due once ENOUGH of the challenges in static/weekly-challenge.json are complete here —
|
||||
* three of the five a week publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT`
|
||||
* in econ.app.ts).
|
||||
* due once ENOUGH of the week's own challenges are complete here — three of the five a
|
||||
* rotation publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
|
||||
* The rotation itself is generated per week by src/challenge-rotation.ts.
|
||||
*/
|
||||
export async function getChallengeStatuses(
|
||||
db: D1Database,
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* The weekly challenge rotation, generated from the calendar week rather than authored.
|
||||
*
|
||||
* A week's rotation is a pure function of which week it is: the same five challenges, the
|
||||
* same window and the same gift for every player, recomputed identically by every isolate
|
||||
* and every request. That is not a nicety — `challenge_status` rows are scoped by
|
||||
* `ChallengeMapId` and the gift threshold counts completions against `Challenges`, so two
|
||||
* callers who disagreed about what this week holds would disagree about who has finished it.
|
||||
* Everything here therefore hangs off {@link rotationIndex} and a seeded PRNG; nothing calls
|
||||
* `Math.random()` or reads the clock except to work out which week it is.
|
||||
*
|
||||
* The client evaluates the rule trees and the server never does (see
|
||||
* .agents/skills/weekly-challenge-config/SKILL.md), so a generated tree is a specification
|
||||
* handed to a client that fails SILENTLY when it's malformed. Everything emitted here is
|
||||
* therefore built from the three idioms that are pinned by captured live data or by a
|
||||
* rotation this server has already served — no lib-only node types, no invented fields.
|
||||
*
|
||||
* `static/weekly-challenge.json` still ships, and still wins: a non-empty `Challenges` array
|
||||
* there PINS the week to that hand-authored rotation and skips generation entirely, which is
|
||||
* how a debug or event rotation gets served without a code change. When it is empty the file
|
||||
* supplies only the parts generation doesn't own — the fallback gift name, the theme string,
|
||||
* and `CompletedRequired`.
|
||||
*/
|
||||
|
||||
import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
|
||||
/** Rec Room's weekly reset: Wednesday 21:00 UTC, the boundary both captured rotations sit on. */
|
||||
const ROTATION_EPOCH_MS = Date.UTC(2020, 0, 1, 21, 0, 0)
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Where generated `ChallengeMapId`s start. Hand-authored rotations have used small ids (the
|
||||
* captured 17, this repo's 19), and a generated id that collided with one would let a
|
||||
* player's stored rows from that rotation read as progress against a completely different
|
||||
* set of challenges. A four-digit floor keeps the two id spaces from ever meeting.
|
||||
*/
|
||||
const CHALLENGE_MAP_ID_BASE = 1000
|
||||
|
||||
/** How many challenges a week publishes. Five is the captured rotation's size, and what the three-of-five gift threshold is written against. */
|
||||
const CHALLENGES_PER_ROTATION = 5
|
||||
|
||||
/**
|
||||
* How many challenges of one kind a week may hold. Five slots over three kinds with a cap of
|
||||
* two guarantees every kind appears, so no week is five variations of "finish some games".
|
||||
*/
|
||||
const MAX_PER_KIND = 2
|
||||
|
||||
/** `ct:6` event ids — `ChallengeEventTypes`. Only the two the captured/served trees use. */
|
||||
const EVENT_GAME_END = 2
|
||||
const EVENT_ELIMINATED_AI = 5
|
||||
|
||||
/** The targets each kind counts to. Fixed rather than rolled: a week should vary in WHAT it asks, not in how much. */
|
||||
const GAMES_TARGET = 5
|
||||
const AI_TARGET = 10
|
||||
|
||||
/** What a challenge asks for. Each maps to one proven `Config` idiom and one line of copy. */
|
||||
type ChallengeKind = 'games' | 'win' | 'ai'
|
||||
|
||||
/**
|
||||
* A room the generator may name, keyed by the scene(s) its games run in.
|
||||
*
|
||||
* `scenes` is what `ct:7` matches, and one scene can belong to several rooms (Soccer and
|
||||
* Stadium are one scene; Dodgeball, Gym and DodgeballVR are another) — `shares` records the
|
||||
* rooms a challenge naming this one also completes in, which is a property of the game data,
|
||||
* not something the tree can narrow. Entries are one per scene, so picking by room key also
|
||||
* keeps a week from naming one scene twice.
|
||||
*
|
||||
* `link` is the `^Token` the client resolves into a tappable room name; `null` where the room
|
||||
* name would make a doubtful token (Charades spans two scenes and starts with a digit) and
|
||||
* the copy falls back to plain text, which the captured rotation also does.
|
||||
*/
|
||||
interface ChallengeRoom {
|
||||
/** Stable key — also the slug fragment in a generated `Name`. */
|
||||
key: string
|
||||
/** Plain display name, used when `link` is null. */
|
||||
name: string
|
||||
/** The `^Token` room link, or null to write the name plainly. */
|
||||
link: string | null
|
||||
/** `UnitySceneId`s this room's games run in, straight from apps/rooms/static/ImportRooms.json. */
|
||||
scenes: string[]
|
||||
/** The kinds this room can be asked for — quests have enemies to defeat, hangouts have no games at all. */
|
||||
kinds: ChallengeKind[]
|
||||
/** True for the quest rooms, whose "win" is completing the quest rather than beating other players. */
|
||||
quest?: boolean
|
||||
/** Other rooms on the same scene, which a challenge naming this one also completes in. */
|
||||
shares?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The rooms in play. Every scene id here resolves in `apps/rooms/static/ImportRooms.json` —
|
||||
* a challenge naming a scene this server hosts no room for can never be completed by anyone,
|
||||
* and nothing server-side would report that.
|
||||
*
|
||||
* `kinds` is deliberately conservative. `win` reads the `won` session variable, which is
|
||||
* pinned by the captured rotation for quests and is meaningful in a head-to-head game, so
|
||||
* rooms where "winning" is vague (bowling, disc golf, charades, Stunt Runner) only ever ask
|
||||
* for completed games. `ai` is quests only: they are the rooms with enemies in them.
|
||||
*/
|
||||
const CHALLENGE_ROOMS: ChallengeRoom[] = [
|
||||
// Quests — win the quest, or thin out its enemies.
|
||||
{
|
||||
key: 'GoldenTrophy',
|
||||
name: 'Quest for the Golden Trophy',
|
||||
link: '^GoldenTrophy',
|
||||
scenes: ['91e16e35-f48f-4700-ab8a-a1b79e50e51b'],
|
||||
kinds: ['win', 'ai'],
|
||||
quest: true,
|
||||
},
|
||||
{
|
||||
key: 'Jumbotron',
|
||||
name: 'The Rise of Jumbotron',
|
||||
link: '^TheRiseofJumbotron',
|
||||
scenes: ['acc06e66-c2d0-4361-b0cd-46246a4c455c'],
|
||||
kinds: ['win', 'ai'],
|
||||
quest: true,
|
||||
},
|
||||
{
|
||||
key: 'CrimsonCauldron',
|
||||
name: 'Curse of the Crimson Cauldron',
|
||||
link: '^CrimsonCauldron',
|
||||
scenes: ['949fa41f-4347-45c0-b7ac-489129174045'],
|
||||
kinds: ['win', 'ai'],
|
||||
quest: true,
|
||||
},
|
||||
{
|
||||
key: 'IsleOfLostSkulls',
|
||||
name: 'The Isle of Lost Skulls',
|
||||
link: '^IsleOfLostSkulls',
|
||||
scenes: ['7e01cfe0-820a-406f-b1b3-0a5bf575235c'],
|
||||
kinds: ['win', 'ai'],
|
||||
quest: true,
|
||||
},
|
||||
{
|
||||
key: 'Crescendo',
|
||||
name: 'Crescendo of the Blood Moon',
|
||||
link: '^Crescendo',
|
||||
scenes: ['49cb8993-a956-43e2-86f4-1318f279b22a'],
|
||||
kinds: ['win', 'ai'],
|
||||
quest: true,
|
||||
},
|
||||
|
||||
// Head-to-head rooms — finish games, or win one.
|
||||
{
|
||||
key: 'Clearcut',
|
||||
name: 'Paintball: Clear Cut',
|
||||
link: '^Paintball.Clearcut',
|
||||
scenes: ['380d18b5-de9c-49f3-80f7-f4a95c1de161'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'PaintballVR/Clearcut, Clearcut/Home',
|
||||
},
|
||||
{
|
||||
key: 'River',
|
||||
name: 'Paintball: River',
|
||||
link: '^Paintball.River',
|
||||
scenes: ['e122fe98-e7db-49e8-a1b1-105424b6e1f0'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'PaintballVR/River, River/Home',
|
||||
},
|
||||
{
|
||||
key: 'Homestead',
|
||||
name: 'Paintball: Homestead',
|
||||
link: '^Paintball.Homestead',
|
||||
scenes: ['a785267d-c579-42ea-be43-fec1992d1ca7'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'PaintballVR/Homestead, Homestead/Home',
|
||||
},
|
||||
{
|
||||
key: 'Quarry',
|
||||
name: 'Paintball: Quarry',
|
||||
link: '^Paintball.Quarry',
|
||||
scenes: ['ff4c6427-7079-4f59-b22a-69b089420827'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'PaintballVR/Quarry, Quarry/Home',
|
||||
},
|
||||
{
|
||||
key: 'Spillway',
|
||||
name: 'Paintball: Spillway',
|
||||
link: '^Paintball.Spillway',
|
||||
scenes: ['58763055-2dfb-4814-80b8-16fac5c85709'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'PaintballVR/Spillway, Spillway/Home',
|
||||
},
|
||||
{
|
||||
key: 'Dodgeball',
|
||||
name: 'Dodgeball',
|
||||
link: '^Dodgeball',
|
||||
scenes: ['3d474b26-26f7-45e9-9a36-9b02847d5e6f'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'Gym/Home, DodgeballVR/Home',
|
||||
},
|
||||
{
|
||||
key: 'Soccer',
|
||||
name: 'Soccer',
|
||||
link: '^Soccer',
|
||||
scenes: ['6d5eea4b-f069-4ed0-9916-0e2f07df0d03'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'Stadium/Home',
|
||||
},
|
||||
{
|
||||
key: 'Hangar',
|
||||
name: 'Laser Tag: Hangar',
|
||||
link: '^LaserTag.Hangar',
|
||||
scenes: ['239e676c-f12f-489f-bf3a-d4c383d692c3'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'Hangar/Home',
|
||||
},
|
||||
{
|
||||
key: 'CyberJunkCity',
|
||||
name: 'Laser Tag: CyberJunk City',
|
||||
link: '^LaserTag.CyberJunkCity',
|
||||
scenes: ['9d6456ce-6264-48b4-808d-2d96b3d91038'],
|
||||
kinds: ['games', 'win'],
|
||||
shares: 'LaserTagCyberJunk/Home, CyberJunkCity/Home',
|
||||
},
|
||||
{
|
||||
key: 'Paddleball',
|
||||
name: 'Paddleball',
|
||||
link: '^Paddleball',
|
||||
scenes: ['d89f74fa-d51e-477a-a425-025a891dd499'],
|
||||
kinds: ['games', 'win'],
|
||||
},
|
||||
{
|
||||
key: 'FrontierSolos',
|
||||
name: 'Rec Royale: Solos',
|
||||
link: '^RecRoyaleSolos',
|
||||
scenes: ['b010171f-4875-4e89-baba-61e878cd41e1'],
|
||||
kinds: ['games', 'win'],
|
||||
},
|
||||
{
|
||||
key: 'FrontierSquads',
|
||||
name: 'Rec Royale: Squads',
|
||||
link: '^RecRoyaleSquads',
|
||||
scenes: ['253fa009-6e65-4c90-91a1-7137a56a267f'],
|
||||
kinds: ['games', 'win'],
|
||||
},
|
||||
|
||||
// Rooms where finishing is the whole ask — "winning" one of these isn't a thing the
|
||||
// `won` variable is known to report.
|
||||
{
|
||||
key: 'Bowling',
|
||||
name: 'Bowling',
|
||||
link: '^Bowling',
|
||||
scenes: ['ae929543-9a07-41d5-8ee9-dbbee8c36800'],
|
||||
kinds: ['games'],
|
||||
shares: 'BowlingAlley/Home',
|
||||
},
|
||||
{
|
||||
key: 'DiscGolfLake',
|
||||
name: 'Disc Golf: Lake',
|
||||
link: '^DiscGolfLake',
|
||||
scenes: ['f6f7256c-e438-4299-b99e-d20bef8cf7e0'],
|
||||
kinds: ['games'],
|
||||
shares: 'Lake/Home',
|
||||
},
|
||||
{
|
||||
key: 'DiscGolfPropulsion',
|
||||
name: 'Disc Golf: Propulsion',
|
||||
link: '^DiscGolfPropulsion',
|
||||
scenes: ['d9378c9f-80bc-46fb-ad1e-1bed8a674f55'],
|
||||
kinds: ['games'],
|
||||
shares: 'PropulsionTestRange/Home',
|
||||
},
|
||||
{
|
||||
key: 'Charades',
|
||||
name: 'Charades',
|
||||
link: null,
|
||||
// Both charades scenes, as the captured rotation's own charades challenge does.
|
||||
scenes: ['a673712c-877f-4749-b69a-4a4c6310d545', '4078dfed-24bb-4db7-863f-578ba48d726b'],
|
||||
kinds: ['games'],
|
||||
shares: '3DCharades/InkSpaceHome, Legacy3DCharades/Home',
|
||||
},
|
||||
{
|
||||
key: 'StuntRunner',
|
||||
name: 'Stunt Runner',
|
||||
link: '^StuntRunner',
|
||||
scenes: ['b7281665-a715-4051-826b-8e08e69c6172'],
|
||||
kinds: ['games'],
|
||||
},
|
||||
]
|
||||
|
||||
/** One challenge as the rotation serves it — `Complete` is stamped per caller by `getCurrent`. */
|
||||
export interface RotationChallenge {
|
||||
ChallengeId: number
|
||||
Name: string
|
||||
Config: string
|
||||
Description: string
|
||||
Tooltip: string
|
||||
Complete: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The rotation's reward block. Same item vocabulary as a storefront gift drop, but with
|
||||
* `Context`/`Rarity` spelled `GiftContext`/`GiftRarity` — the two shapes are not
|
||||
* interchangeable, see `toChallengeGiftDrop` in econ.app.ts.
|
||||
*/
|
||||
export interface ChallengeGiftBlock {
|
||||
GiftDropId: number
|
||||
AvatarItemDesc: string
|
||||
AvatarItemType: number
|
||||
ConsumableItemDesc: string
|
||||
EquipmentPrefabName: string
|
||||
EquipmentModificationGuid: string
|
||||
StorefrontType: number
|
||||
Xp: number
|
||||
Level: number
|
||||
GiftContext: number
|
||||
GiftRarity: number
|
||||
/**
|
||||
* Display strings, OPTIONAL because neither the captured rotation nor a generated block
|
||||
* carries them — the reward's name is resolved from the catalog entry selling the same
|
||||
* item, falling back to `FallbackGiftName`. A pinned rotation can set them to name its
|
||||
* reward outright.
|
||||
*/
|
||||
FriendlyName?: string
|
||||
Tooltip?: string
|
||||
}
|
||||
|
||||
/** A week's whole rotation — the body `GET /api/challenge/v2/getCurrent` serves. */
|
||||
export interface WeeklyChallengeRotation {
|
||||
ChallengeMapId: number
|
||||
CompletedRequired: boolean
|
||||
StartAt: string
|
||||
EndAt: string
|
||||
ServerTime: string
|
||||
Challenges: RotationChallenge[]
|
||||
Gift: ChallengeGiftBlock
|
||||
FallbackGiftName: string
|
||||
ChallengeThemeString: string
|
||||
}
|
||||
|
||||
/** An equipment item the weekly gift can be drawn from — one sf3 entry, trimmed to what a gift needs. */
|
||||
export interface EquipmentGift {
|
||||
GiftDropId: number
|
||||
EquipmentPrefabName: string
|
||||
EquipmentModificationGuid: string
|
||||
Rarity: number
|
||||
}
|
||||
|
||||
/** Whether the shipped file pins the week, in which case nothing here is generated. */
|
||||
function pinnedRotation(): WeeklyChallengeRotation | null {
|
||||
return weeklyChallenge.Challenges.length > 0 ? (weeklyChallenge as WeeklyChallengeRotation) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Which week it is: whole weeks since the epoch, so the value ticks over at Wednesday 21:00
|
||||
* UTC and every caller in the same week gets the same number.
|
||||
*/
|
||||
export function rotationIndex(now: Date): number {
|
||||
return Math.floor((now.getTime() - ROTATION_EPOCH_MS) / WEEK_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* This week's `ChallengeMapId` — the identity of the rotation, and what makes a stored
|
||||
* completion belong to one week rather than another. Cheap on purpose: `updateProgress` asks
|
||||
* only this to decide whether a report is against the live week.
|
||||
*/
|
||||
export function rotationMapId(now: Date): number {
|
||||
return pinnedRotation()?.ChallengeMapId ?? CHALLENGE_MAP_ID_BASE + rotationIndex(now)
|
||||
}
|
||||
|
||||
/** The week's window, as the client's `StartAt`/`EndAt` want it: UTC, but written without a zone. */
|
||||
function rotationWindow(index: number): { StartAt: string; EndAt: string } {
|
||||
const start = ROTATION_EPOCH_MS + index * WEEK_MS
|
||||
return {
|
||||
StartAt: toLocalIsoString(new Date(start)),
|
||||
EndAt: toLocalIsoString(new Date(start + WEEK_MS)),
|
||||
}
|
||||
}
|
||||
|
||||
/** `2026-08-19T21:00:00` — ISO 8601 with the milliseconds and the `Z` cut off, which is the shape the client's window fields take. */
|
||||
function toLocalIsoString(at: Date): string {
|
||||
return at.toISOString().slice(0, 19)
|
||||
}
|
||||
|
||||
/**
|
||||
* `2026-08-25T14:42:54.2754728Z` — .NET's round-trip format, seven fractional digits. The
|
||||
* client dates its countdown off this, and because a generated window is genuinely the
|
||||
* current one, this is the real clock rather than the frozen timestamp a static file needs.
|
||||
*/
|
||||
function toDotNetString(at: Date): string {
|
||||
return `${at.toISOString().slice(0, -1)}0000Z`
|
||||
}
|
||||
|
||||
/** mulberry32 — a small deterministic PRNG. Same seed, same week, same rotation, everywhere. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spread a week index into a seed. Adjacent weeks are adjacent integers, and feeding those
|
||||
* straight in makes consecutive rotations correlate; a multiply by a large odd constant
|
||||
* (Knuth's) scatters them.
|
||||
*/
|
||||
function seedFor(mapId: number, salt: number): number {
|
||||
return Math.imul(mapId ^ salt, 2654435761) >>> 0
|
||||
}
|
||||
|
||||
/** In-place Fisher-Yates against a seeded stream — the only place ordering comes from. */
|
||||
function shuffle<T>(items: T[], random: () => number): T[] {
|
||||
const out = [...items]
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1))
|
||||
const a = out[i] as T
|
||||
const b = out[j] as T
|
||||
out[i] = b
|
||||
out[j] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A `ct:7` scene allow-list node. */
|
||||
function sceneNode(scenes: string[]) {
|
||||
return { ct: 7, vs: scenes.map((l) => ({ l })) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule tree for one (kind, room), as an object — stringified into `Config` by the caller.
|
||||
* Each branch is one of the three idioms in the skill doc, with the field order the captured
|
||||
* trees use:
|
||||
*
|
||||
* - `games` — a counter over finished games in the room (`ct:1` + `GameEnd` + scene).
|
||||
* - `win` — one finished game in the room that the player won (`ct:0` + `GameEnd` + `won` + scene).
|
||||
* - `ai` — a counter over enemies defeated in the room (`ct:1` + `EliminatedAI` + scene).
|
||||
*/
|
||||
function configFor(kind: ChallengeKind, room: ChallengeRoom): unknown {
|
||||
const scene = sceneNode(room.scenes)
|
||||
switch (kind) {
|
||||
case 'games':
|
||||
return {
|
||||
ct: 1,
|
||||
ipc: false,
|
||||
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_GAME_END] }, scene] }],
|
||||
t: GAMES_TARGET,
|
||||
}
|
||||
case 'win':
|
||||
return {
|
||||
ct: 0,
|
||||
ipc: false,
|
||||
wc: [{ ct: 6, vs: [EVENT_GAME_END] }, { ct: 9, vs: [true], v: 'won' }, scene],
|
||||
}
|
||||
case 'ai':
|
||||
return {
|
||||
ct: 1,
|
||||
ipc: false,
|
||||
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_ELIMINATED_AI] }, scene] }],
|
||||
t: AI_TARGET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy for one (kind, room). Generated from the same two inputs as the tree, which is
|
||||
* the point: the client renders these strings and evaluates the tree independently, so
|
||||
* hand-written copy is free to drift into describing a challenge that doesn't exist.
|
||||
*/
|
||||
function copyFor(
|
||||
kind: ChallengeKind,
|
||||
room: ChallengeRoom
|
||||
): { Description: string; Tooltip: string } {
|
||||
const where = room.link ?? room.name
|
||||
switch (kind) {
|
||||
case 'games':
|
||||
return {
|
||||
Description: `Complete ${GAMES_TARGET} games in ${where}`,
|
||||
Tooltip: `Play ${GAMES_TARGET} games of ${room.name} through to the end. Winning is optional.`,
|
||||
}
|
||||
case 'win':
|
||||
return room.quest === true
|
||||
? {
|
||||
Description: `Complete the ${where} quest`,
|
||||
Tooltip: `See ${room.name} through to a win.`,
|
||||
}
|
||||
: {
|
||||
Description: `Win a game in ${where}`,
|
||||
Tooltip: `Come out on top of a game of ${room.name}.`,
|
||||
}
|
||||
case 'ai':
|
||||
return {
|
||||
Description: `Defeat ${AI_TARGET} enemies in ${where}`,
|
||||
Tooltip: `Take out ${AI_TARGET} enemies in ${room.name}. They don't have to be in one run.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The internal slug — never displayed, but it's what a log line or a D1 row is read against. */
|
||||
function nameFor(kind: ChallengeKind, room: ChallengeRoom): string {
|
||||
switch (kind) {
|
||||
case 'games':
|
||||
return `Complete${GAMES_TARGET}Games${room.key}`
|
||||
case 'win':
|
||||
return `Win${room.key}`
|
||||
case 'ai':
|
||||
return `Defeat${AI_TARGET}AI${room.key}`
|
||||
}
|
||||
}
|
||||
|
||||
/** One thing the generator may publish: a room crossed with a kind that room supports. */
|
||||
interface Candidate {
|
||||
challengeId: number
|
||||
kind: ChallengeKind
|
||||
room: ChallengeRoom
|
||||
}
|
||||
|
||||
/**
|
||||
* Every (room, kind) pair, in a fixed order — the index in this list IS the challenge id.
|
||||
*
|
||||
* Deriving the id from the pair rather than from the position in a week keeps ids meaningful
|
||||
* across weeks: id 12 is always "win in Dodgeball", so a `challenge_status` row that outlives
|
||||
* its rotation is at worst stale, never a different challenge wearing the same id. Ids are
|
||||
* only required to be unique within a rotation, which distinct pairs trivially are.
|
||||
*
|
||||
* Appending to `CHALLENGE_ROOMS` is safe; INSERTING into the middle renumbers everything
|
||||
* after it, so add rooms at the end.
|
||||
*/
|
||||
const CANDIDATES: Candidate[] = CHALLENGE_ROOMS.flatMap((room) =>
|
||||
room.kinds.map((kind) => ({ challengeId: 0, kind, room }))
|
||||
).map((candidate, index) => ({ ...candidate, challengeId: index + 1 }))
|
||||
|
||||
/**
|
||||
* Pick the week's challenges: shuffle every candidate, then take the first five that keep
|
||||
* one room out of two slots and one kind out of three. The relaxation pass exists so the
|
||||
* constraints can never under-deliver a rotation — a short week would quietly lower the gift
|
||||
* threshold, since it clamps to what's published.
|
||||
*/
|
||||
function pickChallenges(random: () => number): RotationChallenge[] {
|
||||
const shuffled = shuffle(CANDIDATES, random)
|
||||
const picked: Candidate[] = []
|
||||
const rooms = new Set<string>()
|
||||
const kinds = new Map<ChallengeKind, number>()
|
||||
for (const pass of [0, 1]) {
|
||||
for (const candidate of shuffled) {
|
||||
if (picked.length === CHALLENGES_PER_ROTATION) break
|
||||
if (rooms.has(candidate.room.key)) continue
|
||||
if (pass === 0 && (kinds.get(candidate.kind) ?? 0) >= MAX_PER_KIND) continue
|
||||
picked.push(candidate)
|
||||
rooms.add(candidate.room.key)
|
||||
kinds.set(candidate.kind, (kinds.get(candidate.kind) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return picked.map(({ challengeId, kind, room }) => ({
|
||||
ChallengeId: challengeId,
|
||||
Name: nameFor(kind, room),
|
||||
Config: JSON.stringify(configFor(kind, room)),
|
||||
...copyFor(kind, room),
|
||||
Complete: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The week's gift: one equipment item, drawn from the pool with the week's own seed.
|
||||
*
|
||||
* Weekly rewards are equipment — the captured rotation's is a camera skin — so the pool is
|
||||
* every sf3 item carrying an `EquipmentModificationGuid`. Drawing from the live catalog
|
||||
* rather than a copied list is what lets `toChallengeGiftDrop` resolve the pick back to the
|
||||
* entry selling it and hand the player a properly named item.
|
||||
*
|
||||
* Null when the pool is empty (the catalog didn't load), and the caller keeps the static
|
||||
* file's block so the reward preview is still something rather than nothing.
|
||||
*/
|
||||
function pickWeeklyGift(mapId: number, pool: EquipmentGift[]): ChallengeGiftBlock | null {
|
||||
if (pool.length === 0) return null
|
||||
const random = mulberry32(seedFor(mapId, 0x9e3779b9))
|
||||
const gift = pool[Math.floor(random() * pool.length)] as EquipmentGift
|
||||
return {
|
||||
GiftDropId: gift.GiftDropId,
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: 0,
|
||||
ConsumableItemDesc: '',
|
||||
EquipmentPrefabName: gift.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: gift.EquipmentModificationGuid,
|
||||
StorefrontType: 0,
|
||||
Xp: 0,
|
||||
Level: 0,
|
||||
GiftContext: 0,
|
||||
GiftRarity: gift.Rarity,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoised generation. The rotation is identical for every caller in a week, so it is built
|
||||
* once per isolate per week rather than per request; `ServerTime` is the one field that
|
||||
* moves, and it is written fresh on the way out.
|
||||
*
|
||||
* The cached object is never handed out directly for that reason, and callers that
|
||||
* personalise it (`getCurrent` stamping per-player state) rebuild rather than mutate.
|
||||
*/
|
||||
let cached: WeeklyChallengeRotation | null = null
|
||||
|
||||
/**
|
||||
* This week's rotation, with the static file's `Gift` as a placeholder — callers that can
|
||||
* read the catalog replace it with {@link pickWeeklyGift}. Pure apart from `now`: same week
|
||||
* in, same rotation out.
|
||||
*/
|
||||
export function buildRotation(now: Date): WeeklyChallengeRotation {
|
||||
const pinned = pinnedRotation()
|
||||
if (pinned !== null) return pinned
|
||||
const index = rotationIndex(now)
|
||||
const mapId = CHALLENGE_MAP_ID_BASE + index
|
||||
if (cached === null || cached.ChallengeMapId !== mapId) {
|
||||
cached = {
|
||||
ChallengeMapId: mapId,
|
||||
CompletedRequired: weeklyChallenge.CompletedRequired,
|
||||
...rotationWindow(index),
|
||||
ServerTime: '',
|
||||
Challenges: pickChallenges(mulberry32(seedFor(mapId, 0))),
|
||||
Gift: weeklyChallenge.Gift as ChallengeGiftBlock,
|
||||
FallbackGiftName: weeklyChallenge.FallbackGiftName,
|
||||
ChallengeThemeString: weeklyChallenge.ChallengeThemeString,
|
||||
}
|
||||
}
|
||||
return { ...cached, ServerTime: toDotNetString(now) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the week's own reward on a rotation. Separate from {@link buildRotation} because the
|
||||
* pool comes from the storefront catalog, which is an async read the cheap paths
|
||||
* (`updateProgress` deciding whether a report is against the live week) have no reason to pay.
|
||||
*
|
||||
* A PINNED rotation is returned untouched: it ships its own `Gift`, and overwriting that with
|
||||
* a rolled one would make the pin a half-pin.
|
||||
*/
|
||||
export function withWeeklyGift(
|
||||
rotation: WeeklyChallengeRotation,
|
||||
pool: EquipmentGift[]
|
||||
): WeeklyChallengeRotation {
|
||||
if (pinnedRotation() !== null) return rotation
|
||||
const gift = pickWeeklyGift(rotation.ChallengeMapId, pool)
|
||||
return gift === null ? rotation : { ...rotation, Gift: gift }
|
||||
}
|
||||
+93
-74
@@ -33,7 +33,6 @@ import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
import defaultAvatar from '../static/default-avatar.json'
|
||||
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
||||
import myProgress from '../static/my-progress.json'
|
||||
import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
import { getAvatar, setAvatar } from './avatar-db'
|
||||
import {
|
||||
ALL_PLATFORMS,
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
spendCurrency,
|
||||
} from './balance-db'
|
||||
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
|
||||
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
|
||||
import {
|
||||
consumeConsumable,
|
||||
countConsumable,
|
||||
@@ -105,6 +105,11 @@ import type {
|
||||
PurchaseBalanceModificationPayload,
|
||||
} from '../../notify/src/notification-payloads'
|
||||
import type { Avatar } from './avatar-db'
|
||||
import type {
|
||||
ChallengeGiftBlock,
|
||||
EquipmentGift,
|
||||
WeeklyChallengeRotation,
|
||||
} from './challenge-rotation'
|
||||
import type { ConsumeResult } from './consumables-db'
|
||||
import type { App } from './context'
|
||||
import type { Equipment } from './equipment-db'
|
||||
@@ -680,6 +685,40 @@ async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
|
||||
return storefront?.StoreItems ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* The equipment a weekly challenge gift can be drawn from: every roll-catalog item carrying
|
||||
* an `EquipmentModificationGuid`. Weekly rewards are equipment — the captured rotation's is a
|
||||
* camera skin — and in sf3 that guid is exactly what marks an item as equipment (187 of its
|
||||
* 1161, all with a prefab, none with an avatar item or consumable attached).
|
||||
*
|
||||
* `GiftDropId` comes off `PurchasableItemId`, which every sf3 equipment entry agrees with.
|
||||
*/
|
||||
function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] {
|
||||
return catalog
|
||||
.filter((item) => item.GiftDrop.EquipmentModificationGuid !== '')
|
||||
.map((item) => ({
|
||||
GiftDropId: item.PurchasableItemId,
|
||||
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
|
||||
Rarity: item.GiftDrop.Rarity,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The same pool, memoised for the life of the isolate. `getCurrent` needs it on every call
|
||||
* just to show the week's reward, and sf3 is a megabyte and a half of JSON to fetch and parse
|
||||
* — but it is a bundled asset, so it cannot change under a running isolate and a deploy
|
||||
* builds new ones. A failed read is deliberately NOT cached: it would pin an empty pool (and
|
||||
* so the static fallback gift) until the next deploy.
|
||||
*/
|
||||
let cachedGiftPool: EquipmentGift[] | null = null
|
||||
async function loadEquipmentGiftPool(c: Context<App>): Promise<EquipmentGift[]> {
|
||||
if (cachedGiftPool !== null) return cachedGiftPool
|
||||
const pool = toEquipmentGiftPool(await loadRollCatalog(c))
|
||||
if (pool.length > 0) cachedGiftPool = pool
|
||||
return pool
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the player already owns what a drop carries — the question a query drop's "an
|
||||
* item that you don't have" turns on, and the one that decides whether the weekly gift
|
||||
@@ -1289,30 +1328,6 @@ async function grantLevelUpGifts(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as
|
||||
* a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`,
|
||||
* so it has to be translated before the grant path can read it (see
|
||||
* {@link toChallengeGiftDrop}).
|
||||
*
|
||||
* `FriendlyName`/`Tooltip` are OPTIONAL because the captured rotation has neither — the
|
||||
* client resolves the reward's name from the item itself, falling back to
|
||||
* `FallbackGiftName`. A rotation we publish can carry them to name the granted item
|
||||
* properly without a code change.
|
||||
*/
|
||||
interface ChallengeGift {
|
||||
AvatarItemDesc: string
|
||||
AvatarItemType: number
|
||||
ConsumableItemDesc: string
|
||||
EquipmentPrefabName: string
|
||||
EquipmentModificationGuid: string
|
||||
GiftContext: number
|
||||
GiftRarity: number
|
||||
Xp: number
|
||||
FriendlyName?: string
|
||||
Tooltip?: string
|
||||
}
|
||||
|
||||
/** The message on the gift box the weekly reward arrives in. */
|
||||
const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!'
|
||||
|
||||
@@ -1334,8 +1349,8 @@ const DEFAULT_FALLBACK_STARS = 4
|
||||
* it is what the client renders when the gift resolves to a box rather than a named item —
|
||||
* so a rotation can retune the tier by renaming it, with no code change.
|
||||
*/
|
||||
function fallbackGiftRarity(): number {
|
||||
const stars = Number(/^(\d+)-star/i.exec(weeklyChallenge.FallbackGiftName)?.[1])
|
||||
function fallbackGiftRarity(rotation: WeeklyChallengeRotation): number {
|
||||
const stars = Number(/^(\d+)-star/i.exec(rotation.FallbackGiftName)?.[1])
|
||||
return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0
|
||||
}
|
||||
|
||||
@@ -1350,8 +1365,11 @@ function fallbackGiftRarity(): number {
|
||||
* selling the same item, so the granted item reads as itself — "Camera Skin (Comic)" rather
|
||||
* than the name of the box it might have arrived in.
|
||||
*/
|
||||
function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
|
||||
const gift = weeklyChallenge.Gift as ChallengeGift
|
||||
function toChallengeGiftDrop(
|
||||
rotation: WeeklyChallengeRotation,
|
||||
catalog: StoreItem[]
|
||||
): StoreGiftDrop {
|
||||
const gift: ChallengeGiftBlock = rotation.Gift
|
||||
const sold = catalog.find(
|
||||
({ GiftDrop: drop }) =>
|
||||
(gift.EquipmentModificationGuid !== '' &&
|
||||
@@ -1359,7 +1377,7 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
|
||||
(gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc)
|
||||
)?.GiftDrop
|
||||
return {
|
||||
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName,
|
||||
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? rotation.FallbackGiftName,
|
||||
Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '',
|
||||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
||||
AvatarItemDesc: gift.AvatarItemDesc,
|
||||
@@ -1380,17 +1398,17 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
|
||||
* it. Handed over instead of the rotation's item when that item would be a duplicate, which
|
||||
* is what the fallback name is for — the reward reads "the Camera Skin, or a 4-Star Box".
|
||||
*/
|
||||
function toChallengeFallbackDrop(): StoreGiftDrop {
|
||||
function toChallengeFallbackDrop(rotation: WeeklyChallengeRotation): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: weeklyChallenge.FallbackGiftName,
|
||||
FriendlyName: rotation.FallbackGiftName,
|
||||
Tooltip: '',
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: fallbackGiftRarity(),
|
||||
Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext,
|
||||
Rarity: fallbackGiftRarity(rotation),
|
||||
Context: rotation.Gift.GiftContext,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
IsQuery: true,
|
||||
@@ -1410,11 +1428,9 @@ const CHALLENGES_REQUIRED_FOR_GIFT = 3
|
||||
* all-or-nothing when it's true — the reading its name and the partial default suggest —
|
||||
* and a rotation shorter than the threshold can only ever ask for what it publishes.
|
||||
*/
|
||||
function challengesRequiredForGift(): number {
|
||||
const published = weeklyChallenge.Challenges.length
|
||||
return weeklyChallenge.CompletedRequired
|
||||
? published
|
||||
: Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
|
||||
function challengesRequiredForGift(rotation: WeeklyChallengeRotation): number {
|
||||
const published = rotation.Challenges.length
|
||||
return rotation.CompletedRequired ? published : Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1440,23 +1456,27 @@ function challengesRequiredForGift(): number {
|
||||
* would otherwise meet without playing.
|
||||
*/
|
||||
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
|
||||
const rotation = buildRotation(new Date())
|
||||
try {
|
||||
if (weeklyChallenge.Challenges.length === 0) return
|
||||
const statuses = await getChallengeStatuses(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
|
||||
const done = weeklyChallenge.Challenges.filter(
|
||||
if (rotation.Challenges.length === 0) return
|
||||
const statuses = await getChallengeStatuses(c.env.DB, accountId, rotation.ChallengeMapId)
|
||||
const done = rotation.Challenges.filter(
|
||||
(ch) => statuses.get(ch.ChallengeId)?.complete === true
|
||||
).length
|
||||
if (done < challengesRequiredForGift()) return
|
||||
if (done < challengesRequiredForGift(rotation)) return
|
||||
// Claim first: this is what stops the next report paying out a second time.
|
||||
const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
|
||||
const claimed = await claimChallengeGift(c.env.DB, accountId, rotation.ChallengeMapId)
|
||||
if (!claimed) return
|
||||
// Only now is the catalog worth reading: it names the week's reward and is what the
|
||||
// grant path rolls a duplicate's replacement from.
|
||||
const catalog = await loadRollCatalog(c)
|
||||
const reward = toChallengeGiftDrop(catalog)
|
||||
const week = withWeeklyGift(rotation, toEquipmentGiftPool(catalog))
|
||||
const reward = toChallengeGiftDrop(week, catalog)
|
||||
const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
|
||||
const granted = await grantGiftDrop(
|
||||
c,
|
||||
accountId,
|
||||
duplicate ? toChallengeFallbackDrop() : reward,
|
||||
duplicate ? toChallengeFallbackDrop(week) : reward,
|
||||
CHALLENGE_GIFT_MESSAGE,
|
||||
{ rollCatalog: catalog }
|
||||
)
|
||||
@@ -1467,7 +1487,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
|
||||
await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID)
|
||||
logger.info('weekly challenge gift granted', {
|
||||
accountId,
|
||||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
||||
challengeMapId: rotation.ChallengeMapId,
|
||||
giftId: granted.id,
|
||||
fallbackRoll: duplicate,
|
||||
challengesComplete: done,
|
||||
@@ -1475,7 +1495,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
|
||||
} catch (err) {
|
||||
logger.error('failed to grant weekly challenge gift', {
|
||||
accountId,
|
||||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
||||
challengeMapId: rotation.ChallengeMapId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
@@ -2859,40 +2879,43 @@ const app = new Hono<App>({ strict: false })
|
||||
(c) => c.json(adCarouselItems)
|
||||
)
|
||||
|
||||
// Current weekly challenge. The rotation itself is the bundled static JSON (its format
|
||||
// is documented in the README) but each challenge's state is per-player, so the caller's
|
||||
// rows from `challenge_status` are stamped over the static ones: `Complete` over the
|
||||
// static `false`, and `Config` over the static rule tree — the client evaluates that tree
|
||||
// locally and reports it back with its running counts written into it (`cc`/`c`), so
|
||||
// serving the pristine tree back is what makes partial progress reset every session.
|
||||
// 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.
|
||||
// Current weekly challenge. The rotation is GENERATED from the calendar week (see
|
||||
// challenge-rotation.ts — the same five challenges, window and gift for everyone, derived
|
||||
// from the week index; static/weekly-challenge.json pins it instead when it carries
|
||||
// challenges), but each challenge's state is per-player, so the caller's rows from
|
||||
// `challenge_status` are stamped over the week's: `Complete` over the published `false`,
|
||||
// and `Config` over the published rule tree — the client evaluates that tree locally and
|
||||
// reports it back with its running counts written into it (`cc`/`c`), so serving the
|
||||
// pristine tree back is what makes partial progress reset every session.
|
||||
// Auth is OPTIONAL: without a valid bearer the week is served unstamped 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: [
|
||||
'The bundled static rotation, with each challenge’s `Complete` and `Config` stamped',
|
||||
'from the caller’s progress rows — the stored `Config` carries the client’s running',
|
||||
'counts. Auth is optional — unauthenticated callers get the static catalog with every',
|
||||
'`Complete` false and every `Config` as authored.',
|
||||
'This week’s rotation — generated from the calendar week — with each challenge’s',
|
||||
'`Complete` and `Config` stamped from the caller’s progress rows, the stored `Config`',
|
||||
'carrying the client’s running counts. Auth is optional: unauthenticated callers get',
|
||||
'the week unstamped, every `Complete` false and every `Config` as published.',
|
||||
].join(' '),
|
||||
security: OPTIONAL_AUTHED,
|
||||
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
||||
}),
|
||||
async (c) => {
|
||||
const rotation = withWeeklyGift(buildRotation(new Date()), await loadEquipmentGiftPool(c))
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json(weeklyChallenge)
|
||||
const statuses = await getChallengeStatuses(c.env.DB, id, weeklyChallenge.ChallengeMapId)
|
||||
if (statuses.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
|
||||
// progress to the next caller.
|
||||
if (id === null) return c.json(rotation)
|
||||
const statuses = await getChallengeStatuses(c.env.DB, id, rotation.ChallengeMapId)
|
||||
if (statuses.size === 0) return c.json(rotation)
|
||||
// Rebuild rather than mutate: the generated rotation is cached module state shared
|
||||
// by every request this isolate serves, so stamping it in place would leak one
|
||||
// player's progress to the next caller.
|
||||
return c.json({
|
||||
...weeklyChallenge,
|
||||
Challenges: weeklyChallenge.Challenges.map((challenge) => {
|
||||
...rotation,
|
||||
Challenges: rotation.Challenges.map((challenge) => {
|
||||
const status = statuses.get(challenge.ChallengeId)
|
||||
if (status === undefined) return challenge
|
||||
// A row with no stored tree (never reported one) keeps the authored `Config`;
|
||||
@@ -2964,11 +2987,7 @@ const app = new Hono<App>({ strict: false })
|
||||
// The response is unchanged whether or not a gift was won: the client learns about
|
||||
// the box from `GET /api/avatar/v2/gifts`, and adding a field here would be
|
||||
// inventing response shape the client never sent us.
|
||||
if (
|
||||
stored.complete &&
|
||||
challengeId !== 0 &&
|
||||
challengeMapId === weeklyChallenge.ChallengeMapId
|
||||
) {
|
||||
if (stored.complete && challengeId !== 0 && challengeMapId === rotationMapId(new Date())) {
|
||||
await awardChallengeGift(c, id)
|
||||
}
|
||||
return c.json({
|
||||
|
||||
@@ -19,9 +19,6 @@ import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventio
|
||||
// The notification-type ids the hub carries, from the worker that owns them — asserting
|
||||
// against the enum rather than a copied number is what keeps these frames honest.
|
||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||
// 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,
|
||||
@@ -31,6 +28,10 @@ import {
|
||||
spendCurrency,
|
||||
} from '../../balance-db'
|
||||
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
|
||||
// The live weekly rotation, generated the same way the worker generates it, so the challenge
|
||||
// tests exercise whatever this week actually holds instead of ids from a rotation that has
|
||||
// since rolled over.
|
||||
import { buildRotation, rotationIndex } from '../../challenge-rotation'
|
||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
|
||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||
@@ -44,8 +45,11 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
/** This week's rotation — the same one the worker builds for these requests. */
|
||||
const weekly = buildRotation(new Date())
|
||||
|
||||
/** The first challenge of the live rotation — the progress tests report against it. */
|
||||
const CURRENT_CHALLENGE = weeklyChallenge.Challenges[0]
|
||||
const CURRENT_CHALLENGE = weekly.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.
|
||||
@@ -1834,10 +1838,72 @@ describe('econ endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ChallengeMapId: number; Challenges: unknown[] }
|
||||
expect(body).toHaveProperty('ChallengeMapId')
|
||||
expect(Array.isArray(body.Challenges)).toBe(true)
|
||||
expect(body.ChallengeMapId).toBe(weekly.ChallengeMapId)
|
||||
expect(body.Challenges).toHaveLength(weekly.Challenges.length)
|
||||
})
|
||||
|
||||
test('the rotation is a pure function of the week', async () => {
|
||||
const at = new Date('2026-08-25T12:00:00Z')
|
||||
// Same instant, same rotation — and any instant in the same week, too. Two players
|
||||
// served different challenges for one `ChallengeMapId` would disagree about who has
|
||||
// finished the week.
|
||||
expect(buildRotation(at)).toEqual(buildRotation(at))
|
||||
const laterSameWeek = new Date('2026-08-26T20:59:59Z')
|
||||
expect(rotationIndex(laterSameWeek)).toBe(rotationIndex(at))
|
||||
expect(buildRotation(laterSameWeek).Challenges).toEqual(buildRotation(at).Challenges)
|
||||
|
||||
// …and the week rolls at Wednesday 21:00 UTC, one map id at a time.
|
||||
const nextWeek = new Date('2026-08-26T21:00:00Z')
|
||||
expect(rotationIndex(nextWeek)).toBe(rotationIndex(at) + 1)
|
||||
expect(buildRotation(nextWeek).ChallengeMapId).toBe(buildRotation(at).ChallengeMapId + 1)
|
||||
expect(buildRotation(nextWeek).StartAt).toBe(buildRotation(at).EndAt)
|
||||
})
|
||||
|
||||
test('every generated week is five valid, distinct challenges', async () => {
|
||||
// Walk two years of rotations: the pool, the constraints and the tree builders all have
|
||||
// to hold for every week, not just this one.
|
||||
for (let week = 0; week < 104; week++) {
|
||||
const at = new Date(Date.UTC(2026, 0, 7, 21, 0, 0) + week * 7 * 24 * 60 * 60 * 1000)
|
||||
const rotation = buildRotation(at)
|
||||
const where = `week ${week}`
|
||||
expect(rotation.Challenges, where).toHaveLength(5)
|
||||
// Ids have to be unique within a rotation — `challenge_status` is keyed by them.
|
||||
const ids = rotation.Challenges.map((ch) => ch.ChallengeId)
|
||||
expect(new Set(ids).size, where).toBe(ids.length)
|
||||
// One room per week: five ways to say "play Paintball" is not a rotation.
|
||||
const scenes = rotation.Challenges.flatMap((ch) => sceneIdsOf(ch.Config))
|
||||
expect(new Set(scenes).size, where).toBe(scenes.length)
|
||||
for (const challenge of rotation.Challenges) {
|
||||
// A malformed tree fails SILENTLY in the client — the challenge just never
|
||||
// completes — so the shape is asserted here rather than discovered in game.
|
||||
const tree = JSON.parse(challenge.Config) as { ct: number; t?: number }
|
||||
expect([0, 1], `${where} ${challenge.Name}`).toContain(tree.ct)
|
||||
if (tree.ct === 1) expect(tree.t, `${where} ${challenge.Name}`).toBeGreaterThan(0)
|
||||
expect(sceneIdsOf(challenge.Config).length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
|
||||
// The copy is generated from the same inputs as the tree, so it can't drift — but a
|
||||
// counter still has to say out loud how far it counts.
|
||||
if (tree.ct === 1) expect(challenge.Description, where).toContain(String(tree.t))
|
||||
expect(challenge.Tooltip.length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
|
||||
expect(challenge.Complete, `${where} ${challenge.Name}`).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Every `ct:7` scene id in a rule tree, however deep the tree nests them. */
|
||||
function sceneIdsOf(config: string): string[] {
|
||||
const scenes: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) return node.forEach(walk)
|
||||
if (node === null || typeof node !== 'object') return
|
||||
const record = node as { ct?: number; vs?: Array<{ l?: string }> }
|
||||
if (record.ct === 7)
|
||||
for (const value of record.vs ?? []) if (value.l !== undefined) scenes.push(value.l)
|
||||
for (const value of Object.values(record)) walk(value)
|
||||
}
|
||||
walk(JSON.parse(config))
|
||||
return scenes
|
||||
}
|
||||
|
||||
test('GET /api/storefronts/v1/adcarouselitems returns the carousel items', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/adcarouselitems`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -1860,7 +1926,7 @@ describe('econ endpoints', () => {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||
ChallengeMapId: String(weekly.ChallengeMapId),
|
||||
ChallengeId: String(challenge.ChallengeId),
|
||||
Config: challenge.Config,
|
||||
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
|
||||
@@ -1870,7 +1936,7 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
ChallengeMapId: weeklyChallenge.ChallengeMapId,
|
||||
ChallengeMapId: weekly.ChallengeMapId,
|
||||
ChallengeId: challenge.ChallengeId,
|
||||
Config: challenge.Config,
|
||||
Complete: false,
|
||||
@@ -1893,7 +1959,7 @@ describe('econ endpoints', () => {
|
||||
method: 'POST',
|
||||
headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||
ChallengeMapId: String(weekly.ChallengeMapId),
|
||||
ChallengeId: completedId,
|
||||
Complete: 'True',
|
||||
}),
|
||||
@@ -1944,7 +2010,7 @@ describe('econ endpoints', () => {
|
||||
|
||||
test('the reported Config is stored and served back over the static rule tree', async () => {
|
||||
const challenge = CURRENT_CHALLENGE
|
||||
const bearerHeaders = await bearer('74')
|
||||
const bearerHeaders = await bearer('78')
|
||||
const headers = { ...bearerHeaders, 'Content-Type': 'application/json' }
|
||||
// The client posts the catalog's tree with its own running count written into it —
|
||||
// `cc` on the counter — which is the progress that has to survive the session.
|
||||
@@ -1955,14 +2021,14 @@ describe('econ endpoints', () => {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||
ChallengeMapId: String(weekly.ChallengeMapId),
|
||||
ChallengeId: String(challenge.ChallengeId),
|
||||
...body,
|
||||
}),
|
||||
})
|
||||
const reported = await post({ Config: inProgress, Complete: 'False' })
|
||||
expect(await reported.json()).toEqual({
|
||||
ChallengeMapId: weeklyChallenge.ChallengeMapId,
|
||||
ChallengeMapId: weekly.ChallengeMapId,
|
||||
ChallengeId: challenge.ChallengeId,
|
||||
Config: inProgress,
|
||||
Complete: false,
|
||||
@@ -1988,7 +2054,7 @@ describe('econ endpoints', () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
|
||||
const anonBody = (await anon.json()) as { Challenges: Array<{ Config: string }> }
|
||||
expect(anonBody.Challenges.map((ch) => ch.Config)).toEqual(
|
||||
weeklyChallenge.Challenges.map((ch) => ch.Config)
|
||||
weekly.Challenges.map((ch) => ch.Config)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1996,20 +2062,20 @@ describe('econ endpoints', () => {
|
||||
* How many of the rotation's challenges earn the gift — three, unless the rotation
|
||||
* publishes fewer or declares itself all-or-nothing (`CHALLENGES_REQUIRED_FOR_GIFT`).
|
||||
*/
|
||||
const REQUIRED_FOR_GIFT = weeklyChallenge.CompletedRequired
|
||||
? weeklyChallenge.Challenges.length
|
||||
: Math.min(3, weeklyChallenge.Challenges.length)
|
||||
const REQUIRED_FOR_GIFT = weekly.CompletedRequired
|
||||
? weekly.Challenges.length
|
||||
: Math.min(3, weekly.Challenges.length)
|
||||
|
||||
/** Report the live rotation's challenges complete, for one player. */
|
||||
async function finishTheRotation(sub: string) {
|
||||
const headers = { ...(await bearer(sub)), 'Content-Type': 'application/json' }
|
||||
const ids = weeklyChallenge.Challenges.map((challenge) => challenge.ChallengeId)
|
||||
const ids = weekly.Challenges.map((challenge) => challenge.ChallengeId)
|
||||
const report = (challengeId: number) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||
ChallengeMapId: String(weekly.ChallengeMapId),
|
||||
ChallengeId: String(challengeId),
|
||||
Complete: 'True',
|
||||
}),
|
||||
@@ -2017,6 +2083,16 @@ describe('econ endpoints', () => {
|
||||
return { ids, report }
|
||||
}
|
||||
|
||||
/**
|
||||
* The reward this week advertises, read back from the route that shows it to the client.
|
||||
* The gift is rolled from the storefront catalog rather than authored, so the assertion
|
||||
* that matters is that the box a player receives carries what the rotation promised.
|
||||
*/
|
||||
async function advertisedGift() {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
|
||||
return ((await res.json()) as { Gift: Record<string, string & number> }).Gift
|
||||
}
|
||||
|
||||
/** A player's unopened gift boxes, as the client reads them back. */
|
||||
async function giftBoxes(sub: string) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
|
||||
@@ -2033,7 +2109,8 @@ describe('econ endpoints', () => {
|
||||
}
|
||||
|
||||
test('completing enough of the rotation grants its gift, once', async () => {
|
||||
// The live rotation, so this follows whatever static/weekly-challenge.json holds.
|
||||
// The live rotation, so this follows whatever this week generated.
|
||||
const gift = await advertisedGift()
|
||||
const { ids, report } = await finishTheRotation('74')
|
||||
// The threshold can't ask for more than the week publishes: a five-challenge week asks
|
||||
// for three, and a rotation of three or fewer asks for all of them.
|
||||
@@ -2050,7 +2127,7 @@ describe('econ endpoints', () => {
|
||||
const won = await giftBoxes('74')
|
||||
expect(won).toHaveLength(1)
|
||||
expect(won[0]?.Message).toBe('Weekly challenge complete!')
|
||||
expect(won[0]?.EquipmentModificationGuid).toBe(weeklyChallenge.Gift.EquipmentModificationGuid)
|
||||
expect(won[0]?.EquipmentModificationGuid).toBe(gift.EquipmentModificationGuid)
|
||||
|
||||
// The client is told the moment the set is finished, rather than finding the box the
|
||||
// next time it reads the gifts list. `Immediate` (31), from Coach (1).
|
||||
@@ -2063,10 +2140,10 @@ describe('econ endpoints', () => {
|
||||
FromGiftDropId: 0,
|
||||
FromPlayerId: 1,
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc,
|
||||
AvatarItemType: weeklyChallenge.Gift.AvatarItemType,
|
||||
EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
|
||||
AvatarItemDesc: gift.AvatarItemDesc,
|
||||
AvatarItemType: gift.AvatarItemType,
|
||||
EquipmentPrefabName: gift.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: gift.EquipmentModificationGuid,
|
||||
CurrencyType: 0,
|
||||
Currency: 0,
|
||||
Xp: 0,
|
||||
@@ -2074,9 +2151,10 @@ describe('econ endpoints', () => {
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: -2,
|
||||
GiftContext: weeklyChallenge.Gift.GiftContext,
|
||||
// The catalog's rarity for the item, not the block's `GiftRarity` of 0.
|
||||
GiftRarity: 5,
|
||||
GiftContext: gift.GiftContext,
|
||||
// The catalog's rarity for the item — which is also what the generated block carries,
|
||||
// since the week's gift is drawn from the catalog itself.
|
||||
GiftRarity: gift.GiftRarity,
|
||||
Message: 'Weekly challenge complete!',
|
||||
})
|
||||
|
||||
@@ -2085,9 +2163,7 @@ describe('econ endpoints', () => {
|
||||
headers: await bearer('74'),
|
||||
})
|
||||
const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }>
|
||||
expect(owned.map((e) => e.ModificationGuid)).toContain(
|
||||
weeklyChallenge.Gift.EquipmentModificationGuid
|
||||
)
|
||||
expect(owned.map((e) => e.ModificationGuid)).toContain(gift.EquipmentModificationGuid)
|
||||
|
||||
// Finishing the REST of the set, and re-reporting what's already done (which the client
|
||||
// keeps doing), must not mint a second reward.
|
||||
@@ -2097,10 +2173,11 @@ describe('econ endpoints', () => {
|
||||
|
||||
test('a player who already owns the rotation’s gift rolls the fallback box instead', async () => {
|
||||
// Own the reward up front — the case the rotation's `FallbackGiftName` exists for.
|
||||
const gift = await advertisedGift()
|
||||
await grantEquipment(env.DB, 75, {
|
||||
ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
|
||||
PrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
|
||||
FriendlyName: 'Camera Skin (Comic)',
|
||||
ModificationGuid: gift.EquipmentModificationGuid,
|
||||
PrefabName: gift.EquipmentPrefabName,
|
||||
FriendlyName: 'The week’s reward, already owned',
|
||||
Tooltip: '',
|
||||
Rarity: 5,
|
||||
PlatformMask: -1,
|
||||
@@ -2119,9 +2196,7 @@ describe('econ endpoints', () => {
|
||||
// Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30),
|
||||
// rather than a second copy of the gift.
|
||||
const rolled = won[0]
|
||||
expect(rolled?.EquipmentModificationGuid).not.toBe(
|
||||
weeklyChallenge.Gift.EquipmentModificationGuid
|
||||
)
|
||||
expect(rolled?.EquipmentModificationGuid).not.toBe(gift.EquipmentModificationGuid)
|
||||
expect(rolled?.GiftRarity).toBe(30)
|
||||
expect(
|
||||
(rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== ''
|
||||
|
||||
@@ -4,24 +4,7 @@
|
||||
"StartAt": "2026-08-19T21:00:00",
|
||||
"EndAt": "2026-09-26T21:00:00",
|
||||
"ServerTime": "2026-08-25T14:42:54.2754728Z",
|
||||
"Challenges": [
|
||||
{
|
||||
"ChallengeId": 1,
|
||||
"Name": "Debug2AIGoldenTrophy",
|
||||
"Config": "{\"ct\":1,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[5]},{\"ct\":7,\"vs\":[{\"l\":\"91e16e35-f48f-4700-ab8a-a1b79e50e51b\"}]}]}],\"t\":2}",
|
||||
"Description": "Defeat 2 enemies in ^GoldenTrophy",
|
||||
"Tooltip": "Go to ^GoldenTrophy and take out 2 goblins.",
|
||||
"Complete": false
|
||||
},
|
||||
{
|
||||
"ChallengeId": 2,
|
||||
"Name": "Debug2AIJumbotron",
|
||||
"Config": "{\"ct\":1,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[5]},{\"ct\":7,\"vs\":[{\"l\":\"acc06e66-c2d0-4361-b0cd-46246a4c455c\"}]}]}],\"t\":2}",
|
||||
"Description": "Defeat 2 enemies in ^TheRiseOfJumbotron",
|
||||
"Tooltip": "Go to ^TheRiseOfJumbotron and take out 2 enemies.",
|
||||
"Complete": false
|
||||
}
|
||||
],
|
||||
"Challenges": [],
|
||||
"Gift": {
|
||||
"GiftDropId": 3994,
|
||||
"AvatarItemDesc": "",
|
||||
|
||||
Reference in New Issue
Block a user