From 0fd754a106c13a16207b1860434fa76e6353b18c Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 15:33:56 +0100 Subject: [PATCH 1/4] Show level Plutonium held until the account is trusted The API now holds level Plutonium rewards (level_milestone) until the account is trusted, marking them `held: "trust"` on /users/@me's rewards. The rewards panel shows a held reward with "Trusted accounts only" in place of its Claim button, and a note under the list saying how the account becomes trusted (CrazyGames gets a variant without purchases). - Claim all is offered only for claimable rewards, and after it the panel keeps the rewards the server left held (`held` on the claim-all response) instead of clearing the list. - A single claim answered 403 with `held` marks that reward held rather than showing the claim-failed alert. - The login rewards popup counts only claimable rewards, so held ones alone don't reopen it every boot. - Every new field is optional and reads a malformed value as absent, so older and newer APIs both parse. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 15 +++ resources/lang/en.json | 3 + src/client/Api.ts | 15 ++- src/client/Main.ts | 4 +- src/client/components/RewardsPanel.ts | 51 +++++-- tests/ApiSchemas.test.ts | 122 +++++++++++++++++ tests/client/ProgressionSchemas.test.ts | 25 ++++ tests/client/RewardsPanel.test.ts | 171 +++++++++++++++++++++++- 8 files changed, 393 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index a084809f12..27b14b7aa4 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -105,6 +105,11 @@ export const RewardSchema = z.object({ amount: z.string(), reason: z.string(), note: z.string().nullable(), + // Why the reward can't be claimed yet; absent when it can. "trust": level + // Plutonium, kept until the account is trusted. Optional for older APIs, and + // an unknown or malformed value reads as absent: the reward then offers a + // claim, and the server's 403 marks it held (see claimReward). + held: z.enum(["trust"]).optional().catch(undefined), }); export type Reward = z.infer; @@ -221,12 +226,22 @@ export type ClaimRewardResponse = z.infer; export const ClaimAllRewardsResponseSchema = z.object({ claimed: z.array(z.object({ id: z.string() })), + // The rewards still pending after the claim because they are held (each + // with `held`), in /users/@me's reward shape. Empty from older APIs, and + // when the list can't be read: the next /users/@me read brings them back. + held: z.array(RewardSchema).default([]).catch([]), currency: CurrencyBalancesSchema, }); export type ClaimAllRewardsResponse = z.infer< typeof ClaimAllRewardsResponseSchema >; +// POST /rewards/:rewardId/claim's 403 for a held reward (RewardSchema.held): +// it stays pending, and claims once the hold lifts. +export const ClaimRewardHeldResponseSchema = z.object({ + held: z.string(), +}); + // Account-username lifecycle. `unclaimed`: no bare-name reservation (default). // `claimed`: reservation held but subscription lapsed — the suffix shows again // and a grace deadline runs. `premium`: subscribed, bare display. `indefinite`: diff --git a/resources/lang/en.json b/resources/lang/en.json index b8403afaf5..18405ab68e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -85,6 +85,9 @@ "reactivate_subscription": "Reactivate", "recovery_email_sent": "Recovery email sent to {email}", "reward_daily": "Daily subscription reward", + "reward_held_trust": "Trusted accounts only", + "reward_held_trust_info": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted after you play more public games, or right away with any purchase.", + "reward_held_trust_info_crazygames": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted after you play more public games.", "reward_level_milestone": "Level milestone reward", "reward_level_up": "Level-up reward", "reward_prestige": "Prestige reward", diff --git a/src/client/Api.ts b/src/client/Api.ts index 04b77a137e..ad12dc680e 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -2,6 +2,7 @@ import type { NewsItem, StreamsFeed } from "@openfront/shared/ApiSchemas"; import { ClaimAllRewardsResponse, ClaimAllRewardsResponseSchema, + ClaimRewardHeldResponseSchema, ClaimRewardResponse, ClaimRewardResponseSchema, GetMyTribeNamesResponse, @@ -1392,10 +1393,12 @@ export async function purchaseCosmeticPack( // credits the balance atomically. "not_found" covers unknown, already-claimed // and other players' rewards (indistinguishable by design); the usual cause is // a double-click or a second device claiming first, so callers should re-fetch -// /users/@me and re-render rather than surface an error. +// /users/@me and re-render rather than surface an error. "held" is a reward +// that can't be claimed yet (level Plutonium on an account that isn't +// trusted): a 403 naming the hold. It stays pending, so nothing is lost. export async function claimReward( rewardId: string, -): Promise { +): Promise { try { const response = await fetch( `${getApiBase()}/rewards/${encodeURIComponent(rewardId)}/claim`, @@ -1411,6 +1414,14 @@ export async function claimReward( return false; } if (response.status === 404) return "not_found"; + if ( + response.status === 403 && + ClaimRewardHeldResponseSchema.safeParse( + await response.json().catch(() => null), + ).success + ) { + return "held"; + } if (!response.ok) { console.error( "claimReward: request failed", diff --git a/src/client/Main.ts b/src/client/Main.ts index 7024fc15a6..402b19393e 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -829,7 +829,9 @@ class Client { Date.now(), ), grantStringsReady: steamGrantStringsReady(translateText), - rewardCount: rewards.length, + // Held rewards can't be claimed yet: on their own they'd open the + // popup every boot with nothing to do. + rewardCount: rewards.filter((r) => r.held === undefined).length, claimPromptDue: claimPromptDue(claimStore, Date.now(), publicId), claimStringsReady: claimPromptStringsReady(translateText), }), diff --git a/src/client/components/RewardsPanel.ts b/src/client/components/RewardsPanel.ts index 1e504d3671..53418d8f1d 100644 --- a/src/client/components/RewardsPanel.ts +++ b/src/client/components/RewardsPanel.ts @@ -7,6 +7,7 @@ import { getUserMe, invalidateUserMe, } from "../Api"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; import { showInGameAlert } from "../InGameModal"; import { levelRewardReasonKey } from "../Progression"; import { translateText } from "../Utils"; @@ -53,6 +54,17 @@ export class RewardsPanel extends LitElement { return; } invalidateUserMe(); + if (result === "held") { + // Held since this list was read (or read by an older API): nothing + // was claimed, so the wallet stays as it is. + this.emitChanged({ + currency: null, + rewards: this.rewards.map((r) => + r.id === reward.id ? { ...r, held: "trust" } : r, + ), + }); + return; + } if (result === "not_found") { // Already claimed elsewhere (double-click or second device) — the // currency was still credited exactly once. Re-sync from the server. @@ -85,7 +97,8 @@ export class RewardsPanel extends LitElement { return; } invalidateUserMe(); - this.emitChanged({ currency: result.currency, rewards: [] }); + // Held rewards stay pending: keep showing them, with why. + this.emitChanged({ currency: result.currency, rewards: result.held }); } finally { this.claiming = false; } @@ -137,19 +150,38 @@ export class RewardsPanel extends LitElement { > - this.handleClaim(reward)} - > + ${reward.held !== undefined + ? html`${translateText("account_modal.reward_held_trust")}` + : html` this.handleClaim(reward)} + >`} `; } + // Why held rewards can't be claimed yet: once under the list, however many + // there are. CrazyGames has no purchases, so its copy only suggests playing. + private renderHeldNote(): TemplateResult | "" { + if (!this.rewards.some((r) => r.held !== undefined)) return ""; + const key = crazyGamesSDK.isOnCrazyGames() + ? "account_modal.reward_held_trust_info_crazygames" + : "account_modal.reward_held_trust_info"; + return html`

+ ${translateText(key)} +

`; + } + render() { if (this.rewards.length === 0) return html``; + const claimable = this.rewards.filter((r) => r.held === undefined); return html`
@@ -157,7 +189,7 @@ export class RewardsPanel extends LitElement { 🎁 ${translateText("account_modal.unclaimed_rewards")} - ${this.rewards.length > 1 + ${claimable.length > 1 ? html` ${this.rewards.map((r) => this.renderReward(r))}
+ ${this.renderHeldNote()}
`; } diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index 7e5bf283f2..459ad88875 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -1,5 +1,6 @@ import { ClaimAllRewardsResponseSchema, + ClaimRewardHeldResponseSchema, ClaimRewardResponseSchema, FriendEntrySchema, GetMyTribeNamesResponseSchema, @@ -594,6 +595,27 @@ describe("RewardSchema", () => { RewardSchema.safeParse({ ...validReward, currencyType: "gems" }).success, ).toBe(false); }); + + it("reads held: trust", () => { + const result = RewardSchema.safeParse({ ...validReward, held: "trust" }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.held).toBe("trust"); + }); + + it("reads an absent held as claimable", () => { + const result = RewardSchema.safeParse(validReward); + expect(result.success).toBe(true); + if (result.success) expect(result.data.held).toBeUndefined(); + }); + + it.each([["future_hold"], [42], [null], [{ reason: "trust" }]])( + "reads a malformed held (%j) as absent rather than failing", + (held) => { + const result = RewardSchema.safeParse({ ...validReward, held }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.held).toBeUndefined(); + }, + ); }); describe("UserMeResponseSchema rewards", () => { @@ -629,6 +651,39 @@ describe("UserMeResponseSchema rewards", () => { } }); + it("keeps a held reward's hold", () => { + const result = UserMeResponseSchema.safeParse({ + user: {}, + player: { + ...basePlayer, + rewards: [ + { + id: "7", + currencyType: "hard", + amount: "50", + reason: "level_milestone", + note: null, + held: "trust", + }, + { + id: "8", + currencyType: "soft", + amount: "150", + reason: "level_up", + note: null, + }, + ], + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.player.rewards?.map((r) => r.held)).toEqual([ + "trust", + undefined, + ]); + } + }); + it("accepts a response without rewards (older API versions)", () => { expect( UserMeResponseSchema.safeParse({ user: {}, player: basePlayer }).success, @@ -709,6 +764,73 @@ describe("claim response schemas", () => { expect(result.data.claimed).toEqual([{ id: "42" }]); } }); + + it("reads a claim-all without held (older APIs) as nothing held", () => { + const result = ClaimAllRewardsResponseSchema.safeParse({ + claimed: [], + currency: { soft: "0", hard: "0" }, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.held).toEqual([]); + }); + + it("keeps the rewards a claim-all left held", () => { + const result = ClaimAllRewardsResponseSchema.safeParse({ + claimed: [], + held: [ + { + id: "7", + currencyType: "hard", + amount: "50", + reason: "level_milestone", + note: null, + createdAt: "2026-10-01T12:00:00.000Z", + held: "trust", + }, + ], + currency: { soft: "0", hard: "0" }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.held).toEqual([ + { + id: "7", + currencyType: "hard", + amount: "50", + reason: "level_milestone", + note: null, + held: "trust", + }, + ]); + } + }); + + it("reads a malformed held list as empty rather than failing the claim", () => { + const result = ClaimAllRewardsResponseSchema.safeParse({ + claimed: [{ id: "1" }], + held: "nope", + currency: { soft: "10", hard: "0" }, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.held).toEqual([]); + }); + + it("recognises the 403 body for a held reward", () => { + expect( + ClaimRewardHeldResponseSchema.safeParse({ + error: "Forbidden", + resource: "reward", + held: "trust", + message: "This reward is paid to trusted accounts", + }).success, + ).toBe(true); + expect( + ClaimRewardHeldResponseSchema.safeParse({ + error: "Forbidden", + message: "nope", + }).success, + ).toBe(false); + }); }); describe("UserMeResponseSchema canCreatePublicLobbies", () => { diff --git a/tests/client/ProgressionSchemas.test.ts b/tests/client/ProgressionSchemas.test.ts index 33326d065f..c141abe945 100644 --- a/tests/client/ProgressionSchemas.test.ts +++ b/tests/client/ProgressionSchemas.test.ts @@ -90,6 +90,31 @@ describe("GameXpResponseSchema", () => { expect(parsed.levelsReached).toEqual([{ prestige: 0, level: 5 }]); }); + // The post-game screen has no reward list or claim button: what a game's + // levels paid (held or not) is claimed from the rewards panel, which reads + // /users/@me. A response carrying held rewards must still parse. + it("parses a result whose level rewards are held, offering no claim", () => { + const parsed = GameXpResponseSchema.parse({ + ...eligible, + rewards: [ + { + prestige: 0, + level: 5, + id: "9", + currencyType: "hard", + amount: "25", + reason: "level_milestone", + note: null, + claimed: false, + held: "trust", + }, + ], + flares: [], + }); + expect(parsed.eligible).toBe(true); + expect(parsed).not.toHaveProperty("rewards"); + }); + it("defaults a missing levelsReached to none", () => { const rest: Record = { ...eligible }; delete rest.levelsReached; diff --git a/tests/client/RewardsPanel.test.ts b/tests/client/RewardsPanel.test.ts index ec6d7db48d..37f67a2955 100644 --- a/tests/client/RewardsPanel.test.ts +++ b/tests/client/RewardsPanel.test.ts @@ -15,8 +15,18 @@ vi.mock("../../src/client/InGameModal", () => ({ showInGameAlert: vi.fn(), })); +vi.mock("../../src/client/CrazyGamesSDK", () => ({ + crazyGamesSDK: { isOnCrazyGames: vi.fn(() => false) }, +})); + import type { Reward } from "@openfront/shared/ApiSchemas"; -import { RewardsPanel } from "../../src/client/components/RewardsPanel"; +import { claimAllRewards, claimReward } from "../../src/client/Api"; +import { + RewardsPanel, + type RewardsChangedDetail, +} from "../../src/client/components/RewardsPanel"; +import { crazyGamesSDK } from "../../src/client/CrazyGamesSDK"; +import { showInGameAlert } from "../../src/client/InGameModal"; if (!customElements.get("rewards-panel")) { customElements.define("rewards-panel", RewardsPanel); @@ -79,3 +89,162 @@ describe(" reward labels", () => { ]); }); }); + +describe(" held rewards", () => { + let el: RewardsPanel | undefined; + + afterEach(() => { + el?.remove(); + el = undefined; + vi.mocked(crazyGamesSDK.isOnCrazyGames).mockReturnValue(false); + vi.clearAllMocks(); + }); + + function plutonium(id: string, held?: "trust"): Reward { + return { + id, + currencyType: "hard", + amount: "50", + reason: "level_milestone", + note: null, + ...(held === undefined ? {} : { held }), + }; + } + + async function mount(rewards: Reward[]): Promise { + el = document.createElement("rewards-panel") as RewardsPanel; + el.rewards = rewards; + document.body.appendChild(el); + await el.updateComplete; + return el; + } + + // Each reward's row, keyed by the claim affordance it offers. + function rows(panel: RewardsPanel): ("claim" | "held")[] { + return [...panel.querySelectorAll("div.justify-between.p-3")].map((row) => + row.querySelector("[data-reward-held]") !== null ? "held" : "claim", + ); + } + + function claimAllButton(panel: RewardsPanel): Element | null { + return panel.querySelector( + 'o-button[translationKey="account_modal.claim_all"]', + ); + } + + function changes(panel: RewardsPanel): RewardsChangedDetail[] { + const seen: RewardsChangedDetail[] = []; + panel.addEventListener("rewards-changed", (e) => + seen.push((e as CustomEvent).detail), + ); + return seen; + } + + it("shows a held reward's label and note in place of its claim button", async () => { + const panel = await mount([plutonium("1", "trust")]); + expect(rows(panel)).toEqual(["held"]); + expect( + panel.querySelector('o-button[translationKey="account_modal.claim"]'), + ).toBeNull(); + expect(panel.querySelector("[data-reward-held]")?.textContent?.trim()).toBe( + "account_modal.reward_held_trust", + ); + expect( + panel.querySelector("[data-reward-held-note]")?.textContent?.trim(), + ).toBe("account_modal.reward_held_trust_info"); + }); + + it("uses the CrazyGames note there (no purchases)", async () => { + vi.mocked(crazyGamesSDK.isOnCrazyGames).mockReturnValue(true); + const panel = await mount([plutonium("1", "trust")]); + expect( + panel.querySelector("[data-reward-held-note]")?.textContent?.trim(), + ).toBe("account_modal.reward_held_trust_info_crazygames"); + }); + + it("shows no note when nothing is held", async () => { + const panel = await mount([plutonium("1"), plutonium("2")]); + expect(rows(panel)).toEqual(["claim", "claim"]); + expect(panel.querySelector("[data-reward-held-note]")).toBeNull(); + }); + + it("offers claim all only for rewards that can be claimed", async () => { + // Two held and one claimable: nothing for claim all to add. + let panel = await mount([ + plutonium("1", "trust"), + plutonium("2", "trust"), + reward("level_up"), + ]); + expect(rows(panel)).toEqual(["held", "held", "claim"]); + expect(claimAllButton(panel)).toBeNull(); + panel.remove(); + + panel = await mount([ + plutonium("1", "trust"), + reward("level_up"), + reward("prestige"), + ]); + expect(claimAllButton(panel)).not.toBeNull(); + }); + + it("keeps the rewards claim all left held", async () => { + const held = plutonium("1", "trust"); + vi.mocked(claimAllRewards).mockResolvedValue({ + claimed: [{ id: "level_up" }, { id: "prestige" }], + held: [held], + currency: { soft: 300, hard: 0 }, + }); + const panel = await mount([held, reward("level_up"), reward("prestige")]); + const seen = changes(panel); + (claimAllButton(panel) as HTMLElement).click(); + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0]).toEqual({ + currency: { soft: 300, hard: 0 }, + rewards: [held], + }); + expect(showInGameAlert).not.toHaveBeenCalled(); + }); + + it("marks a reward held when its claim is refused as held", async () => { + vi.mocked(claimReward).mockResolvedValue("held"); + const level = reward("level_up"); + const panel = await mount([plutonium("1"), level]); + const seen = changes(panel); + ( + panel.querySelector( + 'o-button[translationKey="account_modal.claim"]', + ) as HTMLElement + ).click(); + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(claimReward).toHaveBeenCalledWith("1"); + expect(showInGameAlert).not.toHaveBeenCalled(); + // Nothing was claimed: the wallet stays as it is, and the reward stays. + expect(seen[0]).toEqual({ + currency: null, + rewards: [plutonium("1", "trust"), level], + }); + + // Rendered with the hold, as the parent passes the new list back. + panel.rewards = seen[0].rewards; + await panel.updateComplete; + expect(rows(panel)).toEqual(["held", "claim"]); + expect(panel.querySelector("[data-reward-held-note]")).not.toBeNull(); + }); + + it("still alerts when a claim fails for another reason", async () => { + vi.mocked(claimReward).mockResolvedValue(false); + const panel = await mount([plutonium("1")]); + const seen = changes(panel); + ( + panel.querySelector( + 'o-button[translationKey="account_modal.claim"]', + ) as HTMLElement + ).click(); + await vi.waitFor(() => + expect(showInGameAlert).toHaveBeenCalledWith( + "account_modal.claim_failed", + ), + ); + expect(seen).toEqual([]); + }); +}); From cf487e22d3f20b4a724642a9165b3799362cf7d3 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 19:54:59 +0100 Subject: [PATCH 2/4] Held rewards: fail closed on unknown holds, one claimability rule - RewardSchema.held is any non-empty string: a hold kind this client doesn't know reads as held, never as claimable. The note switches on "trust", with a separate path for other holds. - claimReward passes the 403's hold on as named ({ held }) instead of a bare "held", and the panel uses it rather than assuming "trust". - A held 403 re-reads /users/@me and emits the whole fresh list (the hold is per account), still marking the refused reward held. - Claim-all's held list is parsed per row, so one bad row drops only itself. - The login rewards popup opens, and stays open, only while a reward can be claimed. - isRewardClaimable() in ApiSchemas is the one claimability predicate (Main, RewardsPanel, RewardsModal); rewardCount's doc says claimable. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 29 +++++-- src/client/Api.ts | 19 +++-- src/client/BootInterrupts.ts | 6 +- src/client/Main.ts | 7 +- src/client/RewardsModal.ts | 12 ++- src/client/components/RewardsPanel.ts | 58 +++++++++----- tests/Api.test.ts | 68 +++++++++++++++++ tests/ApiSchemas.test.ts | 55 +++++++++++++- tests/client/RewardsModal.test.ts | 104 ++++++++++++++++++++++++++ tests/client/RewardsPanel.test.ts | 81 ++++++++++++++++++-- 10 files changed, 392 insertions(+), 47 deletions(-) create mode 100644 tests/client/RewardsModal.test.ts diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index cc5b2886f7..fc045d95b2 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -106,13 +106,20 @@ export const RewardSchema = z.object({ reason: z.string(), note: z.string().nullable(), // Why the reward can't be claimed yet; absent when it can. "trust": level - // Plutonium, kept until the account is trusted. Optional for older APIs, and - // an unknown or malformed value reads as absent: the reward then offers a - // claim, and the server's 403 marks it held (see claimReward). - held: z.enum(["trust"]).optional().catch(undefined), + // Plutonium, kept until the account is trusted. Any other non-empty value is + // a hold this client doesn't know yet, and still reads as held: an unknown + // restriction must never read as claimable. Optional for older APIs. + held: z.string().min(1).optional().catch(undefined), }); export type Reward = z.infer; +// The one claimability rule: a reward is claimable unless it carries a hold, +// whatever kind. Everything that offers a claim, or counts rewards to claim, +// asks this. +export function isRewardClaimable(reward: Pick): boolean { + return reward.held === undefined; +} + // Player levels / XP ("progression"). Purely cosmetic: levels never affect // gameplay, and XP is computed server-side only — the client just displays it. // Level 1..100, prestige 0..10; `legend` is level 100 at the last prestige. @@ -301,7 +308,17 @@ export const ClaimAllRewardsResponseSchema = z.object({ // The rewards still pending after the claim because they are held (each // with `held`), in /users/@me's reward shape. Empty from older APIs, and // when the list can't be read: the next /users/@me read brings them back. - held: z.array(RewardSchema).default([]).catch([]), + // Read per element, so one malformed row drops only itself. + held: z + .array(z.unknown()) + .default([]) + .catch([]) + .transform((rows) => + rows.flatMap((row) => { + const parsed = RewardSchema.safeParse(row); + return parsed.success ? [parsed.data] : []; + }), + ), currency: CurrencyBalancesSchema, }); export type ClaimAllRewardsResponse = z.infer< @@ -311,7 +328,7 @@ export type ClaimAllRewardsResponse = z.infer< // POST /rewards/:rewardId/claim's 403 for a held reward (RewardSchema.held): // it stays pending, and claims once the hold lifts. export const ClaimRewardHeldResponseSchema = z.object({ - held: z.string(), + held: z.string().min(1), }); // Account-username lifecycle. `unclaimed`: no bare-name reservation (default). diff --git a/src/client/Api.ts b/src/client/Api.ts index ad12dc680e..f17b1bb238 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -1393,12 +1393,13 @@ export async function purchaseCosmeticPack( // credits the balance atomically. "not_found" covers unknown, already-claimed // and other players' rewards (indistinguishable by design); the usual cause is // a double-click or a second device claiming first, so callers should re-fetch -// /users/@me and re-render rather than surface an error. "held" is a reward -// that can't be claimed yet (level Plutonium on an account that isn't -// trusted): a 403 naming the hold. It stays pending, so nothing is lost. +// /users/@me and re-render rather than surface an error. `{ held }` is a reward +// that can't be claimed yet (e.g. "trust": level Plutonium on an account that +// isn't trusted): a 403 naming the hold, whose kind is passed on as named. It +// stays pending, so nothing is lost. export async function claimReward( rewardId: string, -): Promise { +): Promise { try { const response = await fetch( `${getApiBase()}/rewards/${encodeURIComponent(rewardId)}/claim`, @@ -1414,13 +1415,11 @@ export async function claimReward( return false; } if (response.status === 404) return "not_found"; - if ( - response.status === 403 && - ClaimRewardHeldResponseSchema.safeParse( + if (response.status === 403) { + const hold = ClaimRewardHeldResponseSchema.safeParse( await response.json().catch(() => null), - ).success - ) { - return "held"; + ); + if (hold.success) return { held: hold.data.held }; } if (!response.ok) { console.error( diff --git a/src/client/BootInterrupts.ts b/src/client/BootInterrupts.ts index 2fbd6e69e7..f4e025a84a 100644 --- a/src/client/BootInterrupts.ts +++ b/src/client/BootInterrupts.ts @@ -58,7 +58,11 @@ export interface BootInterruptInputs { * the two grant notices. Failing it lets the next interrupt take the boot. */ grantStringsReady: boolean; - /** How many unclaimed rewards the account is holding. */ + /** + * How many of the account's unclaimed rewards can be claimed now + * (isRewardClaimable): held ones don't count, since on their own they'd + * open the popup every boot with nothing to do. + */ rewardCount: number; /** claimPromptDue(...) — the decay rule below. */ claimPromptDue: boolean; diff --git a/src/client/Main.ts b/src/client/Main.ts index e967d5d70b..c5381ad1df 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,5 +1,8 @@ import { GAME_ID_REGEX } from "@openfront/engine-api/Schemas"; -import { UserMeResponse } from "@openfront/shared/ApiSchemas"; +import { + isRewardClaimable, + UserMeResponse, +} from "@openfront/shared/ApiSchemas"; import { assetUrl } from "@openfront/shared/AssetUrls"; import { EventBus } from "@openfront/shared/EventBus"; import { toWireGameStartInfo } from "@openfront/shared/SharedUtil"; @@ -865,7 +868,7 @@ class Client { grantStringsReady: steamGrantStringsReady(translateText), // Held rewards can't be claimed yet: on their own they'd open the // popup every boot with nothing to do. - rewardCount: rewards.filter((r) => r.held === undefined).length, + rewardCount: rewards.filter(isRewardClaimable).length, claimPromptDue: claimPromptDue(claimStore, Date.now(), publicId), claimStringsReady: claimPromptStringsReady(translateText), }), diff --git a/src/client/RewardsModal.ts b/src/client/RewardsModal.ts index 6705c8d983..88cc44665e 100644 --- a/src/client/RewardsModal.ts +++ b/src/client/RewardsModal.ts @@ -1,4 +1,4 @@ -import { Reward } from "@openfront/shared/ApiSchemas"; +import { isRewardClaimable, Reward } from "@openfront/shared/ApiSchemas"; import { html, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { BaseModal } from "./components/BaseModal"; @@ -23,8 +23,14 @@ export class RewardsModal extends BaseModal { this.open(); } + // Held rewards stay listed (with why), but on their own they leave the popup + // nothing to do: it opens, and stays open, only while one can be claimed. + private hasClaimable(): boolean { + return this.rewards.some(isRewardClaimable); + } + public open(args?: Record): void { - if (this.rewards.length === 0) return; + if (!this.hasClaimable()) return; super.open(args); } @@ -40,7 +46,7 @@ export class RewardsModal extends BaseModal { event: CustomEvent, ): void => { this.rewards = event.detail.rewards; - if (this.rewards.length === 0) this.close(); + if (!this.hasClaimable()) this.close(); }; protected renderBody(): TemplateResult { diff --git a/src/client/components/RewardsPanel.ts b/src/client/components/RewardsPanel.ts index 53418d8f1d..dd4d193a64 100644 --- a/src/client/components/RewardsPanel.ts +++ b/src/client/components/RewardsPanel.ts @@ -1,4 +1,4 @@ -import { Reward } from "@openfront/shared/ApiSchemas"; +import { isRewardClaimable, Reward } from "@openfront/shared/ApiSchemas"; import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { @@ -54,13 +54,21 @@ export class RewardsPanel extends LitElement { return; } invalidateUserMe(); - if (result === "held") { + if (result !== "not_found" && "held" in result) { // Held since this list was read (or read by an older API): nothing - // was claimed, so the wallet stays as it is. + // was claimed. A hold is on the account, not the reward, so others in + // this list may be held too: re-read the whole list rather than let + // each offer a claim that 403s in turn. The refused reward keeps the + // 403's hold either way. + const userMe = await getUserMe(); + const rewards = + userMe === false ? this.rewards : (userMe.player.rewards ?? []); this.emitChanged({ - currency: null, - rewards: this.rewards.map((r) => - r.id === reward.id ? { ...r, held: "trust" } : r, + currency: userMe === false ? null : (userMe.player.currency ?? null), + rewards: rewards.map((r) => + r.id === reward.id && isRewardClaimable(r) + ? { ...r, held: result.held } + : r, ), }); return; @@ -150,7 +158,7 @@ export class RewardsPanel extends LitElement { > - ${reward.held !== undefined + ${!isRewardClaimable(reward) ? html` r.held !== undefined)) return ""; - const key = crazyGamesSDK.isOnCrazyGames() + // The trust note. CrazyGames has no purchases, so its copy only suggests + // playing. + private trustNoteKey(): string { + return crazyGamesSDK.isOnCrazyGames() ? "account_modal.reward_held_trust_info_crazygames" : "account_modal.reward_held_trust_info"; - return html`

- ${translateText(key)} -

`; + } + + // A hold this client has no copy for. + private otherHoldNoteKey(): string { + return this.trustNoteKey(); + } + + // Why held rewards can't be claimed yet: once under the list per kind of + // hold, however many rewards it holds. + private renderHeldNotes(): TemplateResult[] { + const held = this.rewards.filter((r) => !isRewardClaimable(r)); + const keys = new Set(); + if (held.some((r) => r.held === "trust")) keys.add(this.trustNoteKey()); + if (held.some((r) => r.held !== "trust")) keys.add(this.otherHoldNoteKey()); + return [...keys].map( + (key) => + html`

+ ${translateText(key)} +

`, + ); } render() { if (this.rewards.length === 0) return html``; - const claimable = this.rewards.filter((r) => r.held === undefined); + const claimable = this.rewards.filter(isRewardClaimable); return html`
@@ -202,7 +226,7 @@ export class RewardsPanel extends LitElement {
${this.rewards.map((r) => this.renderReward(r))}
- ${this.renderHeldNote()} + ${this.renderHeldNotes()}
`; } diff --git a/tests/Api.test.ts b/tests/Api.test.ts index 4579d766aa..a233107bd0 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + claimReward, clearCreatorCode, getApiBase, getAudience, @@ -355,3 +356,70 @@ describe("creator code client functions", () => { }); }); }); + +describe("claimReward", () => { + let fetchMock: ReturnType; + + function respond(status: number, body: unknown) { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + } + + beforeEach(() => { + setConfig("openfront.io"); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "error").mockImplementation(() => {}); + logOut.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("returns the post-claim balances", async () => { + respond(200, { currency: { soft: "150", hard: "0" } }); + expect(await claimReward("7")).toEqual({ + currency: { soft: 150, hard: 0 }, + }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.openfront.io/rewards/7/claim"); + expect(init.method).toBe("POST"); + }); + + it("passes a held 403's hold on as named", async () => { + respond(403, { error: "Forbidden", resource: "reward", held: "trust" }); + expect(await claimReward("7")).toEqual({ held: "trust" }); + }); + + it("keeps a hold kind it doesn't know", async () => { + respond(403, { error: "Forbidden", resource: "reward", held: "other" }); + expect(await claimReward("7")).toEqual({ held: "other" }); + }); + + it.each([ + ["without a held body", { error: "Forbidden", message: "nope" }], + ["with an empty held", { error: "Forbidden", held: "" }], + ["with no JSON body", null], + ])("fails generically on a 403 %s", async (_case, body) => { + if (body === null) { + fetchMock.mockResolvedValueOnce( + new Response("Forbidden", { status: 403 }), + ); + } else { + respond(403, body); + } + expect(await claimReward("7")).toBe(false); + expect(logOut).not.toHaveBeenCalled(); + }); + + it("maps a 404 to not_found", async () => { + respond(404, { error: "Not found" }); + expect(await claimReward("7")).toBe("not_found"); + }); +}); diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index 459ad88875..c57a8c7552 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -6,6 +6,7 @@ import { GetMyTribeNamesResponseSchema, GoogleUser, GoogleUserSchema, + isRewardClaimable, isTemporaryUsername, isVerifiedUsername, PlayerGameModeFilterSchema, @@ -608,7 +609,7 @@ describe("RewardSchema", () => { if (result.success) expect(result.data.held).toBeUndefined(); }); - it.each([["future_hold"], [42], [null], [{ reason: "trust" }]])( + it.each([[42], [null], [{ reason: "trust" }], [""]])( "reads a malformed held (%j) as absent rather than failing", (held) => { const result = RewardSchema.safeParse({ ...validReward, held }); @@ -616,6 +617,27 @@ describe("RewardSchema", () => { if (result.success) expect(result.data.held).toBeUndefined(); }, ); + + it("keeps a hold kind it doesn't know, which is not claimable", () => { + const result = RewardSchema.safeParse({ + ...validReward, + held: "future_hold", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.held).toBe("future_hold"); + expect(isRewardClaimable(result.data)).toBe(false); + } + }); +}); + +describe("isRewardClaimable", () => { + it("is claimable only without a hold", () => { + expect(isRewardClaimable({})).toBe(true); + expect(isRewardClaimable({ held: undefined })).toBe(true); + expect(isRewardClaimable({ held: "trust" })).toBe(false); + expect(isRewardClaimable({ held: "other" })).toBe(false); + }); }); describe("UserMeResponseSchema rewards", () => { @@ -815,6 +837,34 @@ describe("claim response schemas", () => { if (result.success) expect(result.data.held).toEqual([]); }); + it("drops only the malformed rows of a claim-all's held list", () => { + const row = (id: string, held: string) => ({ + id, + currencyType: "hard", + amount: "50", + reason: "level_milestone", + note: null, + held, + }); + const result = ClaimAllRewardsResponseSchema.safeParse({ + claimed: [], + held: [ + row("7", "trust"), + { id: "8", currencyType: "gems" }, + "nope", + row("9", "future_hold"), + ], + currency: { soft: "0", hard: "0" }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.held).toEqual([ + row("7", "trust"), + row("9", "future_hold"), + ]); + } + }); + it("recognises the 403 body for a held reward", () => { expect( ClaimRewardHeldResponseSchema.safeParse({ @@ -830,6 +880,9 @@ describe("claim response schemas", () => { message: "nope", }).success, ).toBe(false); + expect(ClaimRewardHeldResponseSchema.safeParse({ held: "" }).success).toBe( + false, + ); }); }); diff --git a/tests/client/RewardsModal.test.ts b/tests/client/RewardsModal.test.ts new file mode 100644 index 0000000000..4f5de99b59 --- /dev/null +++ b/tests/client/RewardsModal.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string) => key, +})); + +vi.mock("../../src/client/Api", () => ({ + claimAllRewards: vi.fn(), + claimReward: vi.fn(), + getUserMe: vi.fn(), + invalidateUserMe: vi.fn(), +})); + +vi.mock("../../src/client/InGameModal", () => ({ + showInGameAlert: vi.fn(), +})); + +vi.mock("../../src/client/CrazyGamesSDK", () => ({ + crazyGamesSDK: { isOnCrazyGames: vi.fn(() => false) }, +})); + +import type { Reward } from "@openfront/shared/ApiSchemas"; +import type { RewardsChangedDetail } from "../../src/client/components/RewardsPanel"; +import { RewardsModal } from "../../src/client/RewardsModal"; + +if (!customElements.get("rewards-modal")) { + customElements.define("rewards-modal", RewardsModal); +} + +function reward(id: string, held?: string): Reward { + return { + id, + currencyType: "hard", + amount: "50", + reason: "level_milestone", + note: null, + ...(held === undefined ? {} : { held }), + }; +} + +describe(" dismissal", () => { + let el: RewardsModal | undefined; + + afterEach(() => { + el?.remove(); + el = undefined; + }); + + async function mount(rewards: Reward[]): Promise { + el = document.createElement("rewards-modal") as RewardsModal; + document.body.appendChild(el); + await el.updateComplete; + el.openWithRewards(rewards); + await el.updateComplete; + return el; + } + + // What the panel emits after a claim; the modal listens on the panel. + async function panelChanged( + modal: RewardsModal, + detail: RewardsChangedDetail, + ): Promise { + const panel = modal.querySelector("rewards-panel"); + expect(panel).not.toBeNull(); + panel!.dispatchEvent( + new CustomEvent("rewards-changed", { + detail, + bubbles: true, + composed: true, + }), + ); + await modal.updateComplete; + } + + it("closes once only held rewards remain", async () => { + const modal = await mount([reward("1"), reward("2", "trust")]); + expect(modal.isOpen()).toBe(true); + await panelChanged(modal, { + currency: { soft: 0, hard: 50 }, + rewards: [reward("2", "trust")], + }); + expect(modal.isOpen()).toBe(false); + }); + + it("stays open while a reward can still be claimed", async () => { + const modal = await mount([reward("1"), reward("2"), reward("3", "trust")]); + await panelChanged(modal, { + currency: { soft: 0, hard: 50 }, + rewards: [reward("2"), reward("3", "trust")], + }); + expect(modal.isOpen()).toBe(true); + }); + + it("closes when the list empties", async () => { + const modal = await mount([reward("1")]); + await panelChanged(modal, { currency: { soft: 0, hard: 50 }, rewards: [] }); + expect(modal.isOpen()).toBe(false); + }); + + it("doesn't open with nothing to claim", async () => { + const modal = await mount([reward("1", "trust"), reward("2", "other")]); + expect(modal.isOpen()).toBe(false); + }); +}); diff --git a/tests/client/RewardsPanel.test.ts b/tests/client/RewardsPanel.test.ts index 37f67a2955..8abbc775d9 100644 --- a/tests/client/RewardsPanel.test.ts +++ b/tests/client/RewardsPanel.test.ts @@ -19,8 +19,13 @@ vi.mock("../../src/client/CrazyGamesSDK", () => ({ crazyGamesSDK: { isOnCrazyGames: vi.fn(() => false) }, })); -import type { Reward } from "@openfront/shared/ApiSchemas"; -import { claimAllRewards, claimReward } from "../../src/client/Api"; +import type { Reward, UserMeResponse } from "@openfront/shared/ApiSchemas"; +import { + claimAllRewards, + claimReward, + getUserMe, + invalidateUserMe, +} from "../../src/client/Api"; import { RewardsPanel, type RewardsChangedDetail, @@ -205,16 +210,21 @@ describe(" held rewards", () => { expect(showInGameAlert).not.toHaveBeenCalled(); }); - it("marks a reward held when its claim is refused as held", async () => { - vi.mocked(claimReward).mockResolvedValue("held"); - const level = reward("level_up"); - const panel = await mount([plutonium("1"), level]); - const seen = changes(panel); + function clickFirstClaim(panel: RewardsPanel): void { ( panel.querySelector( 'o-button[translationKey="account_modal.claim"]', ) as HTMLElement ).click(); + } + + it("marks a reward held when its claim is refused as held", async () => { + vi.mocked(claimReward).mockResolvedValue({ held: "trust" }); + vi.mocked(getUserMe).mockResolvedValue(false); + const level = reward("level_up"); + const panel = await mount([plutonium("1"), level]); + const seen = changes(panel); + clickFirstClaim(panel); await vi.waitFor(() => expect(seen).toHaveLength(1)); expect(claimReward).toHaveBeenCalledWith("1"); expect(showInGameAlert).not.toHaveBeenCalled(); @@ -231,6 +241,63 @@ describe(" held rewards", () => { expect(panel.querySelector("[data-reward-held-note]")).not.toBeNull(); }); + it("keeps the hold the 403 named, not an assumed one", async () => { + vi.mocked(claimReward).mockResolvedValue({ held: "other" }); + vi.mocked(getUserMe).mockResolvedValue(false); + const panel = await mount([plutonium("1")]); + const seen = changes(panel); + clickFirstClaim(panel); + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0].rewards).toEqual([{ ...plutonium("1"), held: "other" }]); + }); + + it("re-reads the whole list after a held refusal: the hold is per account", async () => { + vi.mocked(claimReward).mockResolvedValue({ held: "trust" }); + // The fresh list holds both milestones, not just the one clicked. + vi.mocked(getUserMe).mockResolvedValue({ + player: { + currency: { soft: 10, hard: 0 }, + rewards: [plutonium("1", "trust"), plutonium("2", "trust")], + }, + } as unknown as UserMeResponse); + const panel = await mount([plutonium("1"), plutonium("2")]); + const seen = changes(panel); + clickFirstClaim(panel); + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(invalidateUserMe).toHaveBeenCalled(); + expect(seen[0]).toEqual({ + currency: { soft: 10, hard: 0 }, + rewards: [plutonium("1", "trust"), plutonium("2", "trust")], + }); + }); + + it("marks the refused reward held even if the fresh list lags", async () => { + vi.mocked(claimReward).mockResolvedValue({ held: "trust" }); + vi.mocked(getUserMe).mockResolvedValue({ + player: { rewards: [plutonium("1"), plutonium("2")] }, + } as unknown as UserMeResponse); + const panel = await mount([plutonium("1"), plutonium("2")]); + const seen = changes(panel); + clickFirstClaim(panel); + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0]).toEqual({ + currency: null, + rewards: [plutonium("1", "trust"), plutonium("2")], + }); + }); + + it("treats a hold it has no copy for as held", async () => { + const panel = await mount([ + { ...plutonium("1"), held: "future_hold" }, + reward("level_up"), + reward("prestige"), + ]); + expect(rows(panel)).toEqual(["held", "claim", "claim"]); + expect( + panel.querySelectorAll("[data-reward-held-note]").length, + ).toBeGreaterThan(0); + }); + it("still alerts when a claim fails for another reason", async () => { vi.mocked(claimReward).mockResolvedValue(false); const panel = await mount([plutonium("1")]); From ad9bbcf363d247082edeb8fcbe02136dfec05ddb Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 20:03:22 +0100 Subject: [PATCH 3/4] Held rewards: a malformed hold reads as held too A held value that isn't a non-empty string (a number, an object, "") now reads as an unknown hold rather than as claimable; only absent or null is claimable. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 15 +++++++++++++-- tests/ApiSchemas.test.ts | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index fc045d95b2..3437f9b8a5 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -108,8 +108,19 @@ export const RewardSchema = z.object({ // Why the reward can't be claimed yet; absent when it can. "trust": level // Plutonium, kept until the account is trusted. Any other non-empty value is // a hold this client doesn't know yet, and still reads as held: an unknown - // restriction must never read as claimable. Optional for older APIs. - held: z.string().min(1).optional().catch(undefined), + // restriction must never read as claimable, so a malformed value (a number, + // an object, "") reads as held too. Absent or null: claimable, as from + // older APIs. + held: z + .unknown() + .transform((v): string | undefined => + v === undefined || v === null + ? undefined + : typeof v === "string" && v.length > 0 + ? v + : "unknown", + ) + .optional(), }); export type Reward = z.infer; diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index c57a8c7552..47d640b592 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -609,15 +609,24 @@ describe("RewardSchema", () => { if (result.success) expect(result.data.held).toBeUndefined(); }); - it.each([[42], [null], [{ reason: "trust" }], [""]])( - "reads a malformed held (%j) as absent rather than failing", + it.each([[42], [{ reason: "trust" }], [""], [true]])( + "reads a malformed held (%j) as held, never claimable", (held) => { const result = RewardSchema.safeParse({ ...validReward, held }); expect(result.success).toBe(true); - if (result.success) expect(result.data.held).toBeUndefined(); + if (result.success) { + expect(result.data.held).toBe("unknown"); + expect(isRewardClaimable(result.data)).toBe(false); + } }, ); + it("reads a null held as claimable", () => { + const result = RewardSchema.safeParse({ ...validReward, held: null }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.held).toBeUndefined(); + }); + it("keeps a hold kind it doesn't know, which is not claimable", () => { const result = RewardSchema.safeParse({ ...validReward, From 2c3006e002beb4cb2f2f6f3c7398712327905f80 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 20:01:10 +0100 Subject: [PATCH 4/4] Held rewards: say how to become trusted, sign-in first DRAFT copy, pending design approval. - Trust needs a linked identity: without one an account is never trusted, whatever it plays or buys. RewardsPanel takes a signedIn flag (responseHasLinkedIdentity(userMe) || a CrazyGames sign-in, as for trustRequiredDialog) from AccountModal, RewardsModal and Main's boot popup, and a signed-out viewer is told to sign in first. - Name the floors: 14 days old and 25 public games (TRUST_MIN_ACCOUNT_AGE_DAYS / TRUST_MIN_GAMES), or any purchase off CrazyGames. - A hold with no copy of its own gets a neutral note, reward_held_other_info, and no "Trusted accounts only" label. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 7 ++- src/client/AccountModal.ts | 7 ++- src/client/Main.ts | 18 +++++-- src/client/RewardsModal.ts | 6 ++- src/client/components/RewardsPanel.ts | 34 +++++++++---- tests/client/AccountModalPrestige.test.ts | 8 +++ tests/client/RewardsModal.test.ts | 18 ++++++- tests/client/RewardsPanel.test.ts | 62 ++++++++++++++++++++--- 8 files changed, 134 insertions(+), 26 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 861c9089a6..5905d49daf 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -85,9 +85,12 @@ "reactivate_subscription": "Reactivate", "recovery_email_sent": "Recovery email sent to {email}", "reward_daily": "Daily subscription reward", + "reward_held_other_info": "Some rewards can't be claimed yet.", "reward_held_trust": "Trusted accounts only", - "reward_held_trust_info": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted after you play more public games, or right away with any purchase.", - "reward_held_trust_info_crazygames": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted after you play more public games.", + "reward_held_trust_info": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted once it is 14 days old and you've played 25 public games, or right away with any purchase.", + "reward_held_trust_info_crazygames": "Plutonium from levels is kept for you until your account is trusted. Your account becomes trusted once it is 14 days old and you've played 25 public games.", + "reward_held_trust_info_signed_out": "Plutonium from levels is kept for you until your account is trusted. To become trusted, sign in to your account, then play more public games or make any purchase.", + "reward_held_trust_info_signed_out_crazygames": "Plutonium from levels is kept for you until your account is trusted. To become trusted, sign in to your account, then play more public games.", "reward_level_milestone": "Level milestone reward", "reward_level_up": "Level-up reward", "reward_prestige": "Prestige reward", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 2a940d80d8..260cd3f1fc 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -7,7 +7,10 @@ import { assetUrl } from "@openfront/shared/AssetUrls"; import { html, nothing, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; -import { hasLinkedIdentity } from "./AccountIdentity"; +import { + hasLinkedIdentity, + responseHasLinkedIdentity, +} from "./AccountIdentity"; import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api"; import { discordLogin, @@ -446,6 +449,8 @@ export class AccountModal extends BaseModal { if (rewards.length === 0) return ""; return html``; } diff --git a/src/client/Main.ts b/src/client/Main.ts index c5381ad1df..b3bdb19899 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -17,6 +17,7 @@ import { import { GameEnv } from "@openfront/shared/configuration/Env"; import { ClientEnv } from "src/client/ClientEnv"; import { renderNavVersion } from "src/client/GameVersion"; +import { responseHasLinkedIdentity } from "./AccountIdentity"; import { adGatekeeper } from "./AdGatekeeper"; import { loadAdmiral, onAdmiralMeasured } from "./Admiral"; import { getUserMe, invalidateUserMe } from "./Api"; @@ -890,10 +891,19 @@ class Client { navigate: (hash) => { window.location.hash = hash; }, - openRewards: () => - whenModalLoaded("rewards-modal", () => - this.rewardsModal?.openWithRewards(rewards), - ), + openRewards: () => { + // Signed in as for trustRequiredDialog: a linked identity, or a + // CrazyGames sign-in (only the SDK knows that one). + void (async () => { + const signedIn = + responseHasLinkedIdentity(userMeResponse) || + (crazyGamesSDK.isOnCrazyGames() && + (await crazyGamesSDK.getUserProfile()) !== null); + whenModalLoaded("rewards-modal", () => + this.rewardsModal?.openWithRewards(rewards, signedIn), + ); + })(); + }, storeClaimPrompt: (store) => localStorage.setItem(CLAIM_PROMPT_KEY, JSON.stringify(store)), storeSteamGrant: (store) => diff --git a/src/client/RewardsModal.ts b/src/client/RewardsModal.ts index 88cc44665e..2e64aa194f 100644 --- a/src/client/RewardsModal.ts +++ b/src/client/RewardsModal.ts @@ -13,13 +13,16 @@ import { translateText } from "./Utils"; @customElement("rewards-modal") export class RewardsModal extends BaseModal { @state() private rewards: Reward[] = []; + // RewardsPanel.signedIn: picks the held-reward note. + @state() private signedIn = false; protected modalConfig() { return { maxWidth: "620px" }; } - public openWithRewards(rewards: Reward[]): void { + public openWithRewards(rewards: Reward[], signedIn: boolean): void { this.rewards = rewards; + this.signedIn = signedIn; this.open(); } @@ -54,6 +57,7 @@ export class RewardsModal extends BaseModal {
diff --git a/src/client/components/RewardsPanel.ts b/src/client/components/RewardsPanel.ts index dd4d193a64..bd8e93501b 100644 --- a/src/client/components/RewardsPanel.ts +++ b/src/client/components/RewardsPanel.ts @@ -28,6 +28,13 @@ export class RewardsPanel extends LitElement { @property({ type: Array }) rewards: Reward[] = []; + // Whether the viewer has an account trust can attach to: a linked identity + // (responseHasLinkedIdentity) or a CrazyGames sign-in, as for + // trustRequiredDialog. Without one an account is never trusted, whatever it + // plays or buys, so the trust note tells them to sign in first. + @property({ type: Boolean }) + signedIn = false; + @state() private claiming = false; createRenderRoot() { @@ -159,10 +166,13 @@ export class RewardsPanel extends LitElement {
${!isRewardClaimable(reward) - ? html`${translateText("account_modal.reward_held_trust")}${reward.held === "trust" + ? translateText("account_modal.reward_held_trust") + : ""}
` : html` { await modal.updateComplete; }; + it("tells the rewards panel a linked account is signed in", () => { + const panel = modal.querySelector("rewards-panel") as unknown as { + signedIn: boolean; + } | null; + expect(panel).not.toBeNull(); + expect(panel!.signedIn).toBe(true); + }); + it("opens the prestige flow on the press", async () => { // Already on its way: the card offers Prestige. await modal.loadPrestigeFlow(); diff --git a/tests/client/RewardsModal.test.ts b/tests/client/RewardsModal.test.ts index 4f5de99b59..61a04ec821 100644 --- a/tests/client/RewardsModal.test.ts +++ b/tests/client/RewardsModal.test.ts @@ -46,15 +46,29 @@ describe(" dismissal", () => { el = undefined; }); - async function mount(rewards: Reward[]): Promise { + async function mount( + rewards: Reward[], + signedIn = true, + ): Promise { el = document.createElement("rewards-modal") as RewardsModal; document.body.appendChild(el); await el.updateComplete; - el.openWithRewards(rewards); + el.openWithRewards(rewards, signedIn); await el.updateComplete; return el; } + it.each([true, false])( + "passes the viewer's sign-in (%s) to the panel", + async (signedIn) => { + const modal = await mount([reward("1"), reward("2", "trust")], signedIn); + const panel = modal.querySelector("rewards-panel") as unknown as { + signedIn: boolean; + }; + expect(panel.signedIn).toBe(signedIn); + }, + ); + // What the panel emits after a claim; the modal listens on the panel. async function panelChanged( modal: RewardsModal, diff --git a/tests/client/RewardsPanel.test.ts b/tests/client/RewardsPanel.test.ts index 8abbc775d9..f0d30e1fa5 100644 --- a/tests/client/RewardsPanel.test.ts +++ b/tests/client/RewardsPanel.test.ts @@ -116,14 +116,24 @@ describe(" held rewards", () => { }; } - async function mount(rewards: Reward[]): Promise { + async function mount( + rewards: Reward[], + signedIn = true, + ): Promise { el = document.createElement("rewards-panel") as RewardsPanel; el.rewards = rewards; + el.signedIn = signedIn; document.body.appendChild(el); await el.updateComplete; return el; } + function notes(panel: RewardsPanel): string[] { + return [...panel.querySelectorAll("[data-reward-held-note]")].map( + (n) => n.textContent?.trim() ?? "", + ); + } + // Each reward's row, keyed by the claim affordance it offers. function rows(panel: RewardsPanel): ("claim" | "held")[] { return [...panel.querySelectorAll("div.justify-between.p-3")].map((row) => @@ -159,12 +169,50 @@ describe(" held rewards", () => { ).toBe("account_modal.reward_held_trust_info"); }); - it("uses the CrazyGames note there (no purchases)", async () => { - vi.mocked(crazyGamesSDK.isOnCrazyGames).mockReturnValue(true); - const panel = await mount([plutonium("1", "trust")]); - expect( - panel.querySelector("[data-reward-held-note]")?.textContent?.trim(), - ).toBe("account_modal.reward_held_trust_info_crazygames"); + // Trust needs an account to attach to, and CrazyGames has no purchases. + it.each([ + [true, false, "account_modal.reward_held_trust_info"], + [true, true, "account_modal.reward_held_trust_info_crazygames"], + [false, false, "account_modal.reward_held_trust_info_signed_out"], + [false, true, "account_modal.reward_held_trust_info_signed_out_crazygames"], + ])( + "picks the trust note by sign-in (%s) and CrazyGames (%s): %s", + async (signedIn, onCrazyGames, key) => { + vi.mocked(crazyGamesSDK.isOnCrazyGames).mockReturnValue(onCrazyGames); + const panel = await mount([plutonium("1", "trust")], signedIn); + expect(notes(panel)).toEqual([key]); + }, + ); + + it("treats a missing sign-in flag as signed out", async () => { + el = document.createElement("rewards-panel") as RewardsPanel; + el.rewards = [plutonium("1", "trust")]; + document.body.appendChild(el); + await el.updateComplete; + expect(notes(el)).toEqual([ + "account_modal.reward_held_trust_info_signed_out", + ]); + }); + + it("gives a hold it has no copy for the neutral note, and no trust label", async () => { + const panel = await mount([{ ...plutonium("1"), held: "future_hold" }]); + expect(rows(panel)).toEqual(["held"]); + expect(panel.querySelector("[data-reward-held]")?.textContent?.trim()).toBe( + "", + ); + expect(notes(panel)).toEqual(["account_modal.reward_held_other_info"]); + }); + + it("explains each kind of hold once", async () => { + const panel = await mount([ + plutonium("1", "trust"), + plutonium("2", "trust"), + { ...plutonium("3"), held: "future_hold" }, + ]); + expect(notes(panel)).toEqual([ + "account_modal.reward_held_trust_info", + "account_modal.reward_held_other_info", + ]); }); it("shows no note when nothing is held", async () => {