diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 871ee2f7500..f7805823e2f 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -168,6 +168,34 @@ export const XpBreakdownSchema = z.object({ }); export type XpBreakdown = z.infer; +// A level reward one game earned (GET /users/@me/xp/:gameId `rewards`). +export const GameXpRewardSchema = RewardSchema.extend({ + prestige: z.number(), + level: z.number(), + claimed: z.boolean(), +}); +export type GameXpReward = z.infer; + +// A flare one game unlocked from the level track: a level's (`prestige` and +// `level` are the run and level crossed) or Legend's (both null). `kind` +// stays a string so a new kind never fails the parse. +export const GameXpFlareSchema = z.object({ + kind: z.string(), + prestige: z.number().nullable(), + level: z.number().nullable(), + flareId: z.string(), + flareName: z.string(), + // The cosmetic behind the flare, when it is one. + cosmetic: z + .object({ + type: z.string(), + name: z.string(), + url: z.string().nullable(), + }) + .nullable(), +}); +export type GameXpFlare = z.infer; + // GET /users/@me/xp/:gameId — the XP a finished game awarded the caller. 404 // until the game has been processed. `reason` is open-ended server-side, so it // stays a string: an unknown reason shows a generic line, never a parse error. @@ -182,6 +210,14 @@ export const GameXpEligibleSchema = z.object({ .array(z.object({ prestige: z.number(), level: z.number() })) .optional() .default([]), + // What the levels this game crossed paid: one entry per grant, in level + // order, Caps before Plutonium. `id` is the reward to claim while + // `claimed` is false. Optional (an API without it still parses), and a + // malformed list reads as absent rather than failing the result. + rewards: z.array(GameXpRewardSchema).optional().catch(undefined), + // The cosmetics this game unlocked from the level track (already granted: + // nothing to claim). Same leniency as `rewards`. + flares: z.array(GameXpFlareSchema).optional().catch(undefined), }); export const GameXpIneligibleSchema = z.object({ gameId: z.string(), diff --git a/resources/lang/en.json b/resources/lang/en.json index 5d55d86f365..bc7c7f20769 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1921,6 +1921,14 @@ "penalty_line": "−{percent}% XP ({source})", "prestige": "Prestige {prestige}", "prestige_short": "P{prestige}", + "rewards_balance": "Balance {caps} Caps · {plutonium} Plutonium", + "rewards_balance_caps": "Balance {caps} Caps", + "rewards_claimed": "Claimed", + "rewards_claiming": "Claiming", + "rewards_each": "({count} × {amount})", + "rewards_level_many": "Level {level} rewards", + "rewards_level_one": "Level {level} reward", + "rewards_levels": "Levels {from}–{to}", "share_moment_legend": "Share Legend", "share_moment_level": "Share Level {level}", "share_text_legend": "I just became a Legend on OpenFront!", @@ -1928,6 +1936,9 @@ "sign_in_to_earn": "Sign in to earn XP and level up.", "skip_reveal": "Skip", "tap_to_skip": "Tap to skip", + "unlock_in_locker": "{type} · in your Locker", + "unlocked": "Unlocked", + "unlocked_cosmetic": "Cosmetic", "xp_bar_label": "Level progress", "xp_before_levels": "Played before levels existed, so there's no XP for this game.", "xp_heading": "XP earned", diff --git a/src/client/GameRewards.ts b/src/client/GameRewards.ts new file mode 100644 index 00000000000..9b843c1f3fe --- /dev/null +++ b/src/client/GameRewards.ts @@ -0,0 +1,201 @@ +import type { GameXpFlare, GameXpReward } from "@openfront/shared/ApiSchemas"; +import { claimReward } from "./Api"; +import { + cosmeticSelectionLabel, + cosmeticTypeLabel, +} from "./components/CosmeticPresentation"; +import { + findPackItem, + type ResolvedCosmetic, + translateCosmetic, +} from "./Cosmetics"; +import { translateText } from "./Utils"; + +// The level rewards and unlocks one game earned, as the end-of-game reveal +// shows them: summed per currency, and claimed together (only this game's). + +export type Currency = "soft" | "hard"; + +export interface RewardLine { + currency: Currency; + // Sum of this game's rewards in the currency (amounts are stringified + // bigints; summed exactly). + total: bigint; + count: number; + // The amount of each, when several rewards in this currency are all the + // same ("3 × 100"); null otherwise. + each: bigint | null; +} + +export interface GameRewardsSummary { + // The row's heading, a translation key and its params. + headingKey: string; + headingParams: Record; + // Caps first, then Plutonium; a currency the game didn't pay is left out. + lines: RewardLine[]; + // Every reward was already claimed (elsewhere, or on an earlier view). + allClaimed: boolean; +} + +function amountOf(reward: GameXpReward): bigint { + try { + return BigInt(reward.amount); + } catch { + return 0n; + } +} + +/** + * The rewards row for one game: "Level 47 reward +100 Caps", "Levels 45–47 + * +300 Caps (3 × 100)", "Level 50 rewards +100 Caps +25 Plutonium". Null + * when the game paid nothing. + */ +export function summarizeGameRewards( + rewards: readonly GameXpReward[], +): GameRewardsSummary | null { + if (rewards.length === 0) return null; + const levels = [...new Set(rewards.map((r) => r.level))].sort( + (a, b) => a - b, + ); + let headingKey: string; + let headingParams: Record; + if (levels.length > 1) { + headingKey = "progression.rewards_levels"; + headingParams = { from: levels[0], to: levels[levels.length - 1] }; + } else { + headingKey = + rewards.length > 1 + ? "progression.rewards_level_many" + : "progression.rewards_level_one"; + headingParams = { level: levels[0] }; + } + const lines: RewardLine[] = []; + for (const currency of ["soft", "hard"] as const) { + const these = rewards.filter((r) => r.currencyType === currency); + if (these.length === 0) continue; + const amounts = these.map(amountOf); + const total = amounts.reduce((a, b) => a + b, 0n); + const each = + amounts.length > 1 && amounts.every((a) => a === amounts[0]) + ? amounts[0] + : null; + lines.push({ currency, total, count: these.length, each }); + } + return { + headingKey, + headingParams, + lines, + allClaimed: rewards.every((r) => r.claimed), + }; +} + +export interface Balances { + soft: number; + hard: number; +} + +export type ClaimOutcome = + | { + ok: true; + // The balances after the last claim this call made, or null when it + // made none (all were claimed elsewhere first). + currency: Balances | null; + // What this call's claims added to the balances. + credited: Balances; + claimedIds: string[]; + } + // A claim failed. The ones before it went through (`claimedIds`), so a + // retry claims only the rest. + | { ok: false; claimedIds: string[] }; + +/** + * Claims this game's unclaimed rewards, one by one, and only those: never + * the player's other pending rewards. `skip` holds ids already claimed from + * this view. A reward someone else claimed first (a second device, the + * rewards panel) answers not_found and counts as claimed. + */ +export async function claimGameRewards( + rewards: readonly GameXpReward[], + skip: ReadonlySet = new Set(), +): Promise { + const claimedIds: string[] = []; + const credited: Balances = { soft: 0, hard: 0 }; + let currency: Balances | null = null; + for (const reward of rewards) { + if (reward.claimed || skip.has(reward.id)) continue; + const result = await claimReward(reward.id); + if (result === false) return { ok: false, claimedIds }; + claimedIds.push(reward.id); + if (result === "not_found") continue; + currency = result.currency; + credited[reward.currencyType] += Number(amountOf(reward)); + } + return { ok: true, currency, credited, claimedIds }; +} + +export interface FlareUnlock { + flare: GameXpFlare; + // The catalog entry, for its preview; null while the catalog is unknown or + // when it no longer lists the cosmetic. + resolved: ResolvedCosmetic | null; + name: string; + // "Skin", "Boat Trail Effect", … + typeLabel: string; +} + +const NAME_PREFIXES: Record = { + pattern: "territory_patterns.pattern", + skin: "territory_patterns.pattern", + flag: "flags", + crown: "crowns", + effect: "effects", +}; + +const TYPE_KEYS: Record = { + pattern: "cosmetics.type_skin", + skin: "cosmetics.type_skin", + flag: "cosmetics.type_flag", + crown: "cosmetics.type_crown", +}; + +/** + * What a flare unlocked, for the reveal's "Unlocked" card: its catalog entry + * where `catalog` lists it (flares spell cosmetics "type:name", or + * "pattern:name:palette"), named the way the store names it. + */ +export function flareUnlock( + flare: GameXpFlare, + catalog: readonly ResolvedCosmetic[], +): FlareUnlock { + const [type = "", name = "", palette] = flare.flareName.split(":"); + const known = ["pattern", "flag", "skin", "crown", "effect"].includes(type); + const resolved = known + ? (findPackItem( + { + type: type as "pattern" | "flag" | "skin" | "crown" | "effect", + name, + colorPalette: palette, + }, + catalog, + ) ?? null) + : null; + if (resolved !== null) { + return { + flare, + resolved, + name: cosmeticSelectionLabel(resolved), + typeLabel: cosmeticTypeLabel(resolved), + }; + } + const prefix = NAME_PREFIXES[type]; + const typeKey = TYPE_KEYS[type]; + return { + flare, + resolved: null, + name: + prefix !== undefined && name !== "" + ? translateCosmetic(prefix, name) + : flare.flareName, + typeLabel: translateText(typeKey ?? "progression.unlocked_cosmetic"), + }; +} diff --git a/src/client/Progression.ts b/src/client/Progression.ts index 2469c947647..efa0448ff5c 100644 --- a/src/client/Progression.ts +++ b/src/client/Progression.ts @@ -5,7 +5,7 @@ import type { XpBreakdown } from "@openfront/shared/ApiSchemas"; // components follow are unit-testable on their own. // The badge art is cut for a 100-level track with 10 prestige ranks (see -// levelBand, prestigeTier), so the caps stay client constants: raising either +// levelBand, prestigeStyle), so the caps stay client constants: raising either // on the server needs new art, not only a new number. A rank past // MAX_PRESTIGE still shows its own number, drawn with the last rank's art. export const MAX_LEVEL = 100; @@ -33,19 +33,194 @@ export function clampPrestige(prestige: number): number { } /** - * The prestige emblem drawn around a badge. Each tier is a different SHAPE, - * not just a colour (colour-blind players): a ring, a double ring, a - * sunburst, and a denser glowing burst for the last rank. + * The outline of a prestige emblem, drawn around the level frame. Every rank + * has its own, so ranks read apart by SHAPE as well as colour (colour-blind + * players): a plain medal, four points, a laurel, an eight-point star, wings, + * a tiara, compass rays, a sun, a crystal, and prismatic rays with big wings. */ -export type PrestigeTier = "none" | "ring" | "double" | "sunburst" | "radiant"; - -export function prestigeTier(prestige: number): PrestigeTier { - const p = clampPrestige(prestige); - if (p === 0) return "none"; - if (p <= 3) return "ring"; - if (p <= 6) return "double"; - if (p <= 9) return "sunburst"; - return "radiant"; +export type PrestigeOutline = + | "plain" + | "points" + | "laurel" + | "star" + | "wings" + | "tiara" + | "compass" + | "sun" + | "crystal" + | "prismatic"; + +/** How one prestige rank is drawn ("Heraldry": a colour and outline each). */ +export interface PrestigeStyle { + rank: number; + // Stable id, for the badge's data-prestige-tier ("bronze" … "prismatic"). + id: string; + outline: PrestigeOutline; + // The emblem's fill, its outline and number tab colour, and its shadow. + base: string; + light: string; + dark: string; + // The inner level frame's fill once prestiged: base darkened toward dark. + frame: string; + // The rank's colour on other surfaces: the ceremony's glow, a card's halo. + accent: string; + // P5 and P10 are milestone ranks: a star on top, wings, and a glow. + milestone: boolean; + // CSS colour of the emblem's glow, or null for none. + glow: string | null; + // Wings reach past the badge's box; where it sits next to text it gets + // side margin so they never touch a name. + winged: boolean; +} + +const PRESTIGE_STYLES: readonly PrestigeStyle[] = [ + { + rank: 1, + id: "bronze", + outline: "plain", + base: "#a8642f", + light: "#f2b77f", + dark: "#3d200b", + frame: "#7b4720", + accent: "#e2924f", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 2, + id: "silver", + outline: "points", + base: "#8792a5", + light: "#f1f5f9", + dark: "#232a35", + frame: "#5d6676", + accent: "#e2e8f0", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 3, + id: "jade", + outline: "laurel", + base: "#0f9d6a", + light: "#86efc4", + dark: "#053b27", + frame: "#0b744e", + accent: "#34d399", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 4, + id: "sapphire", + outline: "star", + base: "#2457d6", + light: "#a8c6ff", + dark: "#0a1a4a", + frame: "#193d9b", + accent: "#60a5fa", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 5, + id: "amethyst", + outline: "wings", + base: "#8b3ee0", + light: "#e4c6ff", + dark: "#2a0b52", + frame: "#6229a4", + accent: "#c084fc", + milestone: true, + glow: "rgba(196,140,255,0.85)", + winged: true, + }, + { + rank: 6, + id: "crimson", + outline: "tiara", + base: "#cc2236", + light: "#ffa3ae", + dark: "#4a0812", + frame: "#951727", + accent: "#f43f5e", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 7, + id: "ember", + outline: "compass", + base: "#ec6a10", + light: "#ffd19e", + dark: "#561f02", + frame: "#ad4b0a", + accent: "#fb923c", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 8, + id: "gold", + outline: "sun", + base: "#e3ad06", + light: "#fff2a8", + dark: "#4f3700", + frame: "#a57b03", + accent: "#facc15", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 9, + id: "diamond", + outline: "crystal", + base: "#3fbfe0", + light: "#effdff", + dark: "#08414f", + frame: "#288aa3", + accent: "#67e8f9", + milestone: false, + glow: null, + winged: false, + }, + { + rank: 10, + id: "prismatic", + outline: "prismatic", + base: "#ec4899", + light: "#ffd1ec", + dark: "#3d0a2a", + frame: "#a32e6a", + accent: "#f472b6", + milestone: true, + glow: "rgba(244,114,182,0.9)", + winged: true, + }, +]; + +/** + * How a prestige rank is drawn; null at prestige 0 (no emblem). A rank past + * the last styled one is drawn as the last. + */ +export function prestigeStyle(prestige: number): PrestigeStyle | null { + const p = Math.min(MAX_PRESTIGE, clampPrestige(prestige)); + return p === 0 ? null : PRESTIGE_STYLES[p - 1]; +} + +// The colour of an unprestiged badge's moments (and of Legend). +const UNPRESTIGED_ACCENT = "#facc15"; + +/** A prestige rank's colour, for glows and highlights around its emblem. */ +export function prestigeAccent(prestige: number): string { + return prestigeStyle(prestige)?.accent ?? UNPRESTIGED_ACCENT; } // Levels called out when reached: the end-of-game XP panel gives one reached diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index 043b20a8a9a..4ee7b6681cd 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -6,6 +6,21 @@ import type { import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; +import { getUserMe, invalidateUserMe } from "../Api"; +import { + fetchCosmetics, + resolveCosmetics, + type ResolvedCosmetic, +} from "../Cosmetics"; +import { + type Balances, + claimGameRewards, + type FlareUnlock, + flareUnlock, + type GameRewardsSummary, + type RewardLine, + summarizeGameRewards, +} from "../GameRewards"; import { apportionXp, ineligibleReasonKey, @@ -21,7 +36,12 @@ import { type XpLineKey, } from "../Progression"; import { translateText } from "../Utils"; +import "./baseComponents/Button"; +import "./CapIcon"; +import "./CosmeticPreview"; import "./LevelBadge"; +import "./PlutoniumIcon"; +import type { RewardsChangedDetail } from "./RewardsPanel"; import { formatXp, xpBonusText, xpProgressText } from "./XpBar"; // What the XP section of a finished game is showing. The caller owns the @@ -59,6 +79,20 @@ function wipeStart(i: number, n: number): number { } const MULTIPLIER_HOLD_MS = 400; const PAYOFF_MS = 900; +// The level rewards, after the last level-up of the game: a cosmetic it +// unlocked wipes into the milestone card once that has landed, then the +// rewards row rises in (later when it follows a card), and its amounts +// count up. +const UNLOCK_DELAY_MS = 700; +const REWARDS_DELAY_MS = 300; +const REWARDS_AFTER_CARD_MS = 900; +const REWARDS_COUNT_LEAD_MS = 360; +const REWARDS_COUNT_MS = 500; +// The balance after a claim counts up from the old one. +const BALANCE_LEAD_MS = 120; +const BALANCE_COUNT_MS = 600; + +const easeOut = (f: number) => 1 - Math.pow(1 - f, 3); // The caption step the panel rests on once the reveal is over. const REST_STEP = -1; @@ -108,8 +142,22 @@ interface RevealFrame { lineValues: number[]; // A multiplier being applied to the cards (its wipe): a boost or a cut. applying: "boost" | "cut" | null; + // The level rewards row has risen in, and how far its amounts have + // counted up, 0 to 1. + rewardsShown: boolean; + rewardsCount: number; + // The cosmetics the game unlocked have wiped in. + unlockShown: boolean; } +// The rewards row's Claim button. +type ClaimState = + | { status: "idle"; failed: boolean } + | { status: "claiming" } + // `to` is the balance after the claim (null if unknown), `from` the one + // before it, which the shown balance counts up from. + | { status: "claimed"; from: Balances | null; to: Balances | null }; + // Bar position of a level/progress pair, in level units. Level 100 has no // next level and reads as a full bar. A full level stays just short of the // next, so the position never reads as a level the player hasn't reached. @@ -152,6 +200,18 @@ export class GameXpPanel extends LitElement { // The result last announced as settled (see updated()). private settledView: GameXpPanelState | null = null; + // Claiming this game's level rewards. Reset with every new view. + @state() private claim: ClaimState = { status: "idle", failed: false }; + @state() private claimPressed = false; + // How far the balance shown after a claim has counted up, 0 to 1. + @state() private balanceCount = 1; + private claimedIds = new Set(); + private claimToken = 0; + private claimTimers: ReturnType[] = []; + // The store's catalog, for the previews of what a game unlocked. + @state() private catalog: readonly ResolvedCosmetic[] = []; + private catalogRequested = false; + createRenderRoot() { return this; } @@ -160,14 +220,19 @@ export class GameXpPanel extends LitElement { // Jump to the end: with its timers gone, a reveal left in place would // stay half-done (and aria-busy) if the panel were added back. this.skipReveal(); + this.clearClaimTimers(); + this.balanceCount = 1; super.disconnectedCallback(); } protected willUpdate(changed: PropertyValues): void { if (changed.has("view")) { + this.resetClaim(); const s = this.view; - if (s.kind === "result" && s.data.eligible && this.onScreen) { - this.startReveal(s.data); + if (s.kind === "result" && s.data.eligible) { + if ((s.data.flares ?? []).length > 0) this.loadCatalog(); + if (this.onScreen) this.startReveal(s.data); + else this.skipReveal(); } else { this.skipReveal(); } @@ -234,6 +299,31 @@ export class GameXpPanel extends LitElement { const positionAfterXp = (xp: number) => total <= 0 ? start : start + ((end - start) * xp) / total; + // The rewards and unlocks come after the game's last level-up. + const reachedLevels = levelsReachedInGame(data); + const lastLevel = + reachedLevels.length > 0 + ? reachedLevels[reachedLevels.length - 1].level + : null; + const rewards = summarizeGameRewards(data.rewards ?? []); + const unlocks = (data.flares ?? []).length > 0; + const card = + reachedLegendThisGame(data) || this.milestoneOf(data) !== undefined; + const scheduleRewards = (t0: number) => { + if (unlocks) at(t0 + UNLOCK_DELAY_MS, () => patch({ unlockShown: true })); + if (rewards === null) return; + const rowAt = + t0 + (unlocks || card ? REWARDS_AFTER_CARD_MS : REWARDS_DELAY_MS); + at(rowAt, () => patch({ rewardsShown: true })); + const countAt = rowAt + REWARDS_COUNT_LEAD_MS; + const steps = Math.max(2, Math.round(REWARDS_COUNT_MS / 40)); + for (let i = 1; i <= steps; i++) { + at(countAt + (REWARDS_COUNT_MS * i) / steps, () => + patch({ rewardsCount: easeOut(i / steps) }), + ); + } + }; + this.barAnimate = false; this.reveal = { step: 0, @@ -251,6 +341,9 @@ export class GameXpPanel extends LitElement { multipliersShown: 0, lineValues: [...faces], applying: null, + rewardsShown: false, + rewardsCount: 0, + unlockShown: false, }; // Schedules the bar's climb from where it is to `to` starting at `t`, @@ -331,6 +424,7 @@ export class GameXpPanel extends LitElement { legend, }); }); + if (reached === lastLevel) scheduleRewards(t); t += legend ? LEGEND_MS : LEVEL_UP_MS; // Empty the bar and carry on with the rest of this step. const resume = stepCount + 1; @@ -471,6 +565,21 @@ export class GameXpPanel extends LitElement { }); } + // Tells the host once a result is showing in full: its reveal has ended or + // been skipped, or there was none. Once per result (`detail` is the view). + private announceSettled(): void { + const view = this.view; + if (view.kind !== "result" || this.reveal !== null) return; + if (this.settledView === view) return; + this.settledView = view; + this.dispatchEvent( + new CustomEvent("xp-reveal-settled", { + detail: view, + bubbles: true, + }), + ); + } + // Tells the page this result made the player a Legend, once per result: // `xp-legend` (detail: the result), cancelable. A listener that takes the // moment (plays the Legend ceremony) cancels it. Only ever for a result the @@ -505,21 +614,6 @@ export class GameXpPanel extends LitElement { } } - // Tells the host once a result is showing in full: its reveal has ended or - // been skipped, or there was none. Once per result (`detail` is the view). - private announceSettled(): void { - const view = this.view; - if (view.kind !== "result" || this.reveal !== null) return; - if (this.settledView === view) return; - this.settledView = view; - this.dispatchEvent( - new CustomEvent("xp-reveal-settled", { - detail: view, - bubbles: true, - }), - ); - } - // Ends any reveal on its final state (a no-op when none is playing). private skipReveal(): void { this.clearTimers(); @@ -609,7 +703,7 @@ export class GameXpPanel extends LitElement { return this.frame( html`
${translateText("progression.calculating")} @@ -731,7 +825,7 @@ export class GameXpPanel extends LitElement { data-xp-bar-fill class="h-full bg-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.7)] ${this .barAnimate - ? "transition-[width] ease-linear" + ? "xp-bar-filling transition-[width] ease-linear" : ""}" style="width: ${fillPercent}%; transition-duration: ${this .barDurationMs}ms" @@ -925,6 +1019,54 @@ export class GameXpPanel extends LitElement { .xp-bar-flash { animation: xp-bar-flash 450ms ease-in-out 2; } + /* While the bar fills, its leading edge glows white-hot and a sheen + runs along the fill. Both are pseudo-elements clipped to the fill; + the sheen moves by transform, one tile per loop. */ + [data-xp-panel] [data-xp-bar-fill] { + position: relative; + overflow: hidden; + } + [data-xp-panel] [data-xp-bar-fill]::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 42px; + pointer-events: none; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0), + rgba(255, 250, 210, 0.95) + ); + opacity: 0; + transition: opacity 160ms ease-out; + } + [data-xp-panel] .xp-bar-filling::after { + opacity: 1; + } + [data-xp-panel] .xp-bar-filling::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -48px; + right: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + 115deg, + rgba(255, 255, 255, 0) 0 10px, + rgba(255, 255, 255, 0.28) 10px 14px, + rgba(255, 255, 255, 0) 14px 24px + ); + background-size: 48px 100%; + animation: xp-bar-sheen 420ms linear infinite; + } + @keyframes xp-bar-sheen { + to { + transform: translateX(48px); + } + } @keyframes xp-badge-pop { 0% { transform: scale(1); @@ -1173,6 +1315,55 @@ export class GameXpPanel extends LitElement { .xp-card-in.xp-after-slide.xp-shine::after { animation-delay: 410ms; } + /* A cosmetic unlocked: the divider draws, the half wipes in from it, + its preview pops. The rewards row's claimed state rises in. */ + @keyframes xp-unlock-sep { + from { + transform: scaleY(0); + } + } + .xp-unlock-sep { + animation: xp-unlock-sep 220ms ease-out both; + } + @keyframes xp-unlock-in { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0 0 0); + } + } + .xp-unlock-in { + animation: xp-unlock-in 480ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms both; + } + @keyframes xp-unlock-pop { + from { + transform: scale(0.4); + opacity: 0; + } + } + .xp-unlock-pop { + animation: xp-unlock-pop 420ms cubic-bezier(0.2, 0.9, 0.3, 1.35) 300ms + both; + } + @keyframes xp-claimed-in { + from { + opacity: 0; + transform: translateY(6px); + } + } + .xp-claimed-in { + animation: xp-claimed-in 280ms ease-out both; + } + @keyframes xp-check-in { + from { + transform: scale(0.3); + opacity: 0; + } + } + .xp-check-in { + animation: xp-check-in 380ms cubic-bezier(0.2, 0.9, 0.3, 1.5) both; + } .xp-legend-text { background: linear-gradient(90deg, #fde047, #f0abfc, #fde047); background-size: 200% 100%; @@ -1182,6 +1373,10 @@ export class GameXpPanel extends LitElement { filter: drop-shadow(0 0 10px rgba(250, 204, 21, 0.8)); } @media (prefers-reduced-motion: reduce) { + [data-xp-panel] .xp-bar-filling::before, + [data-xp-panel] .xp-bar-filling::after { + display: none; + } .xp-caption-in, .xp-levelup-in, .xp-bar-flash, @@ -1194,7 +1389,12 @@ export class GameXpPanel extends LitElement { .xp-card-wipe::before, .xp-card-in, .xp-card-in.xp-shine::after, - .xp-card-in .xp-shine::after { + .xp-card-in .xp-shine::after, + .xp-unlock-sep, + .xp-unlock-in, + .xp-unlock-pop, + .xp-claimed-in, + .xp-check-in { animation: none; } } @@ -1209,7 +1409,8 @@ export class GameXpPanel extends LitElement { private renderRecap(data: GameXpEligible): TemplateResult { const r = this.reveal; return html` - ${this.renderLevelUp(data)} ${this.renderBreakdown(data)} + ${this.renderLevelUp(data)} ${this.renderRewards(data)} + ${this.renderBreakdown(data)} ${data.breakdown.leftEarly ? this.collapsible( r === null, @@ -1258,7 +1459,7 @@ export class GameXpPanel extends LitElement { html`
@@ -1280,6 +1481,7 @@ export class GameXpPanel extends LitElement { })}
+ ${this.renderUnlocks(data, true)} `, ); } @@ -1288,12 +1490,17 @@ export class GameXpPanel extends LitElement { // card. An ordinary level-up gets no card: the caption and the badge at the // end of the bar say it. During the reveal its space slides open when the // bar reaches the milestone, and the card pops in. - private renderLevelUp(data: GameXpEligible): TemplateResult | typeof nothing { - if (reachedLegendThisGame(data)) return this.renderLegendMoment(data); - const milestone = [...levelsReachedInGame(data)] + // The highest milestone level reached this game, if any. + private milestoneOf(data: GameXpEligible) { + return [...levelsReachedInGame(data)] .reverse() .find((lvl) => isMilestoneLevel(lvl.level)); - if (milestone === undefined) return nothing; + } + + private renderLevelUp(data: GameXpEligible): TemplateResult | typeof nothing { + if (reachedLegendThisGame(data)) return this.renderLegendMoment(data); + const milestone = this.milestoneOf(data); + if (milestone === undefined) return this.renderUnlockCard(data); const r = this.reveal; const shown = r === null || r.level >= milestone.level; return this.collapsible( @@ -1302,7 +1509,7 @@ export class GameXpPanel extends LitElement { data-xp-levelup data-xp-milestone=${milestone.level} data-xp-slot-hidden=${shown ? nothing : "true"} - class="xp-shine mt-3 flex items-center justify-center gap-3 rounded-xl border border-yellow-400/30 bg-gradient-to-r from-transparent via-yellow-400/15 to-transparent px-3 py-2 ${this.slideSlotClass( + class="xp-shine mt-3 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-xl border border-yellow-400/30 bg-gradient-to-r from-transparent via-yellow-400/15 to-transparent px-3 py-2 ${this.slideSlotClass( shown, )}" > @@ -1329,10 +1536,375 @@ export class GameXpPanel extends LitElement { ${translateText("progression.level", { level: milestone.level })} + ${this.renderUnlocks(data, true)} + `, + ); + } + + // What the game unlocked, resolved against the store's catalog. + private unlocks(data: GameXpEligible): FlareUnlock[] { + return (data.flares ?? []).map((f) => flareUnlock(f, this.catalog)); + } + + private loadCatalog(): void { + if (this.catalogRequested) return; + this.catalogRequested = true; + void fetchCosmetics() + .then((cosmetics) => { + this.catalog = resolveCosmetics(cosmetics, false, null); + }) + .catch(() => { + // Named from the flare alone, without a preview. + }); + } + + // The "Unlocked" half of a milestone (or Legend) card, one per cosmetic + // the game unlocked: after the card has landed, a divider draws and the + // half wipes in from it, its preview popping in. Its space is kept from + // the start, so the card never changes size. On a phone it wraps under + // the milestone. + private renderUnlocks( + data: GameXpEligible, + divider: boolean, + ): TemplateResult | typeof nothing { + const unlocks = this.unlocks(data); + if (unlocks.length === 0) return nothing; + const r = this.reveal; + const shown = r === null || r.unlockShown; + const animate = r !== null && shown; + const hidden = shown ? "" : "invisible"; + return html`${unlocks.map( + (u) => + html`${divider + ? html`` + : nothing} +
+ + ${u.resolved !== null + ? html`` + : html``} + +
+
+ ${translateText("progression.unlocked")} +
+
+ ${u.name} +
+
+ ${translateText("progression.unlock_in_locker", { + type: u.typeLabel, + })} +
+
+
`, + )}`; + } + + // A cosmetic unlocked at a level with no milestone card of its own: the + // "Unlocked" half on a card by itself, its space sliding open. + private renderUnlockCard( + data: GameXpEligible, + ): TemplateResult | typeof nothing { + if ((data.flares ?? []).length === 0) return nothing; + const r = this.reveal; + const shown = r === null || r.unlockShown; + return this.collapsible( + shown, + html`
+ ${this.renderUnlocks(data, false)} +
`, + ); + } + + // --------------------------------------------------------------------------- + // Level rewards: the row under the level-up, and claiming it. + // --------------------------------------------------------------------------- + + // The rewards row, once the reveal has reached the game's last level-up: + // its space slides open and it rises in like a source card, its amounts + // counting up. Only for the server's result (never a provisional figure), + // and not in the past-game summary. + private renderRewards(data: GameXpEligible): TemplateResult | typeof nothing { + const summary = summarizeGameRewards(data.rewards ?? []); + if (summary === null) return nothing; + const r = this.reveal; + const shown = r === null || r.rewardsShown; + const count = r === null ? 1 : r.rewardsCount; + return this.collapsible( + shown, + html`
+
+
+ ${translateText(summary.headingKey, summary.headingParams)} +
+
+ ${summary.lines.map((line) => this.renderRewardLine(line, count))} +
+
+
+ ${this.renderClaim(data, summary)} +
`, ); } + private renderRewardLine(line: RewardLine, count: number): TemplateResult { + const soft = line.currency === "soft"; + const amount = + count >= 1 + ? line.total.toLocaleString() + : Math.round(Number(line.total) * count).toLocaleString(); + return html`
+ ${soft + ? html`` + : html``} + +${amount} + ${translateText(soft ? "cosmetics.soft" : "cosmetics.hard")} + ${line.each !== null + ? html`${translateText("progression.rewards_each", { + count: line.count, + amount: line.each.toLocaleString(), + })}` + : nothing} +
`; + } + + private renderClaim( + data: GameXpEligible, + summary: GameRewardsSummary, + ): TemplateResult { + const claim = this.claim; + if ( + claim.status === "claimed" || + (claim.status === "idle" && summary.allClaimed) + ) { + const to = claim.status === "claimed" ? claim.to : null; + const from = claim.status === "claimed" ? claim.from : null; + return this.renderClaimed(summary, from, to); + } + const claiming = claim.status === "claiming"; + return html`
+ { + e.stopPropagation(); + this.claimPressed = true; + }} + @pointerup=${() => (this.claimPressed = false)} + @pointerleave=${() => (this.claimPressed = false)} + @pointercancel=${() => (this.claimPressed = false)} + @click=${(e: Event) => { + // The rest of the panel skips the reveal; this button doesn't. + e.stopPropagation(); + void this.claimRewards(data); + }} + > + ` + : undefined} + > + + ${claim.status === "idle" && claim.failed + ? html`${translateText("account_modal.claim_failed")}` + : nothing} +
`; + } + + private renderClaimed( + summary: GameRewardsSummary, + from: Balances | null, + to: Balances | null, + ): TemplateResult { + const f = this.balanceCount; + const at = (key: keyof Balances) => + to === null + ? "" + : Math.round( + (from?.[key] ?? to[key]) + (to[key] - (from?.[key] ?? to[key])) * f, + ).toLocaleString(); + const plutonium = summary.lines.some((l) => l.currency === "hard"); + return html`
+ + + ${translateText("progression.rewards_claimed")} + + ${to !== null + ? html`${plutonium + ? translateText("progression.rewards_balance", { + caps: at("soft"), + plutonium: at("hard"), + }) + : translateText("progression.rewards_balance_caps", { + caps: at("soft"), + })}` + : nothing} +
`; + } + + private resetClaim(): void { + this.claimToken++; + this.clearClaimTimers(); + this.claim = { status: "idle", failed: false }; + this.claimPressed = false; + this.balanceCount = 1; + this.claimedIds = new Set(); + } + + private clearClaimTimers(): void { + for (const t of this.claimTimers) clearTimeout(t); + this.claimTimers = []; + } + + // Claims this game's rewards, and only these: never the player's other + // pending rewards. Works mid-reveal too. + private async claimRewards(data: GameXpEligible): Promise { + if (this.claim.status !== "idle") return; + const token = this.claimToken; + this.claim = { status: "claiming" }; + const outcome = await claimGameRewards(data.rewards ?? [], this.claimedIds); + if (token !== this.claimToken) return; + for (const id of outcome.claimedIds) this.claimedIds.add(id); + if (!outcome.ok) { + this.claim = { status: "idle", failed: true }; + return; + } + // The balances and the pending list changed: everything that reads + // /users/@me from here on gets the new ones. + invalidateUserMe(); + const me = await getUserMe(); + if (token !== this.claimToken) return; + const to = + outcome.currency ?? (me === false ? null : (me.player.currency ?? null)); + const from = + to === null || outcome.currency === null + ? to + : { + soft: to.soft - outcome.credited.soft, + hard: to.hard - outcome.credited.hard, + }; + this.claim = { status: "claimed", from, to }; + this.countBalance(); + if (me !== false) { + this.dispatchEvent( + new CustomEvent("rewards-changed", { + detail: { currency: to, rewards: me.player.rewards ?? [] }, + bubbles: true, + composed: true, + }), + ); + } + } + + private countBalance(): void { + this.clearClaimTimers(); + if (prefersReducedMotion()) { + this.balanceCount = 1; + return; + } + this.balanceCount = 0; + const steps = Math.max(2, Math.round(BALANCE_COUNT_MS / 30)); + for (let i = 1; i <= steps; i++) { + this.claimTimers.push( + setTimeout( + () => (this.balanceCount = easeOut(i / steps)), + BALANCE_LEAD_MS + (BALANCE_COUNT_MS * i) / steps, + ), + ); + } + } + // One small card per XP source and multiplier, like the stats page tiles: // a label over a big number. // The slot (entrance) and the card (the multiplier's wipe) are separate diff --git a/src/client/components/LevelBadge.ts b/src/client/components/LevelBadge.ts index 70c34ec16a8..973727dcd68 100644 --- a/src/client/components/LevelBadge.ts +++ b/src/client/components/LevelBadge.ts @@ -4,8 +4,9 @@ import { clampPrestige, levelBand, MAX_LEVEL, - PrestigeTier, - prestigeTier, + prestigeAccent, + prestigeStyle, + PrestigeStyle, } from "../Progression"; import { translateText } from "../Utils"; import { @@ -18,15 +19,22 @@ import { // A player's level as a small emblem: the number inside a frame whose SHAPE // (not just colour, for colour-blind players) changes every ten levels, a -// prestige emblem around it once they have prestiged (itself a different -// shape per rank group, see prestigeTier), and its own look for Legend. +// prestige emblem around it once they have prestiged (a different outline +// and colour for every rank, see prestigeStyle), and its own look for +// Legend. // // Drawn on a 32x32 grid so it scales from the 16px nav badge up to the profile // header without separate art. +// +// Lists draw a badge on every row (150 in a full lobby), so the emblem's +// markup is built once per look — rank, band, size class and digit count — +// and each badge after that is a clone of it plus its number (see +// emblemTemplate). The prismatic gradient lives once per document (or shadow +// root), not once per badge (see ensureSharedDefs). interface BandStyle { - // Frame outline on the 32x32 grid. - shape: SVGTemplateResult; + // Frame outline on the 32x32 grid, as markup. + shape: string; // Vertical centre of the number within the frame. textY: number; // Shrinks the number for frames with less interior room. @@ -56,27 +64,36 @@ function starPoints( return out.join(" "); } +const pointList = (pts: readonly (readonly [number, number])[]): string => + pts.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "); + +// A point `r` from the centre at `deg` degrees (0 = right, -90 = up). +const polar = (deg: number, r: number): [number, number] => [ + 16 + r * Math.cos((deg * Math.PI) / 180), + 16 + r * Math.sin((deg * Math.PI) / 180), +]; + const CROWN = "M2 9 L9 15 L16 4 L23 15 L30 9 L27 29 H5 Z"; // Index = levelBand(level): 1–9, 10–19, … 90–99, then 100. const BANDS: readonly BandStyle[] = [ // 1–9: circle { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 1, colors: "fill-slate-600 stroke-slate-300", }, // 10–19: rounded square { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 1, colors: "fill-emerald-700 stroke-emerald-300", }, // 20–29: diamond { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 0.95, twoDigitWidth: 15, @@ -84,64 +101,56 @@ const BANDS: readonly BandStyle[] = [ }, // 30–39: shield { - shape: svg``, + shape: ``, textY: 15, fontScale: 0.95, colors: "fill-sky-700 stroke-sky-300", }, // 40–49: hexagon { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 0.95, colors: "fill-blue-700 stroke-blue-300", }, // 50–59: octagon { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 1, colors: "fill-indigo-700 stroke-indigo-300", }, // 60–69: banner with a notched tail { - shape: svg``, + shape: ``, textY: 13.5, fontScale: 0.95, colors: "fill-violet-700 stroke-violet-300", }, // 70–79: eight-point star { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 0.85, colors: "fill-fuchsia-700 stroke-fuchsia-300", }, // 80–89: rosette (twelve shallow points) { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 0.95, colors: "fill-rose-700 stroke-rose-300", }, // 90–99: winged { - shape: svg``, + shape: ``, textY: 16.5, fontScale: 0.9, colors: "fill-orange-700 stroke-orange-300", }, // 100: crown { - shape: svg``, + shape: ``, textY: 21, fontScale: 0.95, colors: "fill-amber-600 stroke-yellow-200", @@ -149,7 +158,7 @@ const BANDS: readonly BandStyle[] = [ ]; // The light colour of each band's frame (its Tailwind stroke, written out), -// by levelBand: the profile card's glow behind the badge. +// by levelBand: the profile card's glow behind an unprestiged badge. const BAND_ACCENTS = [ "#cbd5e1", "#6ee7b7", @@ -164,9 +173,26 @@ const BAND_ACCENTS = [ "#fef08a", ] as const; -/** The badge's accent colour, for a glow or a highlight around it. */ -export function levelBadgeAccent(level: number, legend: boolean): string { - return legend ? "#facc15" : BAND_ACCENTS[levelBand(level)]; +const LEGEND_ACCENT = "#facc15"; +const LEGEND_GLOW = "rgba(250,204,21,0.85)"; + +// Side margin, as a share of the badge's size, for a winged emblem (P5, +// P10): the wings reach past the badge's box, and must never touch the name +// beside it. +export const WINGED_MARGIN = 0.12; + +/** + * The badge's accent colour, for a glow or a highlight around it: the + * prestige rank's own colour once prestiged, the level band's before. + */ +export function levelBadgeAccent( + level: number, + legend: boolean, + prestige = 0, +): string { + if (legend) return LEGEND_ACCENT; + if (clampPrestige(prestige) > 0) return prestigeAccent(prestige); + return BAND_ACCENTS[levelBand(level)]; } function displayLevel(level: number): number { @@ -189,6 +215,388 @@ export function levelBadgeLabel( return `${levelText}, ${translateText("progression.prestige", { prestige: p })}`; } +// --------------------------------------------------------------------------- +// Shared definitions: the prismatic medal's gradient, once per document or +// shadow root rather than once per badge. +// --------------------------------------------------------------------------- + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** Id of the prismatic medal's gradient, shared by every badge in a root. */ +export const PRISM_GRADIENT_ID = "level-badge-prism"; + +// The gradient sits in a zero-size, never-hidden at the end of the +// badge's root: a gradient inside a display:none subtree paints nothing in +// some browsers, so it must not live in any one badge (which may be hidden). +// url(#id) resolves within the badge's own tree, so a badge inside a shadow +// root gets its own copy there. +function ensureSharedDefs(host: Element): void { + if (!host.isConnected) return; + const root = host.getRootNode() as Document | ShadowRoot; + if (root.getElementById(PRISM_GRADIENT_ID) !== null) return; + const doc = host.ownerDocument; + const el = (name: string, attrs: Record): Element => { + const node = doc.createElementNS(SVG_NS, name); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); + return node; + }; + const sprite = el("svg", { + "data-level-badge-defs": "", + "aria-hidden": "true", + focusable: "false", + width: "0", + height: "0", + style: + "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none", + }); + const gradient = el("linearGradient", { + id: PRISM_GRADIENT_ID, + x1: "0", + y1: "0", + x2: "1", + y2: "1", + }); + for (const [offset, color] of [ + ["0", "#ff5fa8"], + ["0.5", "#9d5cff"], + ["1", "#38bdf8"], + ]) { + gradient.append(el("stop", { offset, "stop-color": color })); + } + const defs = el("defs", {}); + defs.append(gradient); + sprite.append(defs); + const parent: ParentNode = + root instanceof ShadowRoot ? root : (doc.body ?? doc.documentElement); + parent.append(sprite); +} + +// --------------------------------------------------------------------------- +// Emblem primitives, on the 32x32 grid, as markup. Every value in them comes +// from this file's own constants and arithmetic, never from a caller. +// --------------------------------------------------------------------------- + +function disc(r: number, fill: string, stroke: string, width: number): string { + return ``; +} + +function poly( + points: string, + fill: string, + stroke: string, + width: number, +): string { + return ``; +} + +// One wing (the left), mirrored for the right. `k` scales it out from the +// badge's side. +const WING: readonly (readonly [number, number])[] = [ + [9, 5], + [-4, 1], + [0.5, 7], + [-5, 8.5], + [0, 12.5], + [-4.5, 15.5], + [0.5, 17.5], + [-2, 22], + [6, 21.5], + [8, 16], +]; + +function wings(fill: string, stroke: string, width: number, k: number): string { + const left = WING.map( + ([x, y]) => [8 + (x - 8) * k, 13 + (y - 13) * k] as const, + ); + const right = left.map(([x, y]) => [32 - x, y] as const); + return ( + poly(pointList(left), fill, stroke, width) + + poly(pointList(right), fill, stroke, width) + ); +} + +// Seven leaves up each side, from the foot of the badge to its shoulders. +function laurel(fill: string, stroke: string): string { + const r = 15.3; + let leaves = ""; + for (let i = 0; i < 7; i++) { + const deg = 128 + ((252 - 128) * i) / 6; + const [cx, cy] = polar(deg, r); + const rot = deg + 90 - 28; + for (const [x, turn] of [ + [cx, rot], + [32 - cx, -rot], + ]) { + const ex = x.toFixed(2); + const ey = cy.toFixed(2); + leaves += ``; + } + } + return leaves; +} + +// Points rising from behind the badge's rim at the given angles. +function spikes( + angles: readonly number[], + lengths: readonly number[], + fill: string, + stroke: string, +): string { + return angles + .map((deg, i) => + poly( + pointList([ + polar(deg - 9, 12.8), + polar(deg, lengths[i]), + polar(deg + 9, 12.8), + ]), + fill, + stroke, + 1, + ), + ) + .join(""); +} + +const PRISM = [ + "#ff4f9a", + "#ffa53b", + "#ffe45c", + "#47e0a0", + "#46b4ff", + "#a46bff", +]; + +// Rays in every colour, for the last rank. +function prismRays(n: number, outer: number, inner: number): string { + const half = 180 / n; + return Array.from({ length: n }, (_, i) => { + const deg = -90 + (i * 360) / n; + return poly( + pointList([ + polar(deg - half, inner), + polar(deg, outer), + polar(deg + half, inner), + ]), + PRISM[i % PRISM.length], + "#ffffff", + 0.6, + ); + }).join(""); +} + +// A five-point star on top of the badge: a milestone rank. +function topStar( + fill: string, + stroke: string, + cy: number, + outer: number, + inner: number, +): string { + return poly(starPoints(5, outer, inner, -90, cy), fill, stroke, 0.9); +} + +// The prestige emblem, drawn BEHIND the (shrunken) level frame so it reads +// as a mark of rank around the whole badge rather than a sticker on it. +// Every rank has its own outline in its own colour, so ranks read apart +// even at 16px; the numeral tab is added where there is room for it. +function prestigeEmblem(style: PrestigeStyle): string { + const { base, light, dark } = style; + switch (style.outline) { + case "plain": + return disc(15, base, light, 1.6); + case "points": + return ( + poly(starPoints(4, 19, 9.5, -45), base, light, 1.1) + + disc(14, base, light, 1.4) + ); + case "laurel": + return laurel(base, light) + disc(13.6, base, light, 1.4); + case "star": + return ( + poly(starPoints(8, 18.2, 13.2), base, light, 1) + + disc(13.4, base, light, 1.2) + ); + case "wings": + return ( + wings(base, light, 1.1, 0.9) + + disc(14.4, base, light, 2.2) + + disc(12.5, "none", light, 0.9) + + topStar(light, dark, 2.6, 4.8, 2.1) + ); + case "tiara": + return ( + spikes( + [-150, -120, -90, -60, -30], + [16.8, 18.2, 19.8, 18.2, 16.8], + base, + light, + ) + disc(14, base, light, 1.4) + ); + case "compass": + return ( + poly(starPoints(4, 16.8, 6, -45), base, light, 1) + + poly(starPoints(4, 19.8, 6.5, -90), base, light, 1.1) + + disc(13.4, base, light, 1.2) + ); + case "sun": + return ( + poly(starPoints(16, 18, 14.2), base, light, 0.8) + + disc(13.8, base, light, 1.2) + ); + case "crystal": + return ( + poly(starPoints(6, 19.5, 10.5), base, light, 1.1) + + disc(13.4, base, light, 1.2) + ); + case "prismatic": + // Rays in every colour behind big wings and a prismatic medal. + return ( + prismRays(12, 19.8, 12.5) + + wings(light, "#ffffff", 1, 1.1) + + disc(14.6, `url(#${PRISM_GRADIENT_ID})`, "#ffffff", 2) + + disc(12.4, "none", "#ffffff", 0.8) + + topStar("#ffffff", dark, 2.2, 5.2, 2.3) + ); + } +} + +// The number's , up to its content. At nav size every pixel of glyph +// counts: let the number run a little past the tighter frames, and thin the +// outline that keeps it readable on light fills. +function numberOpen(band: BandStyle, digits: number, small: boolean): string { + const base = digits === 1 ? 19 : digits === 2 ? 16 : small ? 14 : 12; + const fontSize = + base * (small ? Math.max(band.fontScale, 0.95) : band.fontScale); + const textLength = + digits === 3 + ? small + ? 25 + : 21 + : digits === 2 && band.twoDigitWidth !== undefined + ? band.twoDigitWidth + : null; + return ( + `` + ); +} + +// The level frame and number, shrunk inside the prestige emblem when there +// is one. The frame keeps its level band's SHAPE either way (levels still +// progress within a run), but once prestiged it takes the rank's colours. +// Returned in two halves, either side of the number. +function frame( + band: BandStyle, + style: PrestigeStyle | null, + digits: number, + small: boolean, +): [string, string] { + const colours = + style === null + ? `class="${band.colors}"` + : `fill="${style.frame}" stroke="${style.light}"`; + const open = + `${band.shape}` + + numberOpen(band, digits, small); + if (style === null) return [open, ""]; + // A little larger at nav size, where the number needs every pixel. + const scale = small ? 0.78 : 0.72; + return [ + `${open}`, + "", + ]; +} + +// The numeral tab's , up to its content. Only where there is room: +// below 24px the numeral is unreadable, and the emblem's outline and the +// accessible name carry the rank instead. +function tabOpen(style: PrestigeStyle): string { + const wide = style.rank >= 10; + return ( + `` + + `` + ); +} + +// Legend: a black crown with a gold star on a gold sunburst halo, gems on +// its points. +function legendMarkup(): string { + const gems: [number, number, string][] = [ + [2, 9, "#38bdf8"], + [16, 4, "#f43f5e"], + [30, 9, "#38bdf8"], + ]; + return ( + `` + + `` + + `` + + gems + .map( + ([cx, cy, fill]) => + ``, + ) + .join("") + ); +} + +// --------------------------------------------------------------------------- +// One Lit template per look. Lit caches a template by the identity of its +// strings array, so handing it the same array for every badge with the same +// look makes each badge after the first a clone of parsed markup, with only +// the number (and the rank's tab label) filled in. The markup is built only +// from the constants above; the two pieces of text go in as ordinary, +// escaped Lit values. +// --------------------------------------------------------------------------- + +const templates = new Map(); + +function stringsFor(key: string, build: () => string[]): TemplateStringsArray { + let strings = templates.get(key); + if (strings === undefined) { + const parts = build(); + strings = Object.assign(parts, { + raw: [...parts], + }) as unknown as TemplateStringsArray; + templates.set(key, strings); + } + return strings; +} + +function legendTemplate(): SVGTemplateResult { + return svg(stringsFor("legend", () => [legendMarkup()])); +} + +// `prestige` is the rank the tab shows, which can pass the last styled rank. +function emblemTemplate( + level: number, + style: PrestigeStyle | null, + prestige: number, + small: boolean, +): SVGTemplateResult { + const bandIndex = levelBand(level); + const band = BANDS[bandIndex]; + const text = String(displayLevel(level)); + const digits = text.length; + const tab = style !== null && !small; + const key = `${style?.rank ?? 0}:${bandIndex}:${digits}:${small ? "s" : "m"}`; + const strings = stringsFor(key, () => { + const [frameOpen, frameClose] = frame(band, style, digits, small); + const emblem = style === null ? "" : prestigeEmblem(style); + if (!tab) return [emblem + frameOpen, frameClose]; + return [emblem + frameOpen, frameClose + tabOpen(style), ""]; + }); + if (!tab) return svg(strings, text); + return svg( + strings, + text, + translateText("progression.prestige_short", { prestige }), + ); +} + @customElement("level-badge") export class LevelBadge extends LitElement implements StaggeredBadge { @property({ type: Number }) level = 1; @@ -210,6 +618,7 @@ export class LevelBadge extends LitElement implements StaggeredBadge { connectedCallback(): void { super.connectedCallback(); + this.ensureDefs(); if (!this.hasUpdated && this.hasAttribute("stagger")) badgeArrived(); else if (this.waiting) queueBadge(this); } @@ -234,196 +643,47 @@ export class LevelBadge extends LitElement implements StaggeredBadge { this.requestUpdate(); } - /** Accessible name, e.g. "Level 42, Prestige 3". */ - label(): string { - return levelBadgeLabel(this.level, this.prestige, this.legend); - } - - private displayLevel(): number { - return displayLevel(this.level); - } - - private renderNumber(band: BandStyle): SVGTemplateResult { - const text = String(this.displayLevel()); - // At nav size every pixel of glyph counts: let the number run a little - // past the tighter frames, and thin the outline that keeps it readable - // on light fills. - const small = this.size < 24; - const base = - text.length === 1 ? 19 : text.length === 2 ? 16 : small ? 14 : 12; - const fontSize = - base * (small ? Math.max(band.fontScale, 0.95) : band.fontScale); - return svg`${text}`; + protected updated(): void { + this.ensureDefs(); } - // The prestige emblem, drawn BEHIND the (shrunken) level frame so it reads - // as a mark of rank around the whole badge rather than a sticker on it. - // The tier's shape carries the rank group even at 16px; the numeral tab is - // added where there is room for it. - private renderPrestigeEmblem(tier: PrestigeTier): SVGTemplateResult { - switch (tier) { - case "ring": - return svg``; - case "double": - return svg` - - `; - case "sunburst": - return svg``; - case "radiant": - // Eight long rays behind a gold burst: a silhouette no other tier - // has, so the last rank stands apart even at 16px. - return svg` - - `; - case "none": - return svg``; + // Only the last rank's medal uses a gradient. + private ensureDefs(): void { + if (!this.legend && prestigeStyle(this.prestige)?.outline === "prismatic") { + ensureSharedDefs(this); } } - // One to three gems along the top of the emblem: the rank WITHIN its group - // (P1/P2/P3 share a ring, P4/P5/P6 a double ring, …), so ranks read apart - // at a glance and at sizes too small for the numeral tab. P10 is its own - // group and needs none. - private renderRankGems( - prestige: number, - tier: PrestigeTier, - ): SVGTemplateResult | typeof nothing { - if (tier === "none" || tier === "radiant") return nothing; - const count = ((prestige - 1) % 3) + 1; - const angles = - count === 1 ? [-90] : count === 2 ? [-104, -76] : [-118, -90, -62]; - const r = tier === "sunburst" ? 15.2 : 14.6; - return svg`${angles.map((deg) => { - const a = (deg * Math.PI) / 180; - const x = (16 + r * Math.cos(a)).toFixed(2); - const y = 16 + r * Math.sin(a); - const points = [ - `${x},${(y - 2.9).toFixed(2)}`, - `${(Number(x) + 2.3).toFixed(2)},${y.toFixed(2)}`, - `${x},${(y + 2.9).toFixed(2)}`, - `${(Number(x) - 2.3).toFixed(2)},${y.toFixed(2)}`, - ].join(" "); - return svg``; - })}`; - } - - private renderPrestigeTab( - prestige: number, - ): SVGTemplateResult | typeof nothing { - // Below 24px the numeral is unreadable; the emblem's shape still says - // which group and the accessible name carries the exact rank. - if (this.size < 24) return nothing; - return svg` - = 10 ? 8.5 : 10} y="25" width=${prestige >= 10 ? 15 : 12} height="7.5" rx="2" - class="fill-zinc-900 stroke-yellow-300" stroke-width="1"> - = 10 ? 12 : nothing} - lengthAdjust="spacingAndGlyphs" - >${translateText("progression.prestige_short", { prestige })}`; - } - - // The level frame and number, shrunk inside the prestige emblem when there - // is one. - private renderFrame(band: BandStyle, prestiged: boolean): SVGTemplateResult { - const inner = svg` - - ${band.shape} - - ${this.renderNumber(band)}`; - if (!prestiged) return inner; - // A little larger at nav size, where the number needs every pixel. - const scale = this.size < 24 ? 0.78 : 0.72; - return svg` - ${inner} - `; - } - - private renderLegend(): SVGTemplateResult { - return svg` - - - `; + /** Accessible name, e.g. "Level 42, Prestige 3". */ + label(): string { + return levelBadgeLabel(this.level, this.prestige, this.legend); } render() { const px = Math.max(16, this.size); + const style = this.legend ? null : prestigeStyle(this.prestige); + const margin = style?.winged ? Math.round(px * WINGED_MARGIN) : 0; if (this.waiting) { - // The drawn badge's exact box, so nothing moves when it is drawn. + // The drawn badge's exact box (wings' margin included), so nothing + // moves when it is drawn. + const box = `width:${px}px;height:${px}px`; return html` 0 + ? `${box};margin-left:${margin}px;margin-right:${margin}px` + : box} aria-hidden="true" data-badge-waiting >`; } - const band = BANDS[levelBand(this.level)]; const label = this.label(); - const prestige = clampPrestige(this.prestige); - const tier = this.legend ? "none" : prestigeTier(prestige); - const glow = this.legend - ? "drop-shadow-[0_0_3px_rgba(250,204,21,0.6)]" - : tier === "radiant" - ? "drop-shadow-[0_0_3px_rgba(232,121,249,0.7)]" - : ""; + const glow = this.legend ? LEGEND_GLOW : (style?.glow ?? null); + const inline = [ + glow !== null ? `filter: drop-shadow(0 0 3px ${glow})` : "", + margin > 0 ? `margin-left: ${margin}px; margin-right: ${margin}px` : "", + ] + .filter((s) => s !== "") + .join("; "); return html` ${label} ${this.legend - ? this.renderLegend() - : svg` - ${this.renderPrestigeEmblem(tier)} - ${this.renderFrame(band, tier !== "none")} - ${this.renderRankGems(prestige, tier)} - ${tier !== "none" ? this.renderPrestigeTab(prestige) : nothing} - `} + ? legendTemplate() + : emblemTemplate( + this.level, + style, + clampPrestige(this.prestige), + this.size < 24, + )} `; } } diff --git a/src/client/components/NavAccountMenu.ts b/src/client/components/NavAccountMenu.ts index df8e2acf277..5a915698c54 100644 --- a/src/client/components/NavAccountMenu.ts +++ b/src/client/components/NavAccountMenu.ts @@ -1,5 +1,12 @@ import { Progress, UserMeResponse } from "@openfront/shared/ApiSchemas"; -import { html, LitElement, nothing, render, TemplateResult } from "lit"; +import { + html, + LitElement, + nothing, + PropertyValues, + render, + TemplateResult, +} from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { hasLinkedIdentity } from "../AccountIdentity"; @@ -19,6 +26,43 @@ import "./LevelBadge"; import { levelBadgeLabel } from "./LevelBadge"; import { xpBar, xpProgressText } from "./XpBar"; +// The account bar catching up after a game: how long the fill takes to the +// end of the old level and then to the new progress, and the badge's pop as +// the level ticks over. +export const NAV_CATCH_UP_MS = 700; +export const NAV_LEVEL_POP_MS = 560; + +export type NavCatchUp = "snap" | "grow" | "level_up"; + +/** + * How the trigger moves from the progress it shows to a refreshed one: it + * animates only when the new one is higher (more lifetime XP) and someone + * can see it happen: this menu on screen, the page visible, motion allowed. + * Within a level the bar grows; across one it fills, the level ticks over + * and it grows again. Everything else (the first progress, a lower or equal + * one, a prestige) just snaps. + */ +export function navCatchUp( + shown: Progress | null, + next: Progress | null, + env: { onScreen: boolean; pageVisible: boolean; reducedMotion: boolean }, +): NavCatchUp { + if (shown === null || next === null) return "snap"; + if (!(next.lifetimeXp > shown.lifetimeXp)) return "snap"; + if (!env.onScreen || !env.pageVisible || env.reducedMotion) return "snap"; + return next.level === shown.level && next.prestige === shown.prestige + ? "grow" + : "level_up"; +} + +function prefersReducedMotion(): boolean { + try { + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + type MenuItem = { key: string; labelKey: string; @@ -52,6 +96,19 @@ export class NavAccountMenu extends LitElement { // CrazyGames identities come from the SDK, not our user object. @state() private crazyGamesUser: CrazyGamesUser | null = null; + // The progress the trigger shows. It trails a refreshed /users/@me while + // the bar catches up to it (see navCatchUp). + @state() private shownProgress: Progress | null = null; + // While catching up: the fill's width in place of the progress's (100 to + // finish the old level, 0 to start the new one), and its transition. + @state() private barOverride: number | null = null; + @state() private barMs = 0; + @state() private levelPop = false; + private catchUpToken = 0; + private catchUpTimers: ReturnType[] = []; + // What the running step waits for: the fill's transition to end. + private onBarSettled: (() => void) | null = null; + // The panel is rendered into document.body, not into the nav: the nav bar // owns a stacking context, so anything inside it can be painted under the // page's overlays (modals) or paint over ones that should win (toasts). @@ -98,9 +155,107 @@ export class NavAccountMenu extends LitElement { window.removeEventListener("showPage", this.closeMenu); window.removeEventListener("resize", this.closeMenu); this.removePanel(); + this.stopCatchUp(); super.disconnectedCallback(); } + protected willUpdate(changed: PropertyValues): void { + if (!changed.has("userMeResponse") && !changed.has("crazyGamesUser")) { + return; + } + const next = this.progress(); + const shown = this.shownProgress; + if (next === shown) return; + // A catch-up still running is overtaken: carry on from what it shows. + this.stopCatchUp(); + const mode = navCatchUp(shown, next, { + onScreen: this.isOnScreen(), + pageVisible: document.visibilityState === "visible", + reducedMotion: prefersReducedMotion(), + }); + if (mode === "snap" || next === null) { + this.shownProgress = next; + return; + } + void this.catchUp(mode, next); + } + + /** Whether this instance is laid out (the other trigger is hidden). */ + protected isOnScreen(): boolean { + if (typeof this.checkVisibility === "function") { + return this.checkVisibility(); + } + return this.getClientRects().length > 0; + } + + // The bar catching up to new progress, as the player watches: within a + // level it grows to the new width; across one it fills to the end, the + // level ticks over with a pop, it empties without a transition and grows + // to the new width. + private async catchUp(mode: NavCatchUp, next: Progress): Promise { + const token = ++this.catchUpToken; + if (mode === "level_up") { + this.barMs = NAV_CATCH_UP_MS; + this.barOverride = 100; + await this.barSettled(token); + if (token !== this.catchUpToken) return; + this.shownProgress = next; + this.levelPop = true; + this.later(NAV_LEVEL_POP_MS, () => (this.levelPop = false)); + this.barMs = 0; + this.barOverride = 0; + await this.updateComplete; + if (token !== this.catchUpToken) return; + // Lay the empty bar out, so the grow below starts from it. + this.querySelector( + "[data-account-xp-bar] [data-xp-bar-fill]", + )?.getBoundingClientRect(); + } + this.barMs = NAV_CATCH_UP_MS; + this.barOverride = null; + this.shownProgress = next; + await this.barSettled(token); + if (token !== this.catchUpToken) return; + this.barMs = 0; + } + + // Resolves when the fill's transition ends; or a little after it should + // have, if it never reports (an unchanged width, a hidden bar). + private barSettled(token: number): Promise { + return new Promise((resolve) => { + let done = false; + const settle = () => { + if (done) return; + done = true; + if (this.onBarSettled === settle) this.onBarSettled = null; + resolve(); + }; + this.onBarSettled = settle; + this.later(NAV_CATCH_UP_MS + 150, () => { + if (token === this.catchUpToken) settle(); + }); + }); + } + + private handleBarTransitionEnd = (e: TransitionEvent) => { + if (e.propertyName !== "width") return; + this.onBarSettled?.(); + }; + + private later(ms: number, fn: () => void): void { + this.catchUpTimers.push(setTimeout(fn, ms)); + } + + private stopCatchUp(): void { + this.catchUpToken++; + for (const t of this.catchUpTimers) clearTimeout(t); + this.catchUpTimers = []; + this.onBarSettled = null; + this.barOverride = null; + this.barMs = 0; + this.levelPop = false; + } + protected updated(): void { if (!this.menuOpen) { this.removePanel(); @@ -406,7 +561,7 @@ export class NavAccountMenu extends LitElement { private renderLevel( variant: "desktop" | "mobile", ): TemplateResult | typeof nothing { - const progress = this.progress(); + const progress = this.shownProgress; if (progress === null) return nothing; const label = levelBadgeLabel( progress.level, @@ -414,12 +569,39 @@ export class NavAccountMenu extends LitElement { progress.legend, ); const title = `${label} · ${xpProgressText(progress.xpInLevel, progress.xpForNext)}`; - const percent = levelFraction(progress.xpInLevel, progress.xpForNext) * 100; const mobile = variant === "mobile"; return html` + ${this.levelPop + ? html`` + : nothing} + `; + } + + // The thin XP bar under the trigger's level badge and avatar. Desktop: in + // the row's bottom padding, so the avatar never overhangs it. Mobile: just + // under the avatar. + private renderXpBar( + variant: "desktop" | "mobile", + ): TemplateResult | typeof nothing { + const progress = this.shownProgress; + if (progress === null) return nothing; + const percent = + this.barOverride ?? + levelFraction(progress.xpInLevel, progress.xpForNext) * 100; + return html` `; } @@ -521,12 +724,25 @@ export class NavAccountMenu extends LitElement { aria-label=${this.triggerLabel()} data-i18n-title="main.account" > - ${this.renderLevel("desktop")} - ${this.renderIdentityIcons({ - ids: true, - iconClass: "w-5 h-5", - badgeClass: "absolute bottom-1 right-1", - })} + + + ${this.renderLevel("desktop")} + ${this.renderIdentityIcons({ + ids: true, + iconClass: "w-5 h-5", + badgeClass: "absolute -top-1 -right-1.5", + })} + ${this.renderXpBar("desktop")} +
`; } diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 57feb567ca7..d190fc314ba 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -13,7 +13,7 @@ import { translateText, TUTORIAL_VIDEO_URL, } from "../../../client/Utils"; -import { getUserMe } from "../../Api"; +import { getUserMe, invalidateUserMe } from "../../Api"; import "../../components/CosmeticCard"; import { cosmeticSelectionLabel } from "../../components/CosmeticPresentation"; import type { GameXpPanelState } from "../../components/GameXpPanel"; @@ -613,6 +613,11 @@ export class WinModal extends LitElement implements Controller { // (or, worse, a zero). this.xpView = result === null ? { kind: "hidden" } : { kind: "result", data: result }; + // A game that unlocked cosmetics changed what the player owns: the + // Locker (and everything else reading /users/@me) refetches. + if (result?.eligible && (result.flares ?? []).length > 0) { + invalidateUserMe(); + } } catch (err) { console.warn("WinModal: XP section failed", err); this.xpView = { kind: "hidden" }; diff --git a/tests/client/GameXpPanelReveal.test.ts b/tests/client/GameXpPanelReveal.test.ts new file mode 100644 index 00000000000..cf1d3c3fed6 --- /dev/null +++ b/tests/client/GameXpPanelReveal.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, +})); + +import type { GameXpEligible } from "@openfront/shared/ApiSchemas"; +import "../../src/client/components/GameXpPanel"; +import type { GameXpPanel } from "../../src/client/components/GameXpPanel"; + +const data: GameXpEligible = { + gameId: "g1", + eligible: true, + breakdown: { + leftEarly: false, + played: 50, + time: 20, + placement: 0, + win: 100, + firstGame: 0, + feats: 0, + subtotal: 170, + gamePermille: 1000, + subscriberPermille: 1000, + total: 170, + }, + before: { prestige: 0, level: 4, xpInLevel: 100, xpForNext: 400 }, + after: { + prestige: 0, + level: 4, + xpInLevel: 270, + xpForNext: 400, + lifetimeXp: 2000, + legend: false, + canPrestige: false, + }, + levelsReached: [], +}; + +describe("game-xp-panel reveal polish", () => { + let panel: GameXpPanel; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + panel?.remove(); + vi.useRealTimers(); + }); + + async function mount(view: GameXpPanel["view"]): Promise { + panel = document.createElement("game-xp-panel") as GameXpPanel; + panel.view = view; + document.body.appendChild(panel); + await panel.updateComplete; + } + + async function step(ms: number): Promise { + await vi.advanceTimersByTimeAsync(ms); + await panel.updateComplete; + } + + const fill = () => panel.querySelector("[data-xp-bar-fill]")!; + + it("lights the bar's leading edge only while it fills", async () => { + await mount({ kind: "result", data }); + expect(fill().classList.contains("xp-bar-filling")).toBe(false); + // Into the first source's climb. + let filling = false; + for (let i = 0; i < 40 && !filling; i++) { + await step(25); + filling = fill().classList.contains("xp-bar-filling"); + } + expect(filling).toBe(true); + // The fill and its edge move together: the class rides with the width + // transition. + expect(fill().classList.contains("transition-[width]")).toBe(true); + // Once the reveal is over, the bar rests without it. + await step(20_000); + expect(panel.querySelector("[data-xp-revealing]")).toBeNull(); + expect(fill().classList.contains("xp-bar-filling")).toBe(false); + }); + + it("draws the edge and sheen as pseudo-elements, off under reduced motion", async () => { + await mount({ kind: "result", data }); + const css = panel.querySelector("style")!.textContent!; + expect(css).toContain(".xp-bar-filling::after"); + expect(css).toContain(".xp-bar-filling::before"); + // The sheen moves by transform, not by background-position. + const sheen = css.slice(css.indexOf("@keyframes xp-bar-sheen")); + expect(sheen.slice(0, sheen.indexOf("}") + 1)).toContain("transform"); + const reduced = css.slice(css.indexOf("prefers-reduced-motion")); + expect(reduced).toContain(".xp-bar-filling::before"); + expect(reduced).toContain(".xp-bar-filling::after"); + }); + + it("holds the calculating spinner still under reduced motion", async () => { + await mount({ kind: "calculating" }); + const spinner = panel.querySelector(".animate-spin")!; + expect(spinner.classList.contains("motion-reduce:animate-none")).toBe(true); + }); +}); diff --git a/tests/client/GameXpRewards.test.ts b/tests/client/GameXpRewards.test.ts new file mode 100644 index 00000000000..911e2319fc1 --- /dev/null +++ b/tests/client/GameXpRewards.test.ts @@ -0,0 +1,570 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { claimReward, getUserMe, invalidateUserMe, fetchCosmetics, catalog } = + vi.hoisted(() => ({ + claimReward: vi.fn(), + getUserMe: vi.fn(), + invalidateUserMe: vi.fn(), + fetchCosmetics: vi.fn(async () => ({})), + catalog: { entries: [] as unknown[] }, + })); + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, +})); + +vi.mock("../../src/client/Api", () => ({ + claimReward, + getUserMe, + invalidateUserMe, +})); + +vi.mock("../../src/client/Cosmetics", async (importOriginal) => ({ + ...(await importOriginal()), + fetchCosmetics, + resolveCosmetics: vi.fn(() => catalog.entries), +})); + +// The real preview draws the cosmetic; here it only has to exist. +vi.mock("../../src/client/components/CosmeticPreview", () => { + class FakeCosmeticPreview extends HTMLElement {} + if (!customElements.get("cosmetic-preview")) { + customElements.define("cosmetic-preview", FakeCosmeticPreview); + } + return {}; +}); + +import type { + GameXpEligible, + GameXpFlare, + GameXpReward, +} from "@openfront/shared/ApiSchemas"; +import "../../src/client/components/GameXpPanel"; +import type { GameXpPanel } from "../../src/client/components/GameXpPanel"; +import "../../src/client/components/PastGameXpCard"; +import { summarizeGameRewards } from "../../src/client/GameRewards"; + +function reward( + level: number, + currencyType: "soft" | "hard", + amount: number, + extra: Partial = {}, +): GameXpReward { + return { + prestige: 3, + level, + id: `rw${level}${currencyType}`, + currencyType, + amount: String(amount), + reason: currencyType === "soft" ? "level_up" : "level_milestone", + note: null, + claimed: false, + ...extra, + }; +} + +const firebird: GameXpFlare = { + kind: "level", + prestige: 3, + level: 50, + flareId: "9", + flareName: "effect:firebird_trail", + cosmetic: { type: "effect", name: "firebird_trail", url: null }, +}; + +// A game from `from` (most of the way through it) to `to`, crossing every +// level in between. +function game( + from: number, + to: number, + extra: Partial = {}, +): GameXpEligible { + const levelsReached = []; + for (let l = from + 1; l <= to; l++) + levelsReached.push({ prestige: 3, level: l }); + return { + gameId: "wndrG4me1", + eligible: true, + breakdown: { + leftEarly: false, + played: 50, + time: 280, + placement: 90, + win: 300, + firstGame: 0, + feats: 0, + subtotal: 720, + gamePermille: 1000, + subscriberPermille: 1000, + total: 720, + }, + before: { prestige: 3, level: from, xpInLevel: 1700, xpForNext: 2000 }, + after: { + prestige: 3, + level: to, + xpInLevel: 120, + xpForNext: 2000, + lifetimeXp: 132_600, + legend: false, + canPrestige: false, + }, + levelsReached, + ...extra, + }; +} + +const single = () => game(46, 47, { rewards: [reward(47, "soft", 100)] }); +const several = () => + game(44, 47, { + rewards: [ + reward(45, "soft", 100), + reward(46, "soft", 100), + reward(47, "soft", 100), + ], + }); +const milestone = (extra: Partial = {}) => + game(49, 50, { + rewards: [reward(50, "soft", 100), reward(50, "hard", 25)], + flares: [firebird], + ...extra, + }); + +const me = (soft: number, hard: number) => ({ + user: { email: "wonder@example.com" }, + player: { + publicId: "wonder01", + currency: { soft, hard }, + rewards: [ + { + id: "daily1", + currencyType: "soft", + amount: "50", + reason: "subscription_daily", + note: null, + }, + ], + }, +}); + +describe("game-xp-panel level rewards", () => { + let panel: GameXpPanel; + + beforeEach(() => { + vi.useFakeTimers(); + claimReward.mockReset(); + getUserMe.mockReset(); + invalidateUserMe.mockReset(); + catalog.entries = []; + }); + + afterEach(() => { + panel?.remove(); + vi.useRealTimers(); + }); + + async function mount( + data: GameXpEligible, + opts: { reveal?: boolean } = {}, + ): Promise { + panel?.remove(); + panel = document.createElement("game-xp-panel") as GameXpPanel; + panel.view = { kind: "result", data }; + document.body.appendChild(panel); + await settle(); + // Most tests look at the final state: skip the reveal, as a player can. + if (!opts.reveal) { + section().click(); + await settle(); + } + } + + async function settle(ms = 0): Promise { + await vi.advanceTimersByTimeAsync(ms); + await panel.updateComplete; + } + + const section = () => panel.querySelector("[data-xp-panel]")!; + const row = () => panel.querySelector("[data-xp-rewards]"); + const text = (el: Element | null) => + el?.textContent?.replace(/\s+/g, " ").trim() ?? ""; + const amounts = () => + [...panel.querySelectorAll("[data-xp-reward-amount]")].map((el) => + text(el), + ); + const claimButton = () => + panel.querySelector("[data-xp-claim] button"); + const revealing = () => + section().getAttribute("data-xp-revealing") === "true"; + + it("shows the row for the server's result only", async () => { + panel = document.createElement("game-xp-panel") as GameXpPanel; + panel.view = { kind: "calculating" }; + document.body.appendChild(panel); + await settle(); + expect(row()).toBeNull(); + // The past-game summary (its own card) has no rewards row either. + const past = document.createElement("past-game-xp-card") as HTMLElement & { + view: unknown; + updateComplete: Promise; + }; + past.view = { kind: "result", data: single() }; + document.body.appendChild(past); + await past.updateComplete; + expect(past.querySelector("[data-past-xp]")).not.toBeNull(); + expect(past.querySelector("[data-xp-rewards]")).toBeNull(); + expect(past.querySelector("[data-xp-claim]")).toBeNull(); + past.remove(); + await mount(single()); + expect(row()).not.toBeNull(); + // A game that paid nothing has no row. + await mount(game(46, 47)); + expect(row()).toBeNull(); + }); + + it("names a single level's reward", async () => { + await mount(single()); + expect(text(panel.querySelector("[data-xp-rewards-heading]"))).toBe( + 'progression.rewards_level_one:{"level":47}', + ); + expect(amounts()).toEqual(["+100"]); + expect(panel.querySelector("[data-xp-reward-each]")).toBeNull(); + expect(text(row())).toContain("cosmetics.soft"); + expect(text(claimButton())).toBe("account_modal.claim"); + }); + + it("sums several levels' rewards, with how they add up", async () => { + await mount(several()); + expect(text(panel.querySelector("[data-xp-rewards-heading]"))).toBe( + 'progression.rewards_levels:{"from":45,"to":47}', + ); + expect(amounts()).toEqual(["+300"]); + expect(text(panel.querySelector("[data-xp-reward-each]"))).toBe( + 'progression.rewards_each:{"count":3,"amount":"100"}', + ); + }); + + it("shows a milestone's Caps and Plutonium", async () => { + await mount(milestone()); + expect(text(panel.querySelector("[data-xp-rewards-heading]"))).toBe( + 'progression.rewards_level_many:{"level":50}', + ); + const lines = [...panel.querySelectorAll("[data-xp-reward]")].map((el) => + el.getAttribute("data-xp-reward"), + ); + expect(lines).toEqual(["soft", "hard"]); + expect(amounts()).toEqual(["+100", "+25"]); + expect(text(row())).toContain("cosmetics.hard"); + }); + + it("sums amounts exactly, past what a number can hold", () => { + const summary = summarizeGameRewards([ + reward(50, "soft", 0, { amount: "9007199254740993" }), + reward(51, "soft", 0, { amount: "9007199254740993", id: "x" }), + ])!; + expect(summary.lines[0].total).toBe(18014398509481986n); + expect(summary.lines[0].each).toBe(9007199254740993n); + }); + + it("rises in after the last level-up, its amounts counting up", async () => { + await mount(several(), { reveal: true }); + expect(revealing()).toBe(true); + // Laid out but closed until the reveal gets there. + expect( + panel + .querySelector("[data-xp-rewards]")! + .closest("[data-xp-collapsible]")! + .getAttribute("data-xp-collapsible"), + ).toBe("closed"); + const seen = new Set(); + let sawLevel47 = false; + let openedAfter47 = false; + for (let i = 0; i < 600 && revealing(); i++) { + await settle(20); + const moment = panel + .querySelector("[data-xp-caption-levelup]") + ?.getAttribute("data-xp-caption-levelup"); + if (moment === "47") sawLevel47 = true; + const open = + row()! + .closest("[data-xp-collapsible]")! + .getAttribute("data-xp-collapsible") === "open"; + if (open) { + if (!sawLevel47) throw new Error("rewards row opened before level 47"); + openedAfter47 = true; + seen.add(amounts()[0]); + } + } + expect(openedAfter47).toBe(true); + // It counted up: from zero, through values on the way, to the total. + expect(seen.has("+0")).toBe(true); + expect([...seen].some((a) => a !== "+0" && a !== "+300")).toBe(true); + expect(amounts()).toEqual(["+300"]); + }); + + it("adds what the game unlocked to the milestone card", async () => { + await mount(milestone()); + const card = panel.querySelector("[data-xp-levelup]")!; + const unlock = card.querySelector("[data-xp-unlock]")!; + expect(unlock.getAttribute("data-xp-unlock")).toBe("effect:firebird_trail"); + // Named from the flare while the catalog doesn't list it. + expect(text(unlock.querySelector("[data-xp-unlock-name]"))).toBe( + "Firebird Trail", + ); + expect(text(unlock)).toContain("progression.unlocked"); + expect(text(unlock)).toContain("progression.unlock_in_locker"); + // Wraps under the milestone on a phone, beside it from sm up. + expect(unlock.classList.contains("basis-full")).toBe(true); + expect(unlock.classList.contains("sm:basis-auto")).toBe(true); + }); + + it("shows the unlock's preview when the catalog lists it", async () => { + catalog.entries = [ + { + type: "effect", + cosmetic: { name: "firebird_trail" }, + colorPalette: null, + relationship: "owned", + key: "effect:transportShipTrail:firebird_trail", + effectType: "transportShipTrail", + }, + ]; + await mount(milestone()); + await settle(); + const unlock = panel.querySelector("[data-xp-unlock]")!; + expect(unlock.querySelector("cosmetic-preview")).not.toBeNull(); + expect(text(unlock)).toContain( + 'progression.unlock_in_locker:{"type":"cosmetics.type_effect:{\\"type\\":\\"effects.type.transportShipTrail\\"}"}', + ); + }); + + it("wipes the unlock in once the milestone card has landed", async () => { + await mount(milestone(), { reveal: true }); + const unlock = () => panel.querySelector("[data-xp-unlock]")!; + expect(unlock().getAttribute("data-xp-slot-hidden")).toBe("true"); + let landed = false; + for (let i = 0; i < 400 && revealing(); i++) { + await settle(20); + if (unlock().getAttribute("data-xp-slot-hidden") === null) { + landed = true; + expect(unlock().classList.contains("xp-unlock-in")).toBe(true); + // By now the milestone card is on screen. + expect( + panel + .querySelector("[data-xp-levelup]")! + .getAttribute("data-xp-slot-hidden"), + ).toBeNull(); + break; + } + } + expect(landed).toBe(true); + }); + + it("gives an unlock with no milestone a card of its own", async () => { + await mount(game(46, 47, { flares: [{ ...firebird, level: 47 }] })); + expect(panel.querySelector("[data-xp-levelup]")).toBeNull(); + const card = panel.querySelector("[data-xp-unlock-card]")!; + expect(card.querySelector("[data-xp-unlock]")).not.toBeNull(); + }); + + it("claims only this game's rewards, then shows the new balance", async () => { + getUserMe.mockResolvedValue(me(18_550, 125)); + const changed = vi.fn(); + await mount(milestone()); + panel.addEventListener("rewards-changed", (e) => + changed((e as CustomEvent).detail), + ); + + let resolveSecond!: () => void; + claimReward.mockImplementationOnce(() => + Promise.resolve({ currency: { soft: 18_550, hard: 100 } }), + ); + claimReward.mockImplementationOnce( + () => + new Promise((r) => { + resolveSecond = () => r({ currency: { soft: 18_550, hard: 125 } }); + }), + ); + claimButton()!.click(); + await settle(); + // Claiming: disabled, with a spinner. + expect(claimButton()!.disabled).toBe(true); + expect(panel.querySelector("[data-xp-claim-spinner]")).not.toBeNull(); + expect(text(claimButton())).toBe("progression.rewards_claiming"); + resolveSecond(); + await settle(); + + expect(claimReward.mock.calls.map((c) => c[0])).toEqual([ + "rw50soft", + "rw50hard", + ]); + expect(invalidateUserMe).toHaveBeenCalled(); + const claimed = panel.querySelector("[data-xp-claimed]")!; + expect(text(claimed)).toContain("progression.rewards_claimed"); + expect(claimButton()).toBeNull(); + // The balance counts up from the old total to the new one. + expect(text(panel.querySelector("[data-xp-balance]"))).toBe( + `progression.rewards_balance:${JSON.stringify({ caps: (18_450).toLocaleString(), plutonium: (100).toLocaleString() })}`, + ); + await settle(1_000); + expect(text(panel.querySelector("[data-xp-balance]"))).toBe( + `progression.rewards_balance:${JSON.stringify({ caps: (18_550).toLocaleString(), plutonium: (125).toLocaleString() })}`, + ); + // The rest of the page hears about it, as from the rewards panel. + expect(changed).toHaveBeenCalledWith({ + currency: { soft: 18_550, hard: 125 }, + rewards: me(0, 0).player.rewards, + }); + }); + + it("skips rewards already claimed, and shows a Caps-only balance", async () => { + claimReward.mockResolvedValue({ currency: { soft: 900, hard: 3 } }); + getUserMe.mockResolvedValue(me(900, 3)); + await mount( + game(44, 47, { + rewards: [ + reward(45, "soft", 100, { claimed: true }), + reward(46, "soft", 100), + reward(47, "soft", 100), + ], + }), + ); + claimButton()!.click(); + await settle(1_000); + expect(claimReward.mock.calls.map((c) => c[0])).toEqual([ + "rw46soft", + "rw47soft", + ]); + expect(text(panel.querySelector("[data-xp-balance]"))).toBe( + `progression.rewards_balance_caps:${JSON.stringify({ caps: (900).toLocaleString() })}`, + ); + }); + + it("restores the button with an error when a claim fails, and retries the rest", async () => { + claimReward + .mockResolvedValueOnce({ currency: { soft: 18_550, hard: 100 } }) + .mockResolvedValueOnce(false); + await mount(milestone()); + claimButton()!.click(); + await settle(); + expect(panel.querySelector("[data-xp-claim-error]")).not.toBeNull(); + expect(claimButton()!.disabled).toBe(false); + expect(text(claimButton())).toBe("account_modal.claim"); + expect(invalidateUserMe).not.toHaveBeenCalled(); + + // The retry claims only what didn't go through. + claimReward.mockResolvedValueOnce({ + currency: { soft: 18_550, hard: 125 }, + }); + getUserMe.mockResolvedValue(me(18_550, 125)); + claimButton()!.click(); + await settle(1_000); + expect(claimReward.mock.calls.map((c) => c[0])).toEqual([ + "rw50soft", + "rw50hard", + "rw50hard", + ]); + expect(panel.querySelector("[data-xp-claim-error]")).toBeNull(); + expect(panel.querySelector("[data-xp-claimed]")).not.toBeNull(); + }); + + it("counts a reward claimed elsewhere first as claimed", async () => { + claimReward.mockResolvedValue("not_found"); + getUserMe.mockResolvedValue(me(700, 4)); + await mount(single()); + claimButton()!.click(); + await settle(1_000); + expect(panel.querySelector("[data-xp-claimed]")).not.toBeNull(); + // Nothing was credited by this claim: the balance is just the current one. + expect(text(panel.querySelector("[data-xp-balance]"))).toBe( + `progression.rewards_balance_caps:${JSON.stringify({ caps: (700).toLocaleString() })}`, + ); + }); + + it("shows rewards that were all claimed already as claimed", async () => { + await mount( + milestone({ + rewards: [ + reward(50, "soft", 100, { claimed: true }), + reward(50, "hard", 25, { claimed: true }), + ], + }), + ); + expect(panel.querySelector("[data-xp-claimed]")).not.toBeNull(); + expect(claimButton()).toBeNull(); + // No claim made here, so no balance to show. + expect(panel.querySelector("[data-xp-balance]")).toBeNull(); + expect(claimReward).not.toHaveBeenCalled(); + }); + + it("claims mid-reveal without skipping it; a click elsewhere still skips", async () => { + claimReward.mockResolvedValue({ currency: { soft: 1_000, hard: 0 } }); + getUserMe.mockResolvedValue(me(1_000, 0)); + await mount(single(), { reveal: true }); + for (let i = 0; i < 400 && revealing(); i++) { + await settle(20); + if (row()!.getAttribute("data-xp-slot-hidden") === null) break; + } + expect(revealing()).toBe(true); + claimButton()!.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true }), + ); + await settle(); + expect( + panel.querySelector("[data-xp-claim]")!.classList.contains("scale-95"), + ).toBe(true); + claimButton()!.click(); + await settle(); + expect(claimReward).toHaveBeenCalledWith("rw47soft"); + expect(revealing()).toBe(true); + section().click(); + await settle(); + expect(revealing()).toBe(false); + expect(panel.querySelector("[data-xp-claimed]")).not.toBeNull(); + }); + + it("shows the static final state under reduced motion", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: true })), + ); + try { + claimReward.mockResolvedValue({ currency: { soft: 1_000, hard: 0 } }); + getUserMe.mockResolvedValue(me(1_000, 0)); + panel?.remove(); + panel = document.createElement("game-xp-panel") as GameXpPanel; + panel.view = { kind: "result", data: milestone() }; + document.body.appendChild(panel); + await settle(); + // No reveal: the row, its amounts and the unlock are there at once. + expect(revealing()).toBe(false); + expect(amounts()).toEqual(["+100", "+25"]); + expect(row()!.className).not.toContain("xp-card-in"); + const unlock = panel.querySelector("[data-xp-unlock]")!; + expect(unlock.className).not.toContain("xp-unlock-in"); + // The balance lands on its total without counting. + claimButton()!.click(); + await settle(); + expect(text(panel.querySelector("[data-xp-balance]"))).toBe( + `progression.rewards_balance:${JSON.stringify({ caps: (1_000).toLocaleString(), plutonium: (0).toLocaleString() })}`, + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("starts each new result unclaimed", async () => { + claimReward.mockResolvedValue({ currency: { soft: 1_000, hard: 0 } }); + getUserMe.mockResolvedValue(me(1_000, 0)); + await mount(single()); + claimButton()!.click(); + await settle(1_000); + expect(panel.querySelector("[data-xp-claimed]")).not.toBeNull(); + panel.view = { kind: "result", data: several() }; + await settle(); + section().click(); + await settle(); + expect(panel.querySelector("[data-xp-claimed]")).toBeNull(); + expect(claimButton()).not.toBeNull(); + }); +}); diff --git a/tests/client/LevelBadge.test.ts b/tests/client/LevelBadge.test.ts index 5b828abcc0c..b2be1af4946 100644 --- a/tests/client/LevelBadge.test.ts +++ b/tests/client/LevelBadge.test.ts @@ -12,7 +12,12 @@ import { type XpBreakdown, } from "@openfront/shared/ApiSchemas"; import { render as renderTemplate } from "lit"; -import { LevelBadge } from "../../src/client/components/LevelBadge"; +import { + LevelBadge, + levelBadgeAccent, + PRISM_GRADIENT_ID, + WINGED_MARGIN, +} from "../../src/client/components/LevelBadge"; import { xpBar } from "../../src/client/components/XpBar"; import { apportionXp, @@ -22,6 +27,8 @@ import { levelRewardReasonKey, multiplierAmounts, multiplierPercent, + prestigeAccent, + prestigeStyle, subscriberTierOf, visibleMultipliers, visibleXpLines, @@ -122,45 +129,143 @@ describe("", () => { .querySelector("svg") ?.getAttribute("data-prestige-tier"); - it("draws a prestige emblem whose shape changes by rank group", async () => { + const RANK_IDS = [ + "bronze", + "silver", + "jade", + "sapphire", + "amethyst", + "crimson", + "ember", + "gold", + "diamond", + "prismatic", + ]; + + it("gives every prestige rank its own style", async () => { expect(await tierOf({ level: 12, prestige: 0 })).toBe("none"); - expect(await tierOf({ level: 12, prestige: 1 })).toBe("ring"); - expect(await tierOf({ level: 12, prestige: 3 })).toBe("ring"); - expect(await tierOf({ level: 12, prestige: 4 })).toBe("double"); - expect(await tierOf({ level: 12, prestige: 7 })).toBe("sunburst"); - expect(await tierOf({ level: 12, prestige: 10 })).toBe("radiant"); + for (let p = 1; p <= 10; p++) { + expect(await tierOf({ level: 12, prestige: p })).toBe(RANK_IDS[p - 1]); + } + // Out-of-range ranks clamp to a drawable one. + expect(await tierOf({ level: 12, prestige: 14 })).toBe("prismatic"); - // Shape, not just colour: every tier's markup differs. + // Outline, not just colour: with every colour stripped, each rank's + // markup still differs. const markup = new Set(); - for (const prestige of [1, 4, 7, 10]) { - const badge = await render({ level: 12, prestige, size: 16 }); + for (let p = 1; p <= 10; p++) { + const badge = await render({ level: 12, prestige: p, size: 16 }); markup.add( - markupWithoutComments(badge.querySelector("svg")!).replace( - /class="[^"]*"/g, - "", - ), + markupWithoutComments(badge.querySelector("svg")!) + .replace(/(class|fill|stroke|style|id|data-[a-z-]+)="[^"]*"/g, "") + .replace(/url\(#[^)]*\)/g, ""), + ); + } + expect(markup.size).toBe(10); + }); + + it("colours each rank on its own, and its glow on the milestone ranks", async () => { + const colours = new Set(); + for (let p = 1; p <= 10; p++) { + const style = prestigeStyle(p)!; + expect(style.rank).toBe(p); + colours.add(style.base); + // Milestone ranks (P5, P10) glow and have wings; no other rank does. + expect(style.milestone).toBe(p === 5 || p === 10); + expect(style.glow !== null).toBe(p === 5 || p === 10); + expect(style.winged).toBe(p === 5 || p === 10); + const svg = (await render({ level: 12, prestige: p })).querySelector( + "svg", + )!; + expect((svg.getAttribute("style") ?? "").includes("drop-shadow")).toBe( + p === 5 || p === 10, + ); + } + expect(colours.size).toBe(10); + expect(prestigeStyle(0)).toBeNull(); + expect(prestigeAccent(0)).toBe("#facc15"); + expect(prestigeAccent(5)).toBe("#c084fc"); + }); + + it("draws no rank gems: the outline says the rank", async () => { + for (const prestige of [0, 1, 2, 3, 4, 7, 10]) { + const badge = await render({ level: 12, prestige, size: 16 }); + expect(badge.querySelectorAll("[data-rank-gem]").length).toBe(0); + } + }); + + it("tints the level frame to the rank only once prestiged", async () => { + const frame = async (prestige: number) => + (await render({ level: 42, prestige, size: 40 })).querySelector( + "[data-level-frame]", + )!; + // P0: the level band's own colours. + let g = await frame(0); + expect(g.getAttribute("class")).toContain("fill-blue-700"); + expect(g.getAttribute("fill")).toBeNull(); + // Prestiged: the band's shape in the rank's colours. + for (const p of [1, 4, 10]) { + g = await frame(p); + const style = prestigeStyle(p)!; + expect(g.getAttribute("class")).toBeNull(); + expect(g.getAttribute("fill")).toBe(style.frame); + expect(g.getAttribute("stroke")).toBe(style.light); + expect(g.querySelector("polygon")?.getAttribute("points")).toBe( + "16,1.5 29,9 29,23 16,30.5 3,23 3,9", ); } - expect(markup.size).toBe(4); + // The number stays white with its dark outline. + const text = (await render({ level: 42, prestige: 6 })).querySelector( + "text", + )!; + expect(text.getAttribute("class")).toBe("fill-white"); + expect(text.getAttribute("stroke")).toBe("rgb(0 0 0 / 0.55)"); + }); + + it("tints the numeral tab to the rank", async () => { + const badge = await render({ level: 12, prestige: 6, size: 40 }); + const tab = badge.querySelector("[data-prestige-tab]")!; + expect(tab.getAttribute("stroke")).toBe(prestigeStyle(6)!.light); + const label = [...badge.querySelectorAll("text")].find((t) => + t.textContent?.includes("prestige_short"), + )!; + expect(label.getAttribute("fill")).toBe(prestigeStyle(6)!.light); + }); + + it("gives winged ranks side margin, so the wings never touch a name", async () => { + const svgOf = async (prestige: number, size: number) => + (await render({ level: 12, prestige, size })).querySelector("svg")!; + for (const size of [24, 28, 64]) { + const margin = `${Math.round(size * WINGED_MARGIN)}px`; + for (const p of [5, 10]) { + const svg = await svgOf(p, size); + expect(svg.getAttribute("data-winged")).toBe("true"); + expect(svg.style.marginLeft).toBe(margin); + expect(svg.style.marginRight).toBe(margin); + } + for (const p of [0, 1, 4, 6, 9]) { + const svg = await svgOf(p, size); + expect(svg.getAttribute("data-winged")).toBeNull(); + expect(svg.style.marginLeft).toBe(""); + } + } + expect(WINGED_MARGIN).toBeCloseTo(0.12); }); - it("tells ranks within a group apart with one to three gems", async () => { - const gems = async (prestige: number, size = 16) => - (await render({ level: 12, prestige, size })).querySelectorAll( - "[data-rank-gem]", - ).length; - expect(await gems(0)).toBe(0); - expect([await gems(1), await gems(2), await gems(3)]).toEqual([1, 2, 3]); - expect([await gems(4), await gems(5), await gems(6)]).toEqual([1, 2, 3]); - expect([await gems(7), await gems(8), await gems(9)]).toEqual([1, 2, 3]); - // The last rank is its own group, told apart by its silhouette. - expect(await gems(10)).toBe(0); + it("uses the rank's colour for the badge's accent", () => { + expect(levelBadgeAccent(42, false)).toBe("#93c5fd"); + expect(levelBadgeAccent(42, false, 0)).toBe("#93c5fd"); + expect(levelBadgeAccent(42, false, 3)).toBe("#34d399"); + expect(levelBadgeAccent(42, false, 10)).toBe("#f472b6"); + expect(levelBadgeAccent(100, true, 10)).toBe("#facc15"); }); it("shows a rank past the art's last one by its own number", async () => { const badge = await render({ level: 12, prestige: 12, size: 40 }); const svg = badge.querySelector("svg")!; - expect(svg.getAttribute("data-prestige-tier")).toBe("radiant"); + // Drawn as the last rank, labelled as its own. + expect(prestigeStyle(12)).toBe(prestigeStyle(10)); + expect(svg.getAttribute("data-prestige-tier")).toBe("prismatic"); expect([...svg.querySelectorAll("text")].map((t) => t.textContent)).toEqual( ["12", 'progression.prestige_short:{"prestige":12}'], ); @@ -183,7 +288,151 @@ describe("", () => { ).toEqual(["12"]); }); - it("gives Legend its own look", async () => { + describe("in a long list", () => { + let host: HTMLElement | undefined; + + afterEach(() => { + host?.remove(); + host = undefined; + }); + + async function list( + rows: Partial[], + parent: ParentNode = document.body, + ): Promise { + host = document.createElement("div"); + const badges = rows.map((props) => { + const badge = document.createElement("level-badge") as LevelBadge; + Object.assign(badge, props); + host!.append(badge); + return badge; + }); + parent.append(host); + await Promise.all(badges.map((b) => b.updateComplete)); + return badges; + } + + const lobby = (n: number): Partial[] => + Array.from({ length: n }, (_, i) => ({ + level: 1 + ((i * 37) % 100), + prestige: i % 11, + legend: i === 7, + size: i % 2 === 0 ? 24 : 16, + })); + + it("defines the prismatic gradient once, not once per badge", async () => { + const badges = await list(lobby(150)); + const prismatic = badges.filter((b) => b.prestige === 10 && !b.legend); + expect(prismatic.length).toBeGreaterThan(10); + + expect(document.querySelectorAll("linearGradient")).toHaveLength(1); + expect(document.querySelectorAll(`#${PRISM_GRADIENT_ID}`)).toHaveLength( + 1, + ); + expect(document.querySelectorAll("filter")).toHaveLength(0); + // No badge carries definitions of its own. + for (const badge of badges) { + expect(badge.querySelector("defs, linearGradient, filter")).toBeNull(); + } + // Every prismatic medal points at the shared gradient. + for (const badge of prismatic) { + const fills = [...badge.querySelectorAll("circle")].map((c) => + c.getAttribute("fill"), + ); + expect(fills).toContain(`url(#${PRISM_GRADIENT_ID})`); + } + // The shared gradient never hides: a gradient under display:none + // paints nothing in some browsers. + const defs = document.querySelector("[data-level-badge-defs]")!; + expect(defs.closest("level-badge")).toBeNull(); + expect(defs.getAttribute("style")).not.toContain("display"); + }); + + it("puts the gradient back when the page around it is replaced", async () => { + await list([{ level: 12, prestige: 10 }]); + document.body.innerHTML = ""; + host = undefined; + expect(document.getElementById(PRISM_GRADIENT_ID)).toBeNull(); + await list([{ level: 12, prestige: 10 }]); + expect(document.querySelectorAll(`#${PRISM_GRADIENT_ID}`)).toHaveLength( + 1, + ); + }); + + it("gives a badge inside a shadow root the gradient there", async () => { + const outer = document.createElement("div"); + document.body.append(outer); + const shadow = outer.attachShadow({ mode: "open" }); + try { + await list( + [ + { level: 12, prestige: 10 }, + { level: 40, prestige: 10 }, + ], + shadow, + ); + // url(#id) resolves within the badge's own tree. + expect(shadow.querySelectorAll(`#${PRISM_GRADIENT_ID}`)).toHaveLength( + 1, + ); + } finally { + outer.remove(); + } + }); + + it("adds no gradient for a list without the last rank", async () => { + document.body.innerHTML = ""; + await list(lobby(150).filter((p) => p.prestige !== 10)); + expect(document.getElementById(PRISM_GRADIENT_ID)).toBeNull(); + }); + + it("shares one drawing between badges of a look, with each its own number", async () => { + const [a, b, c] = await list([ + { level: 42, prestige: 3, size: 40 }, + { level: 47, prestige: 3, size: 40 }, + { level: 42, prestige: 4, size: 40 }, + ]); + const texts = (badge: LevelBadge) => + [...badge.querySelectorAll("text")].map((t) => t.textContent); + expect(texts(a)).toEqual([ + "42", + 'progression.prestige_short:{"prestige":3}', + ]); + expect(texts(b)).toEqual([ + "47", + 'progression.prestige_short:{"prestige":3}', + ]); + expect(texts(c)).toEqual([ + "42", + 'progression.prestige_short:{"prestige":4}', + ]); + const shapes = (badge: LevelBadge) => { + const svg = badge.querySelector("svg")!.cloneNode(true) as Element; + svg.querySelectorAll("text, title").forEach((t) => t.remove()); + return svg.innerHTML.replace(//g, ""); + }; + expect(shapes(a)).toBe(shapes(b)); + expect(shapes(a)).not.toBe(shapes(c)); + }); + + it("redraws when a badge's level, rank or size changes", async () => { + const [badge] = await list([{ level: 9, prestige: 0, size: 24 }]); + expect(badge.querySelector("svg circle")).not.toBeNull(); + badge.level = 10; + await badge.updateComplete; + expect(badge.querySelector("[data-level-frame] rect")).not.toBeNull(); + expect(badge.querySelector("text")?.textContent).toBe("10"); + badge.prestige = 10; + await badge.updateComplete; + expect(badge.querySelector("[data-prestige-tab]")).not.toBeNull(); + expect(document.getElementById(PRISM_GRADIENT_ID)).not.toBeNull(); + badge.size = 16; + await badge.updateComplete; + expect(badge.querySelector("[data-prestige-tab]")).toBeNull(); + }); + }); + + it("gives Legend its own look: the crown on a gold halo, gems on its points", async () => { const badge = await render({ level: 100, prestige: 10, @@ -195,6 +444,11 @@ describe("", () => { expect(svg.getAttribute("data-prestige-tier")).toBe("none"); expect(svg.getAttribute("aria-label")).toBe("progression.legend"); expect(svg.querySelector("text")).toBeNull(); + expect(svg.querySelector("[data-legend-halo]")).not.toBeNull(); + expect(svg.querySelectorAll("[data-legend-gem]").length).toBe(3); + // A stronger glow than any rank's, and no wings to make room for. + expect(svg.getAttribute("style")).toContain("rgba(250,204,21,0.85)"); + expect(svg.getAttribute("data-winged")).toBeNull(); }); }); diff --git a/tests/client/NavAccountCatchUp.test.ts b/tests/client/NavAccountCatchUp.test.ts new file mode 100644 index 00000000000..e5731731506 --- /dev/null +++ b/tests/client/NavAccountCatchUp.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Auth", () => ({ logOut: vi.fn() })); +vi.mock("../../src/client/CrazyGamesSDK", () => ({ + crazyGamesSDK: { + isOnCrazyGames: () => false, + getUserProfile: vi.fn(async () => null), + showAuthPrompt: vi.fn(), + }, +})); +vi.mock("../../src/client/InGameModal", () => ({ showInGameConfirm: vi.fn() })); +vi.mock("../../src/client/Navigation", () => ({ + closeMobileSidebar: vi.fn(), +})); +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + copyToClipboard: vi.fn(), + showToast: vi.fn(), +})); + +import type { Progress, UserMeResponse } from "@openfront/shared/ApiSchemas"; +import { + NAV_CATCH_UP_MS, + NAV_LEVEL_POP_MS, + NavAccountMenu, + navCatchUp, +} from "../../src/client/components/NavAccountMenu"; + +const at = ( + level: number, + xpInLevel: number, + lifetimeXp: number, + prestige = 3, +): Progress => ({ + prestige, + level, + xpInLevel, + xpForNext: 2000, + lifetimeXp, + legend: false, + canPrestige: false, +}); + +// Sample player Wonder: level 46, most of the way through it. +const before = at(46, 1500, 124_000); +const sameLevel = at(46, 1800, 124_300); +const nextLevel = at(47, 500, 125_000); + +const env = { onScreen: true, pageVisible: true, reducedMotion: false }; + +describe("navCatchUp", () => { + it("grows within a level and levels up across one", () => { + expect(navCatchUp(before, sameLevel, env)).toBe("grow"); + expect(navCatchUp(before, nextLevel, env)).toBe("level_up"); + }); + + it("snaps on first load, and for progress that isn't higher", () => { + expect(navCatchUp(null, nextLevel, env)).toBe("snap"); + expect(navCatchUp(before, null, env)).toBe("snap"); + expect(navCatchUp(before, before, env)).toBe("snap"); + expect(navCatchUp(nextLevel, before, env)).toBe("snap"); + // A prestige resets the level without adding XP: no catch-up. + expect(navCatchUp(at(100, 0, 500_000, 3), at(1, 0, 500_000, 4), env)).toBe( + "snap", + ); + }); + + it("snaps when nobody can watch it", () => { + expect(navCatchUp(before, nextLevel, { ...env, onScreen: false })).toBe( + "snap", + ); + expect(navCatchUp(before, nextLevel, { ...env, pageVisible: false })).toBe( + "snap", + ); + expect(navCatchUp(before, nextLevel, { ...env, reducedMotion: true })).toBe( + "snap", + ); + }); +}); + +function userMe(progress: Progress): UserMeResponse { + return { + user: { email: "wonder@example.com" }, + player: { publicId: "wonder01", progress }, + } as unknown as UserMeResponse; +} + +describe("nav-account-menu catch-up", () => { + let el: NavAccountMenu; + let onScreen = true; + + beforeEach(() => { + vi.useFakeTimers(); + if (!customElements.get("nav-account-menu")) { + customElements.define("nav-account-menu", NavAccountMenu); + } + onScreen = true; + vi.spyOn( + NavAccountMenu.prototype as unknown as { isOnScreen: () => boolean }, + "isOnScreen", + ).mockImplementation(() => onScreen); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + }); + + afterEach(() => { + el?.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function mount(progress: Progress): Promise { + el = document.createElement("nav-account-menu") as NavAccountMenu; + el.variant = "desktop"; + document.body.appendChild(el); + await send(progress); + } + + async function send(progress: Progress): Promise { + document.dispatchEvent( + new CustomEvent("userMeResponse", { detail: userMe(progress) }), + ); + await el.updateComplete; + } + + const fill = () => + el.querySelector("[data-account-xp-bar] [data-xp-bar-fill]")!; + const badgeLevel = () => + ( + el.querySelector("[data-account-level] level-badge") as HTMLElement & { + level: number; + } + ).level; + const popping = () => + el + .querySelector("[data-account-level]")! + .classList.contains("nav-level-pop"); + const transitionEnd = async () => { + const e = new Event("transitionend") as TransitionEvent; + Object.defineProperty(e, "propertyName", { value: "width" }); + fill().dispatchEvent(e); + await vi.advanceTimersByTimeAsync(0); + await el.updateComplete; + }; + + it("shows the first progress as it is", async () => { + await mount(before); + expect(badgeLevel()).toBe(46); + expect(fill().style.width).toBe("75%"); + expect(fill().style.transition).toBe(""); + }); + + it("fills, ticks the level over with a pop, then grows to the new progress", async () => { + await mount(before); + await send(nextLevel); + // 1. Fill to the end of the old level. + expect(badgeLevel()).toBe(46); + expect(fill().style.width).toBe("100%"); + expect(fill().style.transition).toContain(`${NAV_CATCH_UP_MS}ms`); + // 2. When it gets there, the level ticks over and the badge pops; 3. the + // bar empties without a transition; 4. it grows to the new width. + await transitionEnd(); + expect(badgeLevel()).toBe(47); + expect(popping()).toBe(true); + expect(fill().style.width).toBe("25%"); + expect(fill().style.transition).toContain(`${NAV_CATCH_UP_MS}ms`); + await transitionEnd(); + expect(fill().style.transition).toBe(""); + await vi.advanceTimersByTimeAsync(NAV_LEVEL_POP_MS); + await el.updateComplete; + expect(popping()).toBe(false); + }); + + it("empties the bar without a transition before growing it", async () => { + await mount(before); + await send(nextLevel); + // Record the fill's styles at each render. + const seen: string[] = []; + const observer = new MutationObserver(() => + seen.push(`${fill().style.width}|${fill().style.transition}`), + ); + observer.observe(fill(), { attributes: true, attributeFilter: ["style"] }); + await transitionEnd(); + observer.disconnect(); + expect(seen[0]).toBe("0%|"); + expect(seen[seen.length - 1]).toContain("25%|width"); + }); + + it("carries on if the transition never reports its end", async () => { + await mount(before); + await send(nextLevel); + await vi.advanceTimersByTimeAsync(NAV_CATCH_UP_MS + 200); + await el.updateComplete; + expect(badgeLevel()).toBe(47); + await vi.advanceTimersByTimeAsync(NAV_CATCH_UP_MS + 200); + await el.updateComplete; + expect(fill().style.width).toBe("25%"); + expect(fill().style.transition).toBe(""); + }); + + it("grows within a level", async () => { + await mount(before); + await send(sameLevel); + expect(badgeLevel()).toBe(46); + expect(fill().style.width).toBe("90%"); + expect(fill().style.transition).toContain(`${NAV_CATCH_UP_MS}ms`); + expect(popping()).toBe(false); + }); + + it("snaps when the menu is hidden", async () => { + await mount(before); + onScreen = false; + await send(nextLevel); + expect(badgeLevel()).toBe(47); + expect(fill().style.width).toBe("25%"); + expect(fill().style.transition).toBe(""); + }); + + it("snaps when the page is hidden", async () => { + await mount(before); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + await send(nextLevel); + expect(badgeLevel()).toBe(47); + expect(fill().style.transition).toBe(""); + }); + + it("snaps under reduced motion", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: true })), + ); + await mount(before); + await send(nextLevel); + expect(badgeLevel()).toBe(47); + expect(fill().style.transition).toBe(""); + expect(popping()).toBe(false); + }); +}); + +describe("nav-account-menu layout", () => { + let el: NavAccountMenu; + + beforeEach(() => { + if (!customElements.get("nav-account-menu")) { + customElements.define("nav-account-menu", NavAccountMenu); + } + }); + + afterEach(() => { + el?.remove(); + }); + + async function mount(variant: "desktop" | "mobile"): Promise { + el = document.createElement("nav-account-menu") as NavAccountMenu; + el.variant = variant; + document.body.appendChild(el); + await el.updateComplete; + } + + async function send(progress: Progress | undefined): Promise { + const u = userMe(before) as unknown as { player: { progress?: Progress } }; + if (progress === undefined) delete u.player.progress; + else u.player.progress = progress; + document.dispatchEvent(new CustomEvent("userMeResponse", { detail: u })); + await el.updateComplete; + } + + it("keeps the desktop bar under the badge and avatar row, clear of the avatar", async () => { + await mount("desktop"); + await send(undefined); + const row = el.querySelector("[data-account-identity]")!; + // No progress: no bar, and the row adds no padding for one. + expect(el.querySelector("[data-account-xp-bar]")).toBeNull(); + expect(row.classList.contains("pb-1.5")).toBe(false); + const avatar = el.querySelector("[data-account-avatar]"); + + await send(before); + const bar = el.querySelector("[data-account-xp-bar]")!; + // The bar sits in the row's own bottom padding, spanning badge and + // avatar only, and the avatar is a size smaller so it doesn't overhang. + expect(bar.parentElement).toBe(row); + expect(bar.className).toContain("bottom-0 left-0 right-0"); + expect(row.classList.contains("pb-1.5")).toBe(true); + expect(row.className).toContain("[&_[data-account-avatar]]:h-7"); + expect(row.querySelector("[data-account-level]")).not.toBeNull(); + expect(row.querySelector("[data-account-avatar]")).not.toBeNull(); + // The chevron is outside the row, so the bar never runs under it. + const chevron = el.querySelector( + "[data-account-trigger]", + )!.lastElementChild!; + expect(chevron.tagName.toLowerCase()).toBe("svg"); + expect(row.contains(chevron)).toBe(false); + // The avatar NavAccountButton drives is the same element as before. + expect(el.querySelector("[data-account-avatar]")).toBe(avatar); + }); + + it("keeps the mobile bar under the avatar", async () => { + await mount("mobile"); + await send(before); + const bar = el.querySelector("[data-account-xp-bar]")!; + expect(bar.className).toContain("-bottom-1.5 left-0 right-0"); + expect( + bar.parentElement!.querySelector("[data-account-avatar]"), + ).not.toBeNull(); + }); +}); diff --git a/tests/client/ProgressionSchemas.test.ts b/tests/client/ProgressionSchemas.test.ts index 99881f7353e..573e0ed7926 100644 --- a/tests/client/ProgressionSchemas.test.ts +++ b/tests/client/ProgressionSchemas.test.ts @@ -90,6 +90,66 @@ describe("GameXpResponseSchema", () => { expect(parsed.levelsReached).toEqual([{ prestige: 0, level: 5 }]); }); + it("parses the level rewards and flares a game earned", () => { + const parsed = GameXpResponseSchema.parse({ + ...eligible, + rewards: [ + { + prestige: 0, + level: 5, + id: "41", + currencyType: "soft", + amount: "100", + reason: "level_up", + note: null, + claimed: false, + }, + ], + flares: [ + { + kind: "level", + prestige: 0, + level: 5, + flareId: "7", + flareName: "effect:firebird_trail", + cosmetic: { type: "effect", name: "firebird_trail", url: null }, + }, + { + kind: "legend", + prestige: null, + level: null, + flareId: "8", + flareName: "flag:obey_flag", + cosmetic: null, + }, + ], + }); + if (!parsed.eligible) throw new Error("expected eligible"); + expect(parsed.rewards!.map((r) => [r.id, r.amount, r.claimed])).toEqual([ + ["41", "100", false], + ]); + expect(parsed.flares!.map((f) => f.flareName)).toEqual([ + "effect:firebird_trail", + "flag:obey_flag", + ]); + }); + + it("reads missing or malformed rewards and flares as absent", () => { + let parsed = GameXpResponseSchema.parse(eligible); + if (!parsed.eligible) throw new Error("expected eligible"); + expect(parsed.rewards).toBeUndefined(); + expect(parsed.flares).toBeUndefined(); + parsed = GameXpResponseSchema.parse({ + ...eligible, + rewards: [{ id: 5 }], + flares: "nope", + }); + if (!parsed.eligible) throw new Error("expected eligible"); + expect(parsed.rewards).toBeUndefined(); + expect(parsed.flares).toBeUndefined(); + expect(parsed.breakdown.total).toBe(275); + }); + it("defaults a missing levelsReached to none", () => { const rest: Record = { ...eligible }; delete rest.levelsReached; diff --git a/tests/client/graphics/layers/WinModalXp.test.ts b/tests/client/graphics/layers/WinModalXp.test.ts index 4d52497d01c..6cbd268e284 100644 --- a/tests/client/graphics/layers/WinModalXp.test.ts +++ b/tests/client/graphics/layers/WinModalXp.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { getUserMe, fetchProgressionConfig, isLoggedIn } = vi.hoisted(() => ({ - getUserMe: vi.fn(), - fetchProgressionConfig: vi.fn(), - isLoggedIn: vi.fn(), -})); +const { getUserMe, invalidateUserMe, fetchProgressionConfig, isLoggedIn } = + vi.hoisted(() => ({ + getUserMe: vi.fn(), + invalidateUserMe: vi.fn(), + fetchProgressionConfig: vi.fn(), + isLoggedIn: vi.fn(), + })); vi.mock("../../../../src/client/Utils", () => ({ translateText: (key: string, params?: Record) => @@ -15,7 +17,7 @@ vi.mock("../../../../src/client/Utils", () => ({ TUTORIAL_VIDEO_URL: "https://example.com/tutorial", })); -vi.mock("../../../../src/client/Api", () => ({ getUserMe })); +vi.mock("../../../../src/client/Api", () => ({ getUserMe, invalidateUserMe })); vi.mock("../../../../src/client/Auth", () => ({ getAuthHeader: vi.fn(async () => "Bearer test-token"), @@ -1203,6 +1205,36 @@ describe("WinModal XP section", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("refreshes /users/@me after a game that unlocked cosmetics", async () => { + stubXpEndpoint([() => json(eligible())]); + await mount(makeGame({ ended: true })); + expect(xpState()).toBe("result"); + expect(invalidateUserMe).not.toHaveBeenCalled(); + + modal.remove(); + stubXpEndpoint([ + () => + json( + eligible({ + flares: [ + { + kind: "level", + prestige: 0, + level: 5, + flareId: "9", + flareName: "flag:obey_flag", + cosmetic: { type: "flag", name: "obey_flag", url: null }, + }, + ], + }), + ), + ]); + await mount(makeGame({ ended: true })); + expect(xpState()).toBe("result"); + // So the Locker sees what the game unlocked. + expect(invalidateUserMe).toHaveBeenCalledTimes(1); + }); + it("waits for the game to end when the player dies mid-game", async () => { const fetchMock = stubXpEndpoint([notFound]); await mount(makeGame({ ended: false, alive: false }));