From ebce76caff567b73194712150a9f14734878824d Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sun, 4 Oct 2026 18:55:31 +0100 Subject: [PATCH 1/7] Draw a colour and outline for every prestige rank Replaces the four shared prestige tiers (ring, double ring, sunburst, radiant) and their rank gems with one style per rank, Heraldry: bronze plain medal, silver four points, jade laurel, sapphire eight-point star, amethyst wings, crimson tiara, ember compass rays, gold sun, diamond crystal, prismatic rays and big wings. P5 and P10 are milestone ranks with a star on top and a glow. Once prestiged, the inner level frame keeps its band's shape but takes the rank's colours, and the P tab is tinted to match; P0 is unchanged. Legend gets a gold sunburst halo, gems on the crown's points and a stronger glow. Winged ranks get side margin so the wings never touch the name beside them. The prestige ceremony and the profile card's glow use the rank's own colour. Co-Authored-By: Claude Opus 5.5 --- src/client/Progression.ts | 192 +++++++++++- src/client/components/LevelBadge.ts | 418 +++++++++++++++++++------- src/client/components/PrestigeFlow.ts | 19 +- src/client/components/ProfileCard.ts | 6 +- tests/client/LevelBadge.test.ts | 161 ++++++++-- 5 files changed, 641 insertions(+), 155 deletions(-) diff --git a/src/client/Progression.ts b/src/client/Progression.ts index 5322826bb93..ed0d70acf16 100644 --- a/src/client/Progression.ts +++ b/src/client/Progression.ts @@ -24,19 +24,191 @@ 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 type PrestigeOutline = + | "plain" + | "points" + | "laurel" + | "star" + | "wings" + | "tiara" + | "compass" + | "sun" + | "crystal" + | "prismatic"; -export function prestigeTier(prestige: number): PrestigeTier { +/** 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). */ +export function prestigeStyle(prestige: number): PrestigeStyle | null { 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"; + 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/LevelBadge.ts b/src/client/components/LevelBadge.ts index 822eda8cc54..0c984a2c212 100644 --- a/src/client/components/LevelBadge.ts +++ b/src/client/components/LevelBadge.ts @@ -4,15 +4,17 @@ import { clampPrestige, levelBand, MAX_LEVEL, - PrestigeTier, - prestigeTier, + prestigeAccent, + prestigeStyle, + PrestigeStyle, } from "../Progression"; import { translateText } from "../Utils"; // 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. @@ -49,6 +51,15 @@ 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. @@ -142,7 +153,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", @@ -157,9 +168,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 { @@ -182,6 +210,146 @@ export function levelBadgeLabel( return `${levelText}, ${translateText("progression.prestige", { prestige: p })}`; } +// --------------------------------------------------------------------------- +// Emblem primitives, on the 32x32 grid. +// --------------------------------------------------------------------------- + +function disc( + r: number, + fill: string, + stroke: string, + width: number, +): SVGTemplateResult { + return svg``; +} + +function poly( + points: string, + fill: string, + stroke: string, + width: number, +): SVGTemplateResult { + return svg``; +} + +// 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, +): SVGTemplateResult { + 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 svg`${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): SVGTemplateResult { + const r = 15.3; + const leaves: SVGTemplateResult[] = []; + 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], + ]) { + leaves.push(svg``); + } + } + return svg`${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, +): SVGTemplateResult { + return svg`${angles.map((deg, i) => + poly( + pointList([ + polar(deg - 9, 12.8), + polar(deg, lengths[i]), + polar(deg + 9, 12.8), + ]), + fill, + stroke, + 1, + ), + )}`; +} + +const PRISM = [ + "#ff4f9a", + "#ffa53b", + "#ffe45c", + "#47e0a0", + "#46b4ff", + "#a46bff", +]; + +// Rays in every colour, for the last rank. +function prismRays(n: number, outer: number, inner: number): SVGTemplateResult { + const half = 180 / n; + return svg`${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, + ); + })}`; +} + +// A five-point star on top of the badge: a milestone rank. +function topStar( + fill: string, + stroke: string, + cy: number, + outer: number, + inner: number, +): SVGTemplateResult { + return poly(starPoints(5, outer, inner, -90, cy), fill, stroke, 0.9); +} + +// Gradients need a document-unique id: many badges share a page, and a +// reference to an id in a hidden badge would draw nothing. +let gradientCount = 0; + @customElement("level-badge") export class LevelBadge extends LitElement { @property({ type: Number }) level = 1; @@ -190,6 +358,8 @@ export class LevelBadge extends LitElement { // Rendered size in CSS pixels (square). 16 is the smallest supported. @property({ type: Number }) size = 24; + private readonly prismId = `level-badge-prism-${++gradientCount}`; + createRenderRoot() { return this; } @@ -240,86 +410,99 @@ export class LevelBadge extends LitElement { // 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``; + // 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. + private renderPrestigeEmblem(style: PrestigeStyle): SVGTemplateResult { + const { base, light, dark } = style; + switch (style.outline) { + case "plain": + return disc(15, base, light, 1.6); + case "points": + return svg`${poly(starPoints(4, 19, 9.5, -45), base, light, 1.1)}${disc( + 14, + base, + light, + 1.4, + )}`; + case "laurel": + return svg`${laurel(base, light)}${disc(13.6, base, light, 1.4)}`; + case "star": + return svg`${poly(starPoints(8, 18.2, 13.2), base, light, 1)}${disc( + 13.4, + base, + light, + 1.2, + )}`; + case "wings": + return svg`${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 svg`${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 svg`${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 svg`${poly(starPoints(16, 18, 14.2), base, light, 0.8)}${disc( + 13.8, + base, + light, + 1.2, + )}`; + case "crystal": + return svg`${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 svg` + + + + + + + ${prismRays(12, 19.8, 12.5)}${wings(light, "#ffffff", 1, 1.1)}${disc( + 14.6, + `url(#${this.prismId})`, + "#ffffff", + 2, + )}${disc(12.4, "none", "#ffffff", 0.8)}${topStar( + "#ffffff", + dark, + 2.2, + 5.2, + 2.3, + )}`; } } - // 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, + style: PrestigeStyle, ): 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. + // Below 24px the numeral is unreadable; the emblem's outline still says + // which rank and the accessible name carries it too. if (this.size < 24) return nothing; + const wide = style.rank >= 10; 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} + fill=${style.light} + textLength=${wide ? 12 : nothing} lengthAdjust="spacingAndGlyphs" - >${translateText("progression.prestige_short", { prestige })}`; + >${translateText("progression.prestige_short", { prestige: style.rank })}`; } // The level frame and number, shrunk inside the prestige emblem when there - // is one. - private renderFrame(band: BandStyle, prestiged: boolean): SVGTemplateResult { + // 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. + private renderFrame( + band: BandStyle, + style: PrestigeStyle | null, + ): SVGTemplateResult { const inner = svg` - + ${band.shape} ${this.renderNumber(band)}`; - if (!prestiged) return inner; + if (style === null) 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` @@ -349,8 +543,23 @@ export class LevelBadge extends LitElement { `; } + // Legend: a black crown with a gold star on a gold sunburst halo, gems on + // its points. private renderLegend(): SVGTemplateResult { + const gems: [number, number, string][] = [ + [2, 9, "#38bdf8"], + [16, 4, "#f43f5e"], + [30, 9, "#38bdf8"], + ]; return svg` + + ${gems.map( + ([cx, cy, fill]) => svg``, + )} `; } @@ -368,13 +581,15 @@ export class LevelBadge extends LitElement { const band = BANDS[levelBand(this.level)]; const label = this.label(); const px = Math.max(16, this.size); - 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 style = this.legend ? null : prestigeStyle(this.prestige); + const glow = this.legend ? LEGEND_GLOW : (style?.glow ?? null); + const margin = style?.winged ? Math.round(px * WINGED_MARGIN) : 0; + 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} + ${style !== null ? this.renderPrestigeEmblem(style) : nothing} + ${this.renderFrame(band, style)} + ${style !== null ? this.renderPrestigeTab(style) : nothing} `} `; } diff --git a/src/client/components/PrestigeFlow.ts b/src/client/components/PrestigeFlow.ts index f231029a7da..e37a4f8e1d1 100644 --- a/src/client/components/PrestigeFlow.ts +++ b/src/client/components/PrestigeFlow.ts @@ -10,12 +10,7 @@ import { import { customElement, property, state } from "lit/decorators.js"; import type { PrestigeResponse, Progress } from "../../core/ApiSchemas"; import { momentShareText, momentShareUrl } from "../MomentShare"; -import { - clampPrestige, - MAX_PRESTIGE, - PrestigeTier, - prestigeTier, -} from "../Progression"; +import { clampPrestige, MAX_PRESTIGE, prestigeAccent } from "../Progression"; import { prestigeMe } from "../ProgressionApi"; import { translateText } from "../Utils"; import "./CapIcon"; @@ -49,14 +44,6 @@ export const HOLD_MS = 1500; // Ranks that come with an exclusive cosmetic (the levels plan, §3.4). const COSMETIC_RANKS = [1, 5, 10]; -const TIER_COLORS: Record = { - none: "#facc15", - ring: "#f59e0b", - double: "#e2e8f0", - sunburst: "#facc15", - radiant: "#d946ef", -}; - // The honeycomb baked into the menu background (resources/images/ // background.webp, 2500x1382): flat-topped hexes with 147px sides, one of // whose rows of horizontal edges runs along y=1260 with an edge centred on @@ -333,7 +320,7 @@ export class PrestigeFlow extends LitElement { aria-modal="true" aria-labelledby="prestige-confirm-title" class="prestige-ceremony fixed inset-0 z-[10020] flex flex-col items-center overflow-y-auto px-4 py-8 text-white" - style="--tier: ${TIER_COLORS[prestigeTier(rank)]}" + style="--tier: ${prestigeAccent(rank)}" > ${this.renderCeremonyStyles()} @@ -641,7 +628,7 @@ export class PrestigeFlow extends LitElement { private renderCeremony(before: Progress): TemplateResult { const after = this.outcome?.progress; const rank = after?.prestige ?? this.nextRank(before); - const color = TIER_COLORS[prestigeTier(rank)]; + const color = prestigeAccent(rank); const shattered = this.reached("shatter"); const revealed = this.reached("reveal"); const done = this.beat === "done"; diff --git a/src/client/components/ProfileCard.ts b/src/client/components/ProfileCard.ts index 1eb190e44e9..b0b405a1a94 100644 --- a/src/client/components/ProfileCard.ts +++ b/src/client/components/ProfileCard.ts @@ -259,7 +259,11 @@ export class ProfileCard extends LitElement { render() { const progress = this.progress; if (progress === null) return nothing; - const accent = levelBadgeAccent(progress.level, progress.legend); + const accent = levelBadgeAccent( + progress.level, + progress.legend, + progress.prestige, + ); const clan = this.clanTag === null ? nothing diff --git a/tests/client/LevelBadge.test.ts b/tests/client/LevelBadge.test.ts index 500344a62af..7c3191fe6ad 100644 --- a/tests/client/LevelBadge.test.ts +++ b/tests/client/LevelBadge.test.ts @@ -6,7 +6,11 @@ vi.mock("../../src/client/Utils", () => ({ params ? `${key}:${JSON.stringify(params)}` : key, })); -import { LevelBadge } from "../../src/client/components/LevelBadge"; +import { + LevelBadge, + levelBadgeAccent, + WINGED_MARGIN, +} from "../../src/client/components/LevelBadge"; import { apportionXp, ineligibleReasonKey, @@ -14,6 +18,8 @@ import { levelFraction, levelRewardReasonKey, multiplierPercent, + prestigeAccent, + prestigeStyle, subscriberTierOf, visibleMultipliers, visibleXpLines, @@ -115,39 +121,135 @@ 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(4); + 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", + ); + } + // 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("labels the rank where there is room, and always in the name", async () => { @@ -164,7 +266,7 @@ describe("", () => { ).toEqual(["12"]); }); - it("gives Legend its own look", async () => { + 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, @@ -176,6 +278,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(); }); }); From c18438528e4282b81c46bac8eb77eb655a7847d2 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sun, 4 Oct 2026 18:56:14 +0100 Subject: [PATCH 2/7] Light the XP bar's leading edge while it fills During the end-of-game reveal, while the bar is climbing its fill gets a white-hot glow at its right edge and a sheen running along it, both pseudo-elements on the fill (the sheen moves by transform). Only while filling, and off under reduced motion. The "calculating" spinner also stops spinning under reduced motion. Co-Authored-By: Claude Opus 5.5 --- src/client/components/GameXpPanel.ts | 56 ++++++++++++- tests/client/GameXpPanelReveal.test.ts | 104 +++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/client/GameXpPanelReveal.test.ts diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index eb90e25aff2..b4c6752b067 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -570,7 +570,7 @@ export class GameXpPanel extends LitElement { return this.frame( html`
${translateText("progression.calculating")} @@ -692,7 +692,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" @@ -879,6 +879,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); @@ -1136,6 +1184,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, diff --git a/tests/client/GameXpPanelReveal.test.ts b/tests/client/GameXpPanelReveal.test.ts new file mode 100644 index 00000000000..39cc459c234 --- /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 "../../src/client/components/GameXpPanel"; +import type { GameXpPanel } from "../../src/client/components/GameXpPanel"; +import type { GameXpEligible } from "../../src/core/ApiSchemas"; + +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); + }); +}); From 782aa96e82dd625901aa8c264c4bb422f258607d Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sun, 4 Oct 2026 19:04:45 +0100 Subject: [PATCH 3/7] Read a game's level rewards and unlocks from the XP result GET /users/@me/xp/:gameId now also says what the levels a game crossed paid (`rewards`, each with its claim id and whether it is claimed) and which cosmetics it unlocked (`flares`). Both are optional, and a malformed list reads as absent rather than failing the result. Co-Authored-By: Claude Opus 5.5 --- src/core/ApiSchemas.ts | 36 +++++++++++++++ tests/client/ProgressionSchemas.test.ts | 60 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 4d59410c4d8..fccb378d3c0 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -160,6 +160,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. @@ -174,6 +202,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/tests/client/ProgressionSchemas.test.ts b/tests/client/ProgressionSchemas.test.ts index 15b2ba4ec31..7e3b64080fd 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; From c1695b0b7d23ef564d8e99d6e430213cab8b4de7 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sun, 4 Oct 2026 19:04:45 +0100 Subject: [PATCH 4/7] Show and claim a game's level rewards in the end-of-game reveal After the game's last level-up (and the milestone card, when there is one), a rewards row rises in the way the source cards do, its amounts counting up: "Level 47 reward +100 Caps", "Levels 45-47 +300 Caps (3 x 100)", "Level 50 rewards +100 Caps +25 Plutonium". Its Claim button claims only this game's rewards, shows a spinner while it works, then "Claimed" with the new balance counting up from the old one, refreshes /users/@me and emits rewards-changed like the rewards panel. A failed claim shows an error and gives the button back; a retry claims only what didn't go through. Rewards already claimed show as claimed. The button works mid-reveal without skipping it. A cosmetic the game unlocked wipes into the milestone (or Legend) card as an "Unlocked" half with its preview, wrapping under the milestone on a phone, or gets a card of its own. The win modal refreshes /users/@me after such a game so the Locker shows it. Server results only, never in the past-game summary; reduced motion shows the final state. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 11 + src/client/GameRewards.ts | 201 +++++++ src/client/components/GameXpPanel.ts | 540 ++++++++++++++++- src/client/hud/layers/WinModal.ts | 7 +- tests/client/GameXpRewards.test.ts | 562 ++++++++++++++++++ .../client/graphics/layers/WinModalXp.test.ts | 44 +- 6 files changed, 1350 insertions(+), 15 deletions(-) create mode 100644 src/client/GameRewards.ts create mode 100644 tests/client/GameXpRewards.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 790619f07e4..232ad59a84a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1852,6 +1852,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!", @@ -1859,6 +1867,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_heading": "XP earned", "xp_progress": "{current} / {next} XP", diff --git a/src/client/GameRewards.ts b/src/client/GameRewards.ts new file mode 100644 index 00000000000..7fbdeeb3d79 --- /dev/null +++ b/src/client/GameRewards.ts @@ -0,0 +1,201 @@ +import type { GameXpFlare, GameXpReward } from "../core/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/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index b4c6752b067..70235eeedbb 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -2,6 +2,21 @@ import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; import type { GameXpEligible, GameXpResponse } from "../../core/ApiSchemas"; +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, @@ -17,7 +32,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, xpBar, xpProgressText } from "./XpBar"; // What the XP section of a finished game is showing. The caller owns the @@ -64,6 +84,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; @@ -113,8 +147,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. function positionOf(level: number, xpInLevel: number, xpForNext: number) { @@ -153,6 +201,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; } @@ -161,10 +221,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) { + if ((s.data.flares ?? []).length > 0) this.loadCatalog(); + } + } if (changed.has("view") || changed.has("compact")) { const s = this.view; if (s.kind === "result" && s.data.eligible && this.onScreen) { @@ -258,6 +327,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, @@ -275,6 +369,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`, @@ -341,6 +438,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; @@ -1175,6 +1273,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%; @@ -1200,7 +1347,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; } } @@ -1215,7 +1367,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, @@ -1264,7 +1417,7 @@ export class GameXpPanel extends LitElement { html`
@@ -1286,6 +1439,7 @@ export class GameXpPanel extends LitElement { })}
+ ${this.renderUnlocks(data, true)} `, ); } @@ -1294,12 +1448,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( @@ -1308,7 +1467,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, )}" > @@ -1335,10 +1494,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/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 49a560dfed8..5c20d6ceeca 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -12,7 +12,7 @@ import { Pattern } from "../../../core/CosmeticSchemas"; import { EventBus } from "../../../core/EventBus"; import { RankedType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { getUserMe } from "../../Api"; +import { getUserMe, invalidateUserMe } from "../../Api"; import "../../components/CosmeticCard"; import { cosmeticSelectionLabel } from "../../components/CosmeticPresentation"; import "../../components/GameXpPanel"; @@ -517,6 +517,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/GameXpRewards.test.ts b/tests/client/GameXpRewards.test.ts new file mode 100644 index 00000000000..c7ee209034b --- /dev/null +++ b/tests/client/GameXpRewards.test.ts @@ -0,0 +1,562 @@ +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 "../../src/client/components/GameXpPanel"; +import type { GameXpPanel } from "../../src/client/components/GameXpPanel"; +import { summarizeGameRewards } from "../../src/client/GameRewards"; +import type { + GameXpEligible, + GameXpFlare, + GameXpReward, +} from "../../src/core/ApiSchemas"; + +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: { compact?: boolean; reveal?: boolean } = {}, + ): Promise { + panel?.remove(); + panel = document.createElement("game-xp-panel") as GameXpPanel; + panel.compact = opts.compact ?? false; + 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 && !panel.compact) { + 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 has no rewards row either. + await mount(single(), { compact: true }); + expect(section().getAttribute("data-xp-state")).toBe("result"); + expect(row()).toBeNull(); + 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/graphics/layers/WinModalXp.test.ts b/tests/client/graphics/layers/WinModalXp.test.ts index 03ade179c4a..25de8539be6 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"), @@ -883,6 +885,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 })); From 169fc46ee1900b234f33b4dcf3a7fc4ab96705a6 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sun, 4 Oct 2026 19:07:29 +0100 Subject: [PATCH 5/7] Let the nav's XP bar catch up to new progress The account trigger keeps the progress it last showed. When a refreshed /users/@me raises it while the trigger is on screen, the page is visible and motion is allowed, the bar animates there: within a level it grows to the new width; across one it fills to the end, the level ticks over with a pop of the badge, it empties without a transition and grows to the new width. Otherwise, and on first load, it snaps. Co-Authored-By: Claude Opus 5.5 --- src/client/components/NavAccountMenu.ts | 201 +++++++++++++++++++- src/client/components/XpBar.ts | 19 +- tests/client/NavAccountCatchUp.test.ts | 239 ++++++++++++++++++++++++ 3 files changed, 450 insertions(+), 9 deletions(-) create mode 100644 tests/client/NavAccountCatchUp.test.ts diff --git a/src/client/components/NavAccountMenu.ts b/src/client/components/NavAccountMenu.ts index 5d087bc6ef3..5dceeaf77dd 100644 --- a/src/client/components/NavAccountMenu.ts +++ b/src/client/components/NavAccountMenu.ts @@ -1,4 +1,11 @@ -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 { Progress, UserMeResponse } from "../../core/ApiSchemas"; @@ -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,42 @@ export class NavAccountMenu extends LitElement { progress.legend, ); const title = `${label} · ${xpProgressText(progress.xpInLevel, progress.xpForNext)}`; - const percent = levelFraction(progress.xpInLevel, progress.xpForNext) * 100; + const percent = + this.barOverride ?? + levelFraction(progress.xpInLevel, progress.xpForNext) * 100; const mobile = variant === "mobile"; return html` + ${this.levelPop + ? html`` + : nothing} `; } diff --git a/src/client/components/XpBar.ts b/src/client/components/XpBar.ts index c23b2d49708..478b5aabbb3 100644 --- a/src/client/components/XpBar.ts +++ b/src/client/components/XpBar.ts @@ -19,12 +19,20 @@ export function xpProgressText(xpInLevel: number, xpForNext: number): string { /** * A thin XP bar. `percent` is 0..100. `valueText` is what a screen reader * announces for the value (typically xpProgressText); without it, the - * percentage. + * percentage. `transitionMs` animates the fill to a new width (0 or absent: + * it jumps); `onFillTransitionEnd` hears when that ends. */ export function xpBar( percent: number, - opts: { heightClass?: string; valueText?: string } = {}, + opts: { + heightClass?: string; + valueText?: string; + transitionMs?: number; + fillClass?: string; + onFillTransitionEnd?: (e: TransitionEvent) => void; + } = {}, ): TemplateResult { + const ms = opts.transitionMs ?? 0; return html`
`; } diff --git a/tests/client/NavAccountCatchUp.test.ts b/tests/client/NavAccountCatchUp.test.ts new file mode 100644 index 00000000000..996401e7fb0 --- /dev/null +++ b/tests/client/NavAccountCatchUp.test.ts @@ -0,0 +1,239 @@ +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 { + NAV_CATCH_UP_MS, + NAV_LEVEL_POP_MS, + NavAccountMenu, + navCatchUp, +} from "../../src/client/components/NavAccountMenu"; +import type { Progress, UserMeResponse } from "../../src/core/ApiSchemas"; + +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); + }); +}); From 47e7900752a13f22fb404119da79b407a817b109 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sun, 4 Oct 2026 19:35:23 +0100 Subject: [PATCH 6/7] Keep the nav's XP bar clear of the avatar On the desktop trigger the XP bar ran along the pill's bottom edge, under the badge, the avatar and the chevron, and the 32px avatar reached 3px down into it. The badge and avatar now sit in a row of their own with the bar in its bottom padding, spanning just the two, and the avatar is 28px while there is a bar, leaving a 3px gap above it. The row is always rendered (only its classes change), so the avatar and icons that NavAccountButton drives are never re-created. The email badge moves to the person icon's top corner, as on mobile. The mobile trigger already kept the bar under the avatar and is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/client/components/NavAccountMenu.ts | 49 +++++++++++++----- tests/client/NavAccountCatchUp.test.ts | 68 +++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/src/client/components/NavAccountMenu.ts b/src/client/components/NavAccountMenu.ts index 5dceeaf77dd..8f662922a0f 100644 --- a/src/client/components/NavAccountMenu.ts +++ b/src/client/components/NavAccountMenu.ts @@ -569,9 +569,6 @@ export class NavAccountMenu extends LitElement { progress.legend, ); const title = `${label} · ${xpProgressText(progress.xpInLevel, progress.xpForNext)}`; - const percent = - this.barOverride ?? - levelFraction(progress.xpInLevel, progress.xpForNext) * 100; const mobile = variant === "mobile"; return html` ${this.levelPop @@ -614,11 +611,26 @@ export class NavAccountMenu extends LitElement { .size=${mobile ? 16 : 22} >
+ `; + } + + // 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`