diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 4f52d7f1c2..dd7534792e 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -105,9 +105,32 @@ 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. 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, 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; +// 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. @@ -293,12 +316,32 @@ 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. + // 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< 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().min(1), +}); + // 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 01a0695673..c4e123736d 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -85,6 +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 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/Api.ts b/src/client/Api.ts index 04b77a137e..f17b1bb238 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,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. +// /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`, @@ -1411,6 +1415,12 @@ export async function claimReward( return false; } if (response.status === 404) return "not_found"; + if (response.status === 403) { + const hold = ClaimRewardHeldResponseSchema.safeParse( + await response.json().catch(() => null), + ); + if (hold.success) return { held: hold.data.held }; + } if (!response.ok) { console.error( "claimReward: request failed", 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 82ab310348..3fb3445d94 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"; @@ -14,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"; @@ -863,7 +867,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(isRewardClaimable).length, claimPromptDue: claimPromptDue(claimStore, Date.now(), publicId), claimStringsReady: claimPromptStringsReady(translateText), }), @@ -885,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 6705c8d983..2e64aa194f 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"; @@ -13,18 +13,27 @@ 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(); } + // 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 +49,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 { @@ -48,6 +57,7 @@ export class RewardsModal extends BaseModal {
diff --git a/src/client/components/RewardsPanel.ts b/src/client/components/RewardsPanel.ts index 1e504d3671..bd8e93501b 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 { @@ -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"; @@ -27,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() { @@ -53,6 +61,25 @@ export class RewardsPanel extends LitElement { return; } invalidateUserMe(); + if (result !== "not_found" && "held" in result) { + // Held since this list was read (or read by an older API): nothing + // 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: userMe === false ? null : (userMe.player.currency ?? null), + rewards: rewards.map((r) => + r.id === reward.id && isRewardClaimable(r) + ? { ...r, held: result.held } + : 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 +112,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 +165,63 @@ export class RewardsPanel extends LitElement { > - this.handleClaim(reward)} - > + ${!isRewardClaimable(reward) + ? // A hold with no copy of its own is left to the note below. + html`${reward.held === "trust" + ? translateText("account_modal.reward_held_trust") + : ""}` + : html` this.handleClaim(reward)} + >`} `; } + // The trust note, by whether the viewer is signed in (see signedIn) and on + // CrazyGames, which has no purchases, so its copy only suggests playing. + private trustNoteKey(): string { + const onCrazyGames = crazyGamesSDK.isOnCrazyGames(); + if (this.signedIn) { + return onCrazyGames + ? "account_modal.reward_held_trust_info_crazygames" + : "account_modal.reward_held_trust_info"; + } + return onCrazyGames + ? "account_modal.reward_held_trust_info_signed_out_crazygames" + : "account_modal.reward_held_trust_info_signed_out"; + } + + // A hold this client has no copy for: say only that it can't be claimed. + private otherHoldNoteKey(): string { + return "account_modal.reward_held_other_info"; + } + + // 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(isRewardClaimable); return html`
@@ -157,7 +229,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.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 7e5bf283f2..47d640b592 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -1,10 +1,12 @@ import { ClaimAllRewardsResponseSchema, + ClaimRewardHeldResponseSchema, ClaimRewardResponseSchema, FriendEntrySchema, GetMyTribeNamesResponseSchema, GoogleUser, GoogleUserSchema, + isRewardClaimable, isTemporaryUsername, isVerifiedUsername, PlayerGameModeFilterSchema, @@ -594,6 +596,57 @@ 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([[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).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, + 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", () => { @@ -629,6 +682,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 +795,104 @@ 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("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({ + 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); + expect(ClaimRewardHeldResponseSchema.safeParse({ held: "" }).success).toBe( + false, + ); + }); }); describe("UserMeResponseSchema canCreatePublicLobbies", () => { diff --git a/tests/client/AccountModalPrestige.test.ts b/tests/client/AccountModalPrestige.test.ts index 41ca9a269b..148c74d5db 100644 --- a/tests/client/AccountModalPrestige.test.ts +++ b/tests/client/AccountModalPrestige.test.ts @@ -142,6 +142,14 @@ describe("AccountModal — after a prestige", () => { 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/ProgressionSchemas.test.ts b/tests/client/ProgressionSchemas.test.ts index 99881f7353..bbe20da65e 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/RewardsModal.test.ts b/tests/client/RewardsModal.test.ts new file mode 100644 index 0000000000..61a04ec821 --- /dev/null +++ b/tests/client/RewardsModal.test.ts @@ -0,0 +1,118 @@ +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[], + signedIn = true, + ): Promise { + el = document.createElement("rewards-modal") as RewardsModal; + document.body.appendChild(el); + await el.updateComplete; + 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, + 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 ec6d7db48d..f0d30e1fa5 100644 --- a/tests/client/RewardsPanel.test.ts +++ b/tests/client/RewardsPanel.test.ts @@ -15,8 +15,23 @@ vi.mock("../../src/client/InGameModal", () => ({ showInGameAlert: vi.fn(), })); -import type { Reward } from "@openfront/shared/ApiSchemas"; -import { RewardsPanel } from "../../src/client/components/RewardsPanel"; +vi.mock("../../src/client/CrazyGamesSDK", () => ({ + crazyGamesSDK: { isOnCrazyGames: vi.fn(() => false) }, +})); + +import type { Reward, UserMeResponse } from "@openfront/shared/ApiSchemas"; +import { + claimAllRewards, + claimReward, + getUserMe, + invalidateUserMe, +} 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 +94,272 @@ 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[], + 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) => + 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"); + }); + + // 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 () => { + 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(); + }); + + 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(); + // 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("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")]); + 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([]); + }); +});