diff --git a/apps/econ/README.md b/apps/econ/README.md index 9b2c1aa..e9726b1 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -156,7 +156,7 @@ rotation to differ. | 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)_ Whether every challenge must be finished before the `Gift` is claimable. | +| `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. | @@ -276,12 +276,19 @@ consolation tier with no code change; a name that doesn't parse falls back to 4 ### Winning the gift (`challenge_gift`) There is no claim endpoint and the client never asks: the reward is handed out from the -`updateProgress` call that completes the set. Every completing report on the **live** -rotation re-reads the caller's completions and, if every challenge in -`weekly-challenge.json` is there, grants the `Gift` the way a purchase grants a drop — the -item into `inventory`/`equipment`/`consumable`, plus a gift box (message +`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 +`inventory`/`equipment`/`consumable`, plus a gift box (message `Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`. +**Three of five, not five of five** (`CHALLENGES_REQUIRED_FOR_GIFT`). A week publishes five +challenges and the gift is for playing most of them, so the two a player can't reach — a +quest they don't own, a mode they don't like — don't sink the whole week. The count is of +challenges the rotation still **publishes**: a live client can report an id an edited +rotation no longer lists, and three of those shouldn't buy a gift nobody worked for. A +rotation publishing fewer than three can only ask for what it has. + **The item, or a roll.** If the player already owns the `Gift`'s item — likely, since the rotation's reward is one fixed item that sells in the store — they get the `FallbackGiftName` box instead, rolled at its star tier. Finishing the week can't be worth @@ -306,14 +313,17 @@ the block empty and naming the tier. `Immediate` (31) rather than `GiftPackageReceived` (30) is what the reference sends for a box the server hands over unasked; the sender is Coach (1). Best-effort — a hub failure is logged and swallowed, since the gift is already granted and stored. -- **`CompletedRequired` is not consulted.** Its meaning is inferred, and the only reading - under which the gift is due _before_ the set is done would pay out on the first challenge. +- **`CompletedRequired: true` makes the rotation all-or-nothing** — the threshold becomes + every published challenge. That reading of the flag is still _inferred_ (it is `false` in + the captured rotation, which is the partial default), but it's the one its name and the + three-of-five rule agree on. - **`Xp`/`Level` on the block are ignored**, as on a purchase — same gap, and both are `0` in the captured rotation. - **A report against an old rotation never wins anything**, and an empty `Challenges` array - is not a finished set (without that guard "every challenge complete" is vacuously true). -- **Players who finished the set before this shipped still get it**: the client re-reports - completed challenges, and the first such report is a completing report. + earns nothing (its threshold clamps to zero, which every player would otherwise meet + without playing). +- **Players already past the threshold when this shipped still get it**: the client + re-reports completed challenges, and the first such report is a completing report. ### Progress (`challenge_status`) diff --git a/apps/econ/src/challenge-db.ts b/apps/econ/src/challenge-db.ts index 436d26d..efa7f9b 100644 --- a/apps/econ/src/challenge-db.ts +++ b/apps/econ/src/challenge-db.ts @@ -16,8 +16,8 @@ * 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. * - * Finishing every challenge in a rotation earns the rotation's `Gift`, which is handed out - * from the same `updateProgress` call that completes the set. That payout is gated by a + * Finishing enough of a rotation's challenges earns its `Gift`, which is handed out from the + * same `updateProgress` call that reaches the threshold. That payout is gated by a * second table here, `challenge_gift` — one row per (account, rotation), claimed once. * * The `econ` worker owns both tables and their migrations @@ -88,8 +88,9 @@ export async function recordChallengeProgress( * 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. * - * Also what "the whole set is finished" is decided from: the rotation's `Gift` is due once - * every challenge in static/weekly-challenge.json appears here. + * Also what earning the rotation's `Gift` is decided from: it is due once ENOUGH of the + * challenges in static/weekly-challenge.json appear here — three of the five a week + * publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts). */ export async function getCompletedChallengeIds( db: D1Database, diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 833f92c..22631c2 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -739,15 +739,35 @@ function toChallengeFallbackDrop(): StoreGiftDrop { } /** - * Award the rotation's `Gift` if this player has just finished the whole set, doing nothing - * otherwise. Called after each completing progress report, since `updateProgress` is the - * only place a challenge is ever finished — there is no separate claim endpoint, and the - * client never asks for this reward. + * How many of a rotation's challenges earn its gift. A week presents five and asks for + * three: the reward is for playing most of the week's set, not for clearing all of it, so + * the two a player can't reach (a quest they don't own, a mode they don't like) don't sink + * the whole week. + */ +const CHALLENGES_REQUIRED_FOR_GIFT = 3 + +/** + * How many completions this rotation's gift needs. `CompletedRequired` makes the set + * 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) +} + +/** + * Award the rotation's `Gift` if this player has just earned it, doing nothing otherwise. + * Called after each completing progress report, since `updateProgress` is the only place a + * challenge is ever finished — there is no separate claim endpoint, and the client never + * asks for this reward. * - * "The whole set" is every challenge in the current rotation, read back from - * `challenge_status`. The rotation's `CompletedRequired` flag is NOT consulted: what it - * means is inferred, and the only reading under which the gift is due before the set is - * finished would pay out on the first challenge, which no rotation can have intended. + * Earning it takes {@link challengesRequiredForGift} of the rotation's challenges, counted + * from `challenge_status`. Only challenges the rotation still publishes count: a report can + * carry an id this week's set no longer lists (an edited rotation under a live client), and + * three of those shouldn't buy a gift the player never worked for. * * What lands is the `Gift` block's item — or, if the player already owns it, the box named * by `FallbackGiftName`, which rolls something they don't have at that tier. Finishing the @@ -757,8 +777,8 @@ function toChallengeFallbackDrop(): StoreGiftDrop { * A grant that throws is swallowed: the client is reporting gameplay progress, and failing * that report (which it would then retry with the same completion) is worse than missing * the reward — the claim row is already taken, so the miss is permanent but visible in the - * logs. An empty rotation is not "all complete"; without the guard, `every` on it is - * vacuously true and every report would win a gift. + * logs. An empty rotation earns nothing: its threshold clamps to zero, which every player + * would otherwise meet without playing. */ async function awardChallengeGift(c: Context, accountId: number): Promise { try { @@ -768,7 +788,8 @@ async function awardChallengeGift(c: Context, accountId: number): Promise complete.has(ch.ChallengeId))) return + const done = weeklyChallenge.Challenges.filter((ch) => complete.has(ch.ChallengeId)).length + if (done < challengesRequiredForGift()) 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) if (!claimed) return @@ -792,6 +813,7 @@ async function awardChallengeGift(c: Context, accountId: number): Promise { expect(await completeOf(await post('18', 'True'))).toBe(true) }) - /** Report every challenge of the live rotation complete, for one player. */ + /** + * 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) + + /** 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) @@ -1474,15 +1482,21 @@ describe('econ endpoints', () => { }> } - test('finishing every challenge in the rotation grants its gift, once', async () => { - // The whole live rotation, so this follows whatever static/weekly-challenge.json holds. + test('completing enough of the rotation grants its gift, once', async () => { + // The live rotation, so this follows whatever static/weekly-challenge.json holds. const { ids, report } = await finishTheRotation('74') - for (const id of ids.slice(0, -1)) expect((await report(id)).status).toBe(200) - // One challenge short of the set — the gift isn't due yet. + // The whole point of the threshold: the gift lands before the set is finished (the + // published week is five challenges for three). + expect(REQUIRED_FOR_GIFT).toBeLessThan(ids.length) + for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) { + expect((await report(id)).status).toBe(200) + } + // One short of the threshold — the gift isn't due yet, even though challenges remain + // unfinished either way. expect(await giftBoxes('74')).toEqual([]) await drainFrames() - expect((await report(ids[ids.length - 1] ?? 0)).status).toBe(200) + expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200) const won = await giftBoxes('74') expect(won).toHaveLength(1) expect(won[0]?.Message).toBe('Weekly challenge complete!') @@ -1525,8 +1539,8 @@ describe('econ endpoints', () => { weeklyChallenge.Gift.EquipmentModificationGuid ) - // The client keeps reporting progress after the set is finished; a second pass over - // the same completions must not mint a second reward. + // Finishing the REST of the set, and re-reporting what's already done (which the client + // keeps doing), must not mint a second reward. for (const id of ids) expect((await report(id)).status).toBe(200) expect(await giftBoxes('74')).toHaveLength(1) }) @@ -1544,9 +1558,11 @@ describe('econ endpoints', () => { }) const { ids, report } = await finishTheRotation('75') - for (const id of ids.slice(0, -1)) expect((await report(id)).status).toBe(200) + for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) { + expect((await report(id)).status).toBe(200) + } await drainFrames() - expect((await report(ids[ids.length - 1] ?? 0)).status).toBe(200) + expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200) const won = await giftBoxes('75') expect(won).toHaveLength(1) diff --git a/apps/econ/static/weekly-challenge.json b/apps/econ/static/weekly-challenge.json index 1357f1c..236f40d 100644 --- a/apps/econ/static/weekly-challenge.json +++ b/apps/econ/static/weekly-challenge.json @@ -60,5 +60,5 @@ "GiftRarity": 0 }, "FallbackGiftName": "4-Star Box", - "ChallengeThemeString": "\"do like \"kapow\"-like its a punch to the face that we're doing weekly challenges\" - fexlar" + "ChallengeThemeString": "" }