From 53b26e0c4e956842401898db2b1f613bb6324c89 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 1 Oct 2026 00:16:58 +0100 Subject: [PATCH 01/17] Show player levels and the XP each game earns A level badge (level bands, prestige emblems, the Legend crown) and an XP bar for the account menu, player profiles and the game stats modal. On the end-of-game screen, the game's XP is revealed a source at a time: a caption per source rolls in over a segmented bar that climbs, pausing with an animation at each level reached, while each source's card lands below. Multipliers then wipe across the cards in the bonus's colours, a subscription tier's own (Sovereign, Warlord, Vanguard), and a milestone or Legend gets its own card. Tapping skips to the end. The modal's buttons get the store's texture and a hover. Every API state is handled (signed out, calculating, ineligible, result), and nothing shows while the API reports progression is off. Resolves #5725 Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 42 + src/client/GameStatsModal.ts | 33 + src/client/PlayerProfileModal.ts | 68 +- src/client/Progression.ts | 212 +++ src/client/ProgressionApi.ts | 169 ++ src/client/components/GameXpPanel.ts | 1452 +++++++++++++++++ src/client/components/LevelBadge.ts | 378 +++++ src/client/components/NavAccountMenu.ts | 54 +- src/client/components/RewardsPanel.ts | 5 + src/client/components/XpBar.ts | 46 + src/client/hud/layers/WinModal.ts | 253 ++- src/core/ApiSchemas.ts | 99 ++ tests/client/LevelBadge.test.ts | 260 +++ tests/client/NavAccountMenuLevel.test.ts | 105 ++ tests/client/ProgressionModals.test.ts | 301 ++++ tests/client/ProgressionSchemas.test.ts | 147 ++ tests/client/RewardsPanel.test.ts | 81 + .../graphics/layers/WinModalTick.test.ts | 35 +- .../client/graphics/layers/WinModalXp.test.ts | 766 +++++++++ 19 files changed, 4476 insertions(+), 30 deletions(-) create mode 100644 src/client/Progression.ts create mode 100644 src/client/ProgressionApi.ts create mode 100644 src/client/components/GameXpPanel.ts create mode 100644 src/client/components/LevelBadge.ts create mode 100644 src/client/components/XpBar.ts create mode 100644 tests/client/LevelBadge.test.ts create mode 100644 tests/client/NavAccountMenuLevel.test.ts create mode 100644 tests/client/ProgressionModals.test.ts create mode 100644 tests/client/ProgressionSchemas.test.ts create mode 100644 tests/client/RewardsPanel.test.ts create mode 100644 tests/client/graphics/layers/WinModalXp.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 1ea2387233..3394da3cba 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -85,6 +85,9 @@ "reactivate_subscription": "Reactivate", "recovery_email_sent": "Recovery email sent to {email}", "reward_daily": "Daily subscription reward", + "reward_level_milestone": "Level milestone reward", + "reward_level_up": "Level-up reward", + "reward_prestige": "Prestige reward", "reward_signup_bonus": "Subscription signup bonus", "sign_in_desc": "Sign in to save your stats and progress", "sub_granted_from_purchase": "Included with your Steam purchase, paid for once on Steam. It isn't billed and doesn't renew, so there's nothing to manage or cancel. When it ends you keep the full game at no charge; only the {tier} perks stop.", @@ -1772,6 +1775,45 @@ "title": "Join Lobby", "version_mismatch": "This game was created with a different version. Cannot join." }, + "progression": { + "awaiting_end": "XP is awarded when the game ends.", + "bonus_line": "+{percent}% XP ({source})", + "calculating": "Calculating XP…", + "ineligible_custom_settings": "Games with custom settings don't earn XP.", + "ineligible_daily_cap": "You've reached today's XP limit. Come back tomorrow!", + "ineligible_generic": "This game didn't earn XP.", + "ineligible_no_action": "No XP this time: you didn't take any actions.", + "ineligible_not_spawned": "You didn't spawn, so this game didn't earn XP.", + "ineligible_too_few_humans": "Not enough human players in this game to earn XP.", + "ineligible_too_short": "This game was too short to earn XP.", + "left_early": "You left before being eliminated, so the game bonus, placement and win XP were forfeited.", + "legend": "Legend", + "legend_reached_body": "The top of the track. {xp} lifetime XP.", + "legend_reached_title": "You are a Legend!", + "level": "Level {level}", + "level_reached": "Level {level}!", + "lifetime_xp": "{xp} lifetime XP", + "line_feats": "Feats", + "line_first_win": "First win of the day", + "line_placement": "Placement", + "line_played": "Game played", + "line_time": "Time played", + "line_win": "Win", + "max_level": "Max level", + "multiplier_game": "Game type", + "multiplier_sovereign": "Sovereign bonus", + "multiplier_subscriber": "Subscriber bonus", + "multiplier_vanguard": "Vanguard bonus", + "multiplier_warlord": "Warlord bonus", + "new_milestone": "New milestone!", + "penalty_line": "−{percent}% XP ({source})", + "prestige": "Prestige {prestige}", + "sign_in_to_earn": "Sign in to earn XP and level up.", + "tap_to_skip": "Tap to skip", + "xp_heading": "XP earned", + "xp_progress": "{current} / {next} XP", + "xp_total": "+{xp} XP" + }, "public_game_modifier": { "compact_map": "Compact Map", "crowded": "Crowded", diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts index d2dc361c22..723e9dfee2 100644 --- a/src/client/GameStatsModal.ts +++ b/src/client/GameStatsModal.ts @@ -1,9 +1,14 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { hasLinkedIdentity } from "./AccountIdentity"; +import { getUserMe } from "./Api"; import "./components/baseComponents/stats/GameInfoView"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; +import "./components/GameXpPanel"; +import type { GameXpPanelState } from "./components/GameXpPanel"; import { modalHeader } from "./components/ui/ModalHeader"; +import { fetchMyGameXp } from "./ProgressionApi"; import { translateText } from "./Utils"; @customElement("game-stats-modal") @@ -11,6 +16,9 @@ export class GameStatsModal extends BaseModal { protected routerName = "stats"; @state() private gameId: string | null = null; + // The XP the signed-in player earned in this game, if any. + @state() private xpView: GameXpPanelState = { kind: "hidden" }; + private xpGeneration = 0; private openedFrom: "account" | "clan" | "profile" | null = null; protected modalConfig() { @@ -39,6 +47,7 @@ export class GameStatsModal extends BaseModal { protected renderBody() { return html`
+
`; @@ -49,9 +58,33 @@ export class GameStatsModal extends BaseModal { typeof args?.gameID === "string" && args.gameID.length > 0 ? args.gameID : null; + this.xpView = { kind: "hidden" }; + if (this.gameId !== null) void this.loadXp(this.gameId); + } + + // One look, no polling: a past game is either processed or it never will + // be. Only an eligible result is shown — this modal also opens other + // players' games, where "you didn't spawn" would read as a verdict on them. + private async loadXp(gameId: string): Promise { + const gen = ++this.xpGeneration; + try { + const me = await getUserMe(); + if (!me || !hasLinkedIdentity(me.user)) return; + // No progress on /users/@me means progression is off. + if (me.player.progress === undefined) return; + const result = await fetchMyGameXp(gameId); + if (gen !== this.xpGeneration || this.gameId !== gameId) return; + if (result.status === "ok" && result.data.eligible) { + this.xpView = { kind: "result", data: result.data }; + } + } catch (err) { + console.warn("GameStatsModal: XP lookup failed", err); + } } protected onClose(): void { + this.xpGeneration++; + this.xpView = { kind: "hidden" }; this.gameId = null; this.openedFrom = null; } diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index f37818694d..690d6e9790 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -5,17 +5,21 @@ import { isVerifiedUsername, type PlayerProfile, type PlayerStatsTree, + type PublicProgress, } from "../core/ApiSchemas"; -import { fetchPublicPlayerProfile } from "./Api"; +import { fetchPublicPlayerProfile, getUserMe } from "./Api"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; import "./components/clan/ClanCard"; +import "./components/LevelBadge"; import "./components/PlayerName"; import { modalHeader } from "./components/ui/ModalHeader"; import { usernameText } from "./components/ui/UsernameText"; import { verifiedBadge } from "./components/ui/VerifiedBadge"; +import { formatXp } from "./components/XpBar"; +import { fetchPublicPlayerProgress } from "./ProgressionApi"; import { playerProfileUrl } from "./utilities/PlayerProfileUrl"; import { currentPagePath, translateText } from "./Utils"; @@ -32,6 +36,8 @@ export class PlayerProfileModal extends BaseModal { @state() private username: string | null = null; @state() private statsTree: PlayerStatsTree | null = null; @state() private clans: NonNullable = []; + // Level / XP, when progression is on and the player has any. + @state() private progress: PublicProgress | null = null; @state() private loading = false; private openedFrom: ProfileOrigin | null = null; // Mirrors the account modal's Games tab: keep the accumulated history list + @@ -213,12 +219,65 @@ export class PlayerProfileModal extends BaseModal { return this.renderNotFound(); } return html` + ${this.renderLevel()} `; } + // Compact level summary above the stats. Hidden without progress data. + private renderLevel() { + const progress = this.progress; + if (progress === null) return nothing; + const details = [ + progress.legend || progress.prestige === 0 + ? null + : translateText("progression.prestige", { + prestige: progress.prestige, + }), + translateText("progression.lifetime_xp", { + xp: formatXp(progress.lifetimeXp), + }), + ].filter((d): d is string => d !== null); + return html` +
+ +
+
+ ${progress.legend + ? translateText("progression.legend") + : translateText("progression.level", { level: progress.level })} +
+
${details.join(" · ")}
+
+
+ `; + } + + // Your own level comes from /users/@me (already cached for the session); + // anyone else's from the public endpoint. Either missing hides the summary. + private async loadProgress(publicId: string): Promise { + try { + const me = await getUserMe(); + if (me && me.player.publicId === publicId) { + return me.player.progress ?? null; + } + const progress = await fetchPublicPlayerProgress(publicId); + return progress === false ? null : progress; + } catch { + return null; + } + } + // False when the fetch failed (missing player, network, bad schema). private profileLoaded(): boolean { return !!this.publicId && !!this.statsTree; @@ -265,6 +324,7 @@ export class PlayerProfileModal extends BaseModal { this.username = null; this.statsTree = null; this.clans = []; + this.progress = null; this.gameHistoryCache = null; this.gamesScrollTop = 0; this.restoreGamesScrollAfterOpen = false; @@ -276,7 +336,10 @@ export class PlayerProfileModal extends BaseModal { private async loadProfile(publicId: string): Promise { const gen = ++this.loadGeneration; - const profile = await fetchPublicPlayerProfile(publicId); + const [profile, progress] = await Promise.all([ + fetchPublicPlayerProfile(publicId), + this.loadProgress(publicId), + ]); // Drop a superseded response: a newer load started, or the modal moved to a // different player. onClose no longer clears publicId, so the id check alone // can't reject a stale same-player load started before an earlier close. @@ -285,6 +348,7 @@ export class PlayerProfileModal extends BaseModal { this.statsTree = profile === false ? null : profile.stats; this.username = profile === false ? null : (profile.username ?? null); this.clans = profile === false ? [] : (profile.clans ?? []); + this.progress = profile === false ? null : progress; } // Intentionally preserves publicId/statsTree/history cache/scroll: the page diff --git a/src/client/Progression.ts b/src/client/Progression.ts new file mode 100644 index 0000000000..2871ae2889 --- /dev/null +++ b/src/client/Progression.ts @@ -0,0 +1,212 @@ +import type { XpBreakdown } from "../core/ApiSchemas"; + +// Pure helpers behind the level / XP UI. No DOM, no fetches — so the rules the +// components follow are unit-testable on their own. + +export const MAX_LEVEL = 100; +export const MAX_PRESTIGE = 10; + +/** + * The level band a badge frame is drawn from: 0 for levels 1–9, 1 for 10–19, + * … 9 for 90–99, and 10 for level 100 alone. Out-of-range input is clamped so + * a surprising server value still draws a frame. + */ +export function levelBand(level: number): number { + if (!Number.isFinite(level)) return 0; + const clamped = Math.min(MAX_LEVEL, Math.max(1, Math.floor(level))); + if (clamped >= MAX_LEVEL) return 10; + return Math.floor(clamped / 10); +} + +export function clampPrestige(prestige: number): number { + if (!Number.isFinite(prestige)) return 0; + return Math.min(MAX_PRESTIGE, Math.max(0, Math.floor(prestige))); +} + +/** + * 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. + */ +export type PrestigeTier = "none" | "ring" | "double" | "sunburst" | "radiant"; + +export function prestigeTier(prestige: number): PrestigeTier { + const p = clampPrestige(prestige); + if (p === 0) return "none"; + if (p <= 3) return "ring"; + if (p <= 6) return "double"; + if (p <= 9) return "sunburst"; + return "radiant"; +} + +// Levels called out when reached: the same set the level-up screen offers a +// share button on. +export const MILESTONE_LEVELS: readonly number[] = [10, 25, 50, 75, 100]; + +export function isMilestoneLevel(level: number): boolean { + return MILESTONE_LEVELS.includes(level); +} + +interface LevelReachedLike { + prestige: number; + level: number; +} + +/** + * The levels one game crossed, for the level-up celebration. A game never + * changes prestige (that is an opt-in reset, done from the profile), so an + * entry at another prestige is dropped rather than celebrated as a level-up. + */ +export function levelsReachedInGame(data: { + before: { prestige: number }; + levelsReached: readonly LevelReachedLike[]; +}): LevelReachedLike[] { + return data.levelsReached.filter((l) => l.prestige === data.before.prestige); +} + +/** True when this game is the one that made the player a Legend. */ +export function reachedLegendThisGame(data: { + before: { prestige: number }; + after: { legend: boolean }; + levelsReached: readonly LevelReachedLike[]; +}): boolean { + return ( + data.after.legend && + levelsReachedInGame(data).some((l) => l.level === MAX_LEVEL) + ); +} + +/** + * How far through the current level, 0..1. A level with nothing left to earn + * (level 100, `xpForNext` 0) reads as full. + */ +export function levelFraction(xpInLevel: number, xpForNext: number): number { + if (!(xpForNext > 0)) return 1; + const fraction = xpInLevel / xpForNext; + if (!Number.isFinite(fraction)) return 0; + return Math.min(1, Math.max(0, fraction)); +} + +export type SubscriberTier = "sovereign" | "warlord" | "vanguard"; + +// The subscription tiers' XP boosts, highest first, in their Discord role +// colours (a gradient, from → to). The game is told only the multiplier, so +// the tier is recognised by it: these must match the multipliers set on the +// tiers in the admin panel. A multiplier not listed here still shows, as a +// plain subscriber bonus. +export const SUBSCRIBER_TIERS: readonly { + tier: SubscriberTier; + permille: number; + colors: readonly [string, string]; +}[] = [ + { tier: "sovereign", permille: 1500, colors: ["#ffc713", "#ffde90"] }, + { tier: "warlord", permille: 1300, colors: ["#4cadd0", "#b2f9ff"] }, + { tier: "vanguard", permille: 1200, colors: ["#369876", "#71ff9e"] }, +]; + +/** The subscription tier a subscriber multiplier belongs to, if it's one. */ +export function subscriberTierOf(permille: number) { + return SUBSCRIBER_TIERS.find((t) => t.permille === permille) ?? null; +} + +/** A permille multiplier as a percentage change: 1200 → 20, 500 → -50. */ +export function multiplierPercent(permille: number): number { + return Math.round(permille - 1000) / 10; +} + +export type XpLineKey = + | "played" + | "time" + | "placement" + | "win" + | "firstWin" + | "feats"; + +export const XP_LINE_KEYS: readonly XpLineKey[] = [ + "played", + "time", + "placement", + "win", + "firstWin", + "feats", +]; + +/** The breakdown lines worth showing: the non-zero ones, in a fixed order. */ +export function visibleXpLines( + breakdown: XpBreakdown, +): { key: XpLineKey; amount: number }[] { + return XP_LINE_KEYS.map((key) => ({ key, amount: breakdown[key] })).filter( + (line) => line.amount !== 0, + ); +} + +/** The multipliers worth showing: anything other than 1x. */ +export function visibleMultipliers( + breakdown: XpBreakdown, +): { key: "game" | "subscriber"; permille: number }[] { + const out: { key: "game" | "subscriber"; permille: number }[] = []; + if (breakdown.gamePermille !== 1000) { + out.push({ key: "game", permille: breakdown.gamePermille }); + } + if (breakdown.subscriberPermille !== 1000) { + out.push({ key: "subscriber", permille: breakdown.subscriberPermille }); + } + return out; +} + +/** + * Splits `total` across `amounts` in proportion, as whole numbers that add up + * to exactly `total` (largest remainder). It is how the breakdown shows each + * source's share of a multiplied award without the cards disagreeing with the + * total. + */ +export function apportionXp( + amounts: readonly number[], + total: number, +): number[] { + const sum = amounts.reduce((a, b) => a + Math.max(0, b), 0); + if (sum <= 0 || total <= 0) return amounts.map(() => 0); + const raw = amounts.map((a) => (Math.max(0, a) * total) / sum); + const out = raw.map((r) => Math.floor(r)); + let left = total - out.reduce((a, b) => a + b, 0); + const order = raw + .map((r, i) => ({ i, frac: r - out[i] })) + .sort((a, b) => b.frac - a.frac || a.i - b.i); + for (const { i } of order) { + if (left <= 0) break; + out[i]++; + left--; + } + return out; +} + +// Ineligibility reasons with their own copy. Anything else — including reasons +// the API adds later — gets the generic line. Keys spelled out in full so the +// translation checks can see them. +const INELIGIBLE_REASON_KEYS: Record = { + not_spawned: "progression.ineligible_not_spawned", + too_short: "progression.ineligible_too_short", + no_action: "progression.ineligible_no_action", + custom_settings: "progression.ineligible_custom_settings", + too_few_humans: "progression.ineligible_too_few_humans", + daily_cap: "progression.ineligible_daily_cap", +}; + +export function ineligibleReasonKey(reason: string): string { + return Object.prototype.hasOwnProperty.call(INELIGIBLE_REASON_KEYS, reason) + ? INELIGIBLE_REASON_KEYS[reason] + : "progression.ineligible_generic"; +} + +// Reward reasons the progression system grants (RewardSchema.reason). +const LEVEL_REWARD_REASON_KEYS: Record = { + level_up: "account_modal.reward_level_up", + level_milestone: "account_modal.reward_level_milestone", + prestige: "account_modal.reward_prestige", +}; + +export function levelRewardReasonKey(reason: string): string | undefined { + return Object.prototype.hasOwnProperty.call(LEVEL_REWARD_REASON_KEYS, reason) + ? LEVEL_REWARD_REASON_KEYS[reason] + : undefined; +} diff --git a/src/client/ProgressionApi.ts b/src/client/ProgressionApi.ts new file mode 100644 index 0000000000..14475e905e --- /dev/null +++ b/src/client/ProgressionApi.ts @@ -0,0 +1,169 @@ +import { + type GameXpResponse, + GameXpResponseSchema, + type ProgressionConfig, + ProgressionConfigSchema, + type PublicProgress, + PublicProgressSchema, +} from "../core/ApiSchemas"; +// ApiBase rather than Api: this module is imported by in-game UI (WinModal) +// whose tests stub Api wholesale. +import { getApiBase } from "./ApiBase"; +import { getAuthHeader } from "./Auth"; + +// Player levels / XP. Every helper here fails closed: progression is purely +// cosmetic, so any error hides the UI rather than surfacing a failure. + +export type GameXpFetchResult = + | { status: "ok"; data: GameXpResponse } + // 404 (not processed yet, or progression off) or a transient failure — + // worth asking again while the caller is still polling. + | { status: "pending" } + // A conclusion: signed out, or a body we cannot read. Stop asking. + | { status: "unavailable" }; + +// GET /users/@me/xp/:gameId — the XP the caller earned in one game. +export async function fetchMyGameXp( + gameId: string, +): Promise { + try { + const authorization = await getAuthHeader(); + if (authorization === "") return { status: "unavailable" }; + const res = await fetch( + `${getApiBase()}/users/@me/xp/${encodeURIComponent(gameId)}`, + { + headers: { Accept: "application/json", Authorization: authorization }, + signal: AbortSignal.timeout(10_000), + }, + ); + if (res.status === 404) return { status: "pending" }; + if (res.status === 401 || res.status === 403) { + return { status: "unavailable" }; + } + if (!res.ok) { + console.warn("fetchMyGameXp: unexpected status", res.status); + return { status: "pending" }; + } + const parsed = GameXpResponseSchema.safeParse(await res.json()); + if (!parsed.success) { + console.warn("fetchMyGameXp: Zod validation failed", parsed.error); + return { status: "unavailable" }; + } + return { status: "ok", data: parsed.data }; + } catch (err) { + console.warn("fetchMyGameXp: request failed", err); + return { status: "pending" }; + } +} + +export interface PollGameXpOptions { + intervalMs?: number; + timeoutMs?: number; + signal?: AbortSignal; + // Injectable for tests. + fetcher?: (gameId: string) => Promise; +} + +/** + * Asks for a game's XP until it is processed, the deadline passes, or the + * caller aborts. Resolves to the response, or null when there is nothing to + * show (timed out, signed out, unreadable, aborted) — never a zero. + */ +export async function pollGameXp( + gameId: string, + opts: PollGameXpOptions = {}, +): Promise { + const intervalMs = opts.intervalMs ?? 3_000; + const timeoutMs = opts.timeoutMs ?? 60_000; + const fetcher = opts.fetcher ?? fetchMyGameXp; + const deadline = Date.now() + timeoutMs; + for (;;) { + if (opts.signal?.aborted) return null; + const result = await fetcher(gameId); + if (opts.signal?.aborted) return null; + if (result.status === "ok") return result.data; + if (result.status === "unavailable") return null; + if (Date.now() + intervalMs > deadline) return null; + await new Promise((resolve) => { + const timer = setTimeout(resolve, intervalMs); + opts.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + } +} + +// GET /public/player/:publicId/progress — another player's level. False on a +// 404 (unknown player, progression off) and every other failure. +export async function fetchPublicPlayerProgress( + publicId: string, +): Promise { + try { + const res = await fetch( + `${getApiBase()}/public/player/${encodeURIComponent(publicId)}/progress`, + { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }, + ); + if (res.status === 404) return false; + if (!res.ok) { + console.warn("fetchPublicPlayerProgress: unexpected status", res.status); + return false; + } + const parsed = PublicProgressSchema.safeParse(await res.json()); + if (!parsed.success) { + console.warn( + "fetchPublicPlayerProgress: Zod validation failed", + parsed.error, + ); + return false; + } + return parsed.data; + } catch (err) { + console.warn("fetchPublicPlayerProgress: request failed", err); + return false; + } +} + +let progressionConfig: Promise | null = null; + +// GET /public/progression/config — the level curve, and whether progression is +// on at all (404 when it is off). Memoised for the page's lifetime; a failure +// is not, so a later caller can try again. +export function fetchProgressionConfig(): Promise { + if (progressionConfig !== null) return progressionConfig; + const request = (async (): Promise => { + try { + const res = await fetch(`${getApiBase()}/public/progression/config`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) return false; + const parsed = ProgressionConfigSchema.safeParse(await res.json()); + if (!parsed.success) { + console.warn( + "fetchProgressionConfig: Zod validation failed", + parsed.error, + ); + return false; + } + return parsed.data; + } catch (err) { + console.warn("fetchProgressionConfig: request failed", err); + return false; + } + })(); + progressionConfig = request; + void request.then((config) => { + if (config === false && progressionConfig === request) { + progressionConfig = null; + } + }); + return request; +} diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts new file mode 100644 index 0000000000..d5244dcab6 --- /dev/null +++ b/src/client/components/GameXpPanel.ts @@ -0,0 +1,1452 @@ +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 { + apportionXp, + ineligibleReasonKey, + isMilestoneLevel, + levelFraction, + levelsReachedInGame, + MAX_LEVEL, + multiplierPercent, + reachedLegendThisGame, + subscriberTierOf, + visibleMultipliers, + visibleXpLines, + type XpLineKey, +} from "../Progression"; +import { translateText } from "../Utils"; +import "./LevelBadge"; +import { formatXp, xpBar, xpProgressText } from "./XpBar"; + +// What the XP section of a finished game is showing. The caller owns the +// fetching; this element only renders, so every state can be set directly. +export type GameXpPanelState = + // Nothing to show: progression off, a spectator, timed out, a failure. + | { kind: "hidden" } + // Not signed in: XP needs an account. + | { kind: "signed_out" } + // The player died but the game is still running; XP comes at the end. + | { kind: "awaiting_end" } + // Waiting for the server to process the game. Never shows a number. + | { kind: "calculating" } + | { kind: "result"; data: GameXpResponse }; + +const LINE_LABEL_KEYS: Record = { + played: "progression.line_played", + time: "progression.line_time", + placement: "progression.line_placement", + win: "progression.line_win", + firstWin: "progression.line_first_win", + feats: "progression.line_feats", +}; + +// Segments the big bar is drawn in. The gaps are decoration: the fill is one +// continuous sweep underneath them. +const BAR_SEGMENTS = 10; + +// Reveal pacing, in ms. +const START_MS = 300; +const CAPTION_LEAD_MS = 250; // a caption lands before the bar moves +const CLIMB_MS = 450; // the bar's climb for one XP source (split across legs) +const MIN_LEG_MS = 140; // no leg of a climb is quicker than this +const GAP_MS = 300; // after a source's card lands, before the next caption +const LEVEL_UP_MS = 1100; // the pause on each level reached +const LEGEND_MS = 1900; // the pause on becoming a Legend +const MULTIPLIER_LEAD_MS = 450; // a multiplier caption slams in +const WIPE_MS = 1000; // a multiplier's wipe across the cards, first to last +const WIPE_CARD_SHARE = 0.4; // how much of the wipe each card takes + +// When the wipe reaches card `i` of `n`, as a fraction of the wipe. +function wipeStart(i: number, n: number): number { + return n > 1 ? (i * (1 - WIPE_CARD_SHARE)) / (n - 1) : 0; +} +const MULTIPLIER_HOLD_MS = 400; +const PAYOFF_MS = 900; + +// The caption step the panel rests on once the reveal is over. +const REST_STEP = -1; + +function prefersReducedMotion(): boolean { + try { + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + +// The caption over the bar: one XP source at a time, then each multiplier, +// then the payoff. At rest it is the payoff, or just the heading; the number +// lives in the counter under the bar, never in two places. +type Caption = + | { kind: "line"; key: XpLineKey; amount: number } + | { kind: "multiplier"; key: "game" | "subscriber"; permille: number } + | { kind: "heading" } + | { kind: "level_up"; level: number } + | { kind: "legend" }; + +// One frame of the reveal. +interface RevealFrame { + // Increments with every caption change; keys the roll-in animation. + step: number; + caption: Caption | null; + // Bar position in level units: 12.25 is a quarter of the way through level + // 12. The fill is read off it. + position: number; + // The level shown at the end of the bar. Separate from `position` so the + // level ticks over at the level-up moment, while the bar is still full. + level: number; + // Set during the pause on a level reached: the level-up moment. + levelUp: number | null; + // Set from the moment the player becomes a Legend. + legend: boolean; + // The earned-XP counter under the bar, counting up. + counted: number; + // Which XP-in-level text to show under the level: before the game, none + // mid-way (the sizes of the levels in between are not known here), after. + xpText: "before" | "none" | "after"; + // The cards below the bar: how many source and multiplier cards have + // landed, and the value on each source card (a multiplier changes them). + linesShown: number; + multipliersShown: number; + lineValues: number[]; + // A multiplier being applied to the cards (its wipe): a boost or a cut. + applying: "boost" | "cut" | 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) { + if (level >= MAX_LEVEL) return MAX_LEVEL; + return level + levelFraction(xpInLevel, xpForNext); +} + +// The source cards' final values: the award split across them, so a +// multiplied award shows on each card and the cards add up to it. +function finalLineValues(data: GameXpEligible): number[] { + const lines = visibleXpLines(data.breakdown); + return apportionXp( + lines.map((l) => l.amount), + data.breakdown.total, + ); +} + +@customElement("game-xp-panel") +export class GameXpPanel extends LitElement { + @property({ attribute: false }) view: GameXpPanelState = { kind: "hidden" }; + // Past-game view (GameStatsModal): a static summary, no reveal. + @property({ type: Boolean }) compact = false; + + // The reveal in progress, or null once it has finished (or when there is + // none: the compact view, reduced motion, a skip). Null renders the final + // state, which is also where the reveal ends. + @state() private reveal: RevealFrame | null = null; + @state() private barAnimate = false; + @state() private barDurationMs = CLIMB_MS; + private animationToken = 0; + private timers: ReturnType[] = []; + + createRenderRoot() { + return this; + } + + disconnectedCallback(): void { + this.clearTimers(); + super.disconnectedCallback(); + } + + protected willUpdate(changed: PropertyValues): void { + if (!changed.has("view") && !changed.has("compact")) return; + const s = this.view; + if (s.kind === "result" && s.data.eligible) { + this.startReveal(s.data); + } else { + this.clearTimers(); + this.reveal = null; + } + } + + private clearTimers(): void { + this.animationToken++; + for (const t of this.timers) clearTimeout(t); + this.timers = []; + } + + private later(ms: number, fn: () => void): void { + this.timers.push(setTimeout(fn, ms)); + } + + // The reveal. For each XP source a caption rolls in over the bar ("TIME + // PLAYED +340 XP"), the bar climbs by that much, the counter under it + // counts up, and the source's card lands in the grid below. When the bar + // reaches the end of a level, everything stops for the level-up moment: + // the bar flashes, the caption becomes "LEVEL 24!", the badge at the end + // pops with a burst, and the level-up card appears. Then the bar empties + // and the climb carries on. Each multiplier then slams in and is applied to + // the cards on screen: a wipe runs across them, first to last, turning each + // value over to its multiplied share (340 → 408) as it passes, with the + // counter and bar going up with it; the bonus line then lands under the + // counter. Becoming a Legend is its own, longer moment. Tapping skips to + // the end. + private startReveal(data: GameXpEligible): void { + this.clearTimers(); + if (this.compact || prefersReducedMotion()) { + this.reveal = null; + return; + } + const token = this.animationToken; + const at = (ms: number, fn: () => void) => + this.later(ms, () => { + if (token === this.animationToken && this.reveal !== null) fn(); + }); + const patch = (next: Partial) => { + if (this.reveal !== null) this.reveal = { ...this.reveal, ...next }; + }; + let stepCount = 0; + const showCaption = (ms: number, caption: Caption) => { + const step = ++stepCount; + at(ms, () => patch({ step, caption, levelUp: null })); + }; + + const { breakdown } = data; + const lines = visibleXpLines(breakdown); + const faces = lines.map((l) => l.amount); + const multipliers = visibleMultipliers(breakdown); + const start = positionOf( + data.before.level, + data.before.xpInLevel, + data.before.xpForNext, + ); + const end = positionOf( + data.after.level, + data.after.xpInLevel, + data.after.xpForNext, + ); + // The bar climbs in proportion to XP. When the multipliers cut the award + // (private and singleplayer games), each source moves the bar its scaled + // share, so the bar never goes backwards; when they boost it, the sources + // move it their face value and each multiplier adds its part. + const total = breakdown.total; + const subtotal = Math.max(1, breakdown.subtotal); + const scale = total < subtotal ? total / subtotal : 1; + const positionAfterXp = (xp: number) => + total <= 0 ? start : start + ((end - start) * xp) / total; + + this.barAnimate = false; + this.reveal = { + step: 0, + caption: null, + position: start, + level: Math.min(MAX_LEVEL, Math.floor(start)), + levelUp: null, + legend: false, + counted: 0, + xpText: "before", + linesShown: 0, + multipliersShown: 0, + lineValues: [...faces], + applying: null, + }; + + // Schedules the bar's climb from where it is to `to` starting at `t`, + // with a level-up moment at every boundary it crosses. `progress` gives + // the frame changes (counter, card values) for how far through the climb + // it is, 0 to 1; they run across the climbing legs, not the pauses. A + // climb that doesn't move the bar still runs its progress when `hold`. + // `totalMs` is how long the climbing takes, pauses aside. Returns when it + // ends. + let position = start; + const climb = ( + t: number, + to: number, + caption: Caption, + progress: (f: number) => Partial, + hold = false, + totalMs = CLIMB_MS, + ): number => { + const from = position; + const runProgress = ( + legT: number, + ms: number, + f0: number, + f1: number, + ) => { + const steps = Math.max(2, Math.round(ms / 40)); + for (let i = 1; i <= steps; i++) { + const f = f0 + ((f1 - f0) * i) / steps; + at(legT + (ms * i) / steps, () => patch(progress(f))); + } + }; + if (to <= from) { + if (!hold) return t; + runProgress(t, totalMs, 0, 1); + return t + totalMs; + } + position = to; + const boundaries: number[] = []; + for (let b = Math.floor(from) + 1; b <= Math.floor(to); b++) { + if (b <= MAX_LEVEL) boundaries.push(b); + } + const distance = to - from; + const legMs = (a: number, b: number) => + Math.max(MIN_LEG_MS, Math.round((totalMs * (b - a)) / distance)); + + let cursor = from; + for (const b of boundaries) { + const ms = legMs(cursor, b); + runProgress(t, ms, (cursor - from) / distance, (b - from) / distance); + at(t, () => { + this.barDurationMs = ms; + this.barAnimate = true; + patch({ position: b - 1e-6, xpText: "none" }); + }); + t += ms; + // The level-up moment: the bar is full, the level ticks over. + const reached = b; + const legend = reached >= MAX_LEVEL && data.after.legend; + at(t, () => { + this.barAnimate = false; + patch({ + level: Math.min(MAX_LEVEL, reached), + levelUp: reached, + legend, + }); + }); + t += legend ? LEGEND_MS : LEVEL_UP_MS; + // Empty the bar and carry on with the rest of this step. + const resume = stepCount + 1; + stepCount = resume; + at(t, () => { + this.barAnimate = false; + patch({ + position: reached, + levelUp: null, + step: resume, + caption, + }); + }); + t += 60; + cursor = b; + } + if (to > cursor) { + const ms = legMs(cursor, to); + runProgress(t, ms, (cursor - from) / distance, 1); + at(t, () => { + this.barDurationMs = ms; + this.barAnimate = true; + patch({ position: to }); + }); + t += ms; + } + return t; + }; + + let t = START_MS; + let barXp = 0; + let counted = 0; + lines.forEach((line, i) => { + const caption: Caption = { + kind: "line", + key: line.key, + amount: line.amount, + }; + showCaption(t, caption); + t += CAPTION_LEAD_MS; + barXp = Math.min(total, barXp + line.amount * scale); + const countFrom = counted; + counted += line.amount; + const countTo = counted; + t = climb(t, positionAfterXp(barXp), caption, (f) => ({ + counted: Math.round(countFrom + (countTo - countFrom) * f), + })); + // The caption is done with: its card lands below. + at(t, () => patch({ counted: countTo, linesShown: i + 1 })); + t += GAP_MS; + }); + + // Each multiplier applied to the cards on screen, in turn. The value + // after the last one is the award, exactly. + let values = [...faces]; + let product = 1; + multipliers.forEach((m, k) => { + const caption: Caption = { + kind: "multiplier", + key: m.key, + permille: m.permille, + }; + showCaption(t, caption); + t += MULTIPLIER_LEAD_MS; + product *= m.permille / 1000; + const stageTotal = + k === multipliers.length - 1 ? total : Math.round(subtotal * product); + const fromValues = values; + const toValues = apportionXp(faces, stageTotal); + values = toValues; + const countFrom = counted; + counted = stageTotal; + const applying = m.permille > 1000 ? "boost" : "cut"; + at(t, () => patch({ applying })); + barXp = Math.max(barXp, Math.min(total, stageTotal)); + // A wipe runs across the cards, first to last; each card's value + // turns over to its multiplied share as the wipe crosses it. + const n = faces.length; + const climbEnd = climb( + t, + positionAfterXp(barXp), + caption, + (f) => ({ + counted: Math.round(countFrom + (stageTotal - countFrom) * f), + lineValues: fromValues.map((v, i) => { + const fi = Math.min( + 1, + Math.max(0, (f - wipeStart(i, n)) / WIPE_CARD_SHARE), + ); + return Math.round(v + (toValues[i] - v) * fi); + }), + }), + true, + WIPE_MS, + ); + t = Math.max(climbEnd, t + WIPE_MS); + at(t, () => + patch({ + counted: stageTotal, + lineValues: toValues, + multipliersShown: k + 1, + applying: null, + }), + ); + t += MULTIPLIER_HOLD_MS; + }); + + // Land exactly on the award, whatever rounding happened on the way. + const last: Caption | null = + multipliers.length > 0 + ? { + kind: "multiplier", + key: multipliers[multipliers.length - 1].key, + permille: multipliers[multipliers.length - 1].permille, + } + : null; + const settleFrom = counted; + t = climb(t, end, last ?? { kind: "heading" }, (f) => ({ + counted: Math.round(settleFrom + (total - settleFrom) * f), + })); + const finalValues = finalLineValues(data); + at(t, () => + patch({ counted: total, lineValues: finalValues, xpText: "after" }), + ); + t += GAP_MS; + + // The payoff caption is the one the panel rests on, so it takes the + // resting step: when the reveal ends it stays the same element instead + // of rolling in a second time. + const payoff = this.payoffCaption(data); + if (payoff !== null) { + at(t, () => patch({ step: REST_STEP, caption: payoff, levelUp: null })); + t += PAYOFF_MS; + } + at(t, () => { + this.barAnimate = false; + this.reveal = null; + }); + } + + private skipReveal(): void { + if (this.reveal === null) return; + this.clearTimers(); + this.barAnimate = false; + this.reveal = null; + } + + private payoffCaption(data: GameXpEligible): Caption | null { + if (reachedLegendThisGame(data)) return { kind: "legend" }; + const reached = levelsReachedInGame(data); + if (reached.length === 0) return null; + return { kind: "level_up", level: reached[reached.length - 1].level }; + } + + // The colours of a multiplier's bonus: a subscription tier's own (see + // SUBSCRIBER_TIERS), or null for the default lime. + private accentOf(m: { + key: "game" | "subscriber"; + permille: number; + }): readonly [string, string] | null { + if (m.key !== "subscriber") return null; + return subscriberTierOf(m.permille)?.colors ?? null; + } + + // The bonus colours the panel is showing: those of the multiplier being + // applied, mid-reveal; otherwise the subscription tier's, if the game had + // a tier's boost. + private panelAccent(data: GameXpEligible): string | undefined { + const multipliers = visibleMultipliers(data.breakdown); + const r = this.reveal; + const current = + r !== null && r.applying !== null + ? multipliers[r.multipliersShown] + : multipliers.find((m) => this.accentOf(m) !== null); + const accent = current === undefined ? null : this.accentOf(current); + return accent === null + ? undefined + : `--accent-a: ${accent[0]}; --accent-b: ${accent[1]}`; + } + + private frame( + content: TemplateResult, + stateName: string, + style?: string, + ): TemplateResult { + const revealing = this.reveal !== null; + return html`
this.skipReveal()} + > + ${content} +
`; + } + + private renderNote(key: string, stateName: string): TemplateResult { + return this.frame( + html`

${translateText(key)}

`, + stateName, + ); + } + + private renderCalculating(): TemplateResult { + return this.frame( + html`
+ + ${translateText("progression.calculating")} +
`, + "calculating", + ); + } + + // --------------------------------------------------------------------------- + // Full panel (WinModal): rolling caption, segmented bar, counter, level. + // --------------------------------------------------------------------------- + + private captionContent(caption: Caption): TemplateResult { + switch (caption.kind) { + case "line": + return html`${translateText(LINE_LABEL_KEYS[caption.key])} + ${translateText("progression.xp_total", { + xp: formatXp(caption.amount), + })}`; + case "multiplier": + return html`${this.bonusText(caption.key, caption.permille)}`; + case "heading": + return html`${translateText("progression.xp_heading")}`; + case "level_up": + return html`${translateText("progression.level_reached", { + level: caption.level, + })}`; + case "legend": + return html`${translateText("progression.legend_reached_title")}`; + } + } + + private renderCaption(data: GameXpEligible): TemplateResult { + const r = this.reveal; + const levelUp = r?.levelUp ?? null; + let content: TemplateResult | typeof nothing = nothing; + let key: string; + if (levelUp !== null && r?.legend) { + // Becoming a Legend: the biggest moment on the track. + key = "legend"; + content = html`
+ ${translateText("progression.legend_reached_title")} +
`; + } else if (levelUp !== null) { + // The level-up moment: a big "LEVEL 24!" that slams in. + key = `lvl-${levelUp}`; + content = html`
+ ${translateText("progression.level_reached", { level: levelUp })} +
`; + } else { + key = `s-${r?.step ?? REST_STEP}`; + // After the reveal the caption rests on the payoff, or the heading. + const caption = + r === null + ? (this.payoffCaption(data) ?? { kind: "heading" as const }) + : r.caption; + if (caption?.kind === "multiplier") { + // A multiplier slams in, and a wipe runs through it: lime for a + // bonus, blue for a cut. + const boost = caption.permille > 1000; + content = html`
+ ${this.captionContent(caption)} +
`; + } else if (caption !== null) { + content = html`
+ ${this.captionContent(caption)} +
`; + } + } + // A fresh element per step (or per level reached), so each replays its + // entrance animation. + return html`
+ ${keyed(key, content)} +
`; + } + + private renderSegmentedBar( + fillPercent: number, + flashing: boolean, + ): TemplateResult { + return html`
+
+ ${Array.from( + { length: BAR_SEGMENTS - 1 }, + (_, i) => + html``, + )} +
`; + } + + private renderFull(data: GameXpEligible): TemplateResult { + const { after } = data; + const r = this.reveal; + const position = + r?.position ?? positionOf(after.level, after.xpInLevel, after.xpForNext); + const level = r?.level ?? Math.min(MAX_LEVEL, Math.floor(position)); + const legend = r === null ? after.legend : r.legend; + const barLevel = Math.floor(position); + const fill = + position >= MAX_LEVEL ? 100 : Math.max(0, (position - barLevel) * 100); + const levelUp = r?.levelUp ?? null; + const progressText = + r === null || r.xpText === "after" + ? xpProgressText(after.xpInLevel, after.xpForNext) + : r.xpText === "before" + ? xpProgressText(data.before.xpInLevel, data.before.xpForNext) + : ""; + const counted = r?.counted ?? data.breakdown.total; + const applying = r?.applying ?? null; + // The bar runs from the level the player is on (left) to the next one + // (right). At a level-up moment the right badge is the level being + // reached, popping; after it, both move on. At level 100 there is no next + // level: the left badge is 100 (the crown for a Legend) and the right + // slot is empty. + const prestige = r !== null ? data.before.prestige : after.prestige; + const current = levelUp !== null ? levelUp - 1 : level; + const next = levelUp ?? (level < MAX_LEVEL ? level + 1 : null); + const levelBadge = (lvl: number, isLegend: boolean) => + html``; + // A badge that changes during the reveal settles in, rather than + // swapping in place. + const settle = r !== null ? "xp-badge-in" : ""; + + return this.frame( + html` + ${this.renderStyles()} ${this.renderCaption(data)} + +
+ +
+ ${keyed( + `${current}-${legend && current >= MAX_LEVEL}`, + html`${levelBadge(current, legend && current >= MAX_LEVEL)}`, + )} +
+
+ ${this.renderSegmentedBar(fill, levelUp !== null)} +
+ + + ${translateText("progression.xp_total", { + xp: formatXp(counted), + })} + + + ${progressText} + +
+
+
+ ${levelUp !== null + ? keyed( + `${levelUp}-${legend}`, + html``, + ) + : nothing} + ${next !== null + ? keyed( + `${next}-${levelUp !== null}-${legend}`, + html`${levelBadge( + next, + levelUp !== null && legend && next >= MAX_LEVEL, + )}`, + ) + : nothing} +
+
+ ${this.renderBonuses(data)} +
${this.renderRecap(data)}
+ `, + "result", + this.panelAccent(data), + ); + } + + private renderStyles(): TemplateResult { + return html``; + } + + // Under the bar: the milestone card and a card per XP source. The source + // cards' slots are laid out from the start (they are coming either way) and + // fill in as the reveal gets to them. What might not come (a milestone, a + // bonus, the left-early note) takes no space until it does: then its space + // slides open and it pops in, so the empty space never gives it away. + private renderRecap(data: GameXpEligible): TemplateResult { + const r = this.reveal; + return html` + ${this.renderLevelUp(data)} ${this.renderBreakdown(data)} + ${data.breakdown.leftEarly + ? this.collapsible( + r === null, + html`

+ ${translateText("progression.left_early")} +

`, + ) + : nothing} + `; + } + + // Classes for a slot that waits for the reveal: hidden until it lands, then + // animated in. + private slotClass(shown: boolean): string { + if (this.reveal === null) return ""; + return shown ? "xp-card-in" : "invisible"; + } + + // The same for a slot inside a collapsible: it pops in once its space has + // slid open. + private slideSlotClass(shown: boolean): string { + if (this.reveal === null) return ""; + return shown ? "xp-card-in xp-after-slide" : "invisible"; + } + + // Space that is closed until `open`, then slides open (the content below + // moves down with it). Closed, the content is laid out but clipped to + // nothing. + private collapsible(open: boolean, content: TemplateResult): TemplateResult { + return html`
+
${content}
+
`; + } + + // The one game that makes a player a Legend gets its own moment instead of + // the ordinary level-up card: it is the end of the whole track. + private renderLegendMoment(data: GameXpEligible): TemplateResult { + const shown = this.reveal === null || this.reveal.legend; + return this.collapsible( + shown, + html`
+ +
+
+ ${translateText("progression.legend")} +
+
+ ${translateText("progression.legend_reached_body", { + xp: formatXp(data.after.lifetimeXp), + })} +
+
+
`, + ); + } + + // A card for a milestone reached this game (10, 25, 50, 75), or the Legend + // 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)] + .reverse() + .find((lvl) => isMilestoneLevel(lvl.level)); + if (milestone === undefined) return nothing; + const r = this.reveal; + const shown = r === null || r.level >= milestone.level; + return this.collapsible( + shown, + html`
+ + + +
+
+ ${translateText("progression.new_milestone")} +
+
+ ${translateText("progression.level", { level: milestone.level })} +
+
+
`, + ); + } + + // 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 + // elements, so their animations don't replace each other. + private renderXpCard( + label: string, + value: string, + classes: string, + attrs: { line?: XpLineKey; shown: boolean; style?: string }, + ): TemplateResult { + return html`
+
+
+ ${label} +
+
+ ${value} +
+
+
`; + } + + // A multiplier as a line of its own, the way Overwatch shows a group + // bonus: "+50% XP (SOVEREIGN BONUS)", "+20% XP (SUBSCRIBER BONUS)" for a + // boost from no known tier, or "−50% XP (GAME TYPE)" for a cut. + private bonusText(key: "game" | "subscriber", permille: number): string { + const percent = multiplierPercent(permille); + const tier = key === "subscriber" ? subscriberTierOf(permille) : null; + return translateText( + percent >= 0 ? "progression.bonus_line" : "progression.penalty_line", + { + percent: Math.abs(percent), + source: translateText(`progression.multiplier_${tier?.tier ?? key}`), + }, + ); + } + + // The multiplier lines under the counter. During the reveal their row + // slides open when the first multiplier's wipe has crossed the cards, and + // each line pops in as its multiplier lands. + private renderBonuses(data: GameXpEligible): TemplateResult | typeof nothing { + const multipliers = visibleMultipliers(data.breakdown); + if (multipliers.length === 0) return nothing; + const r = this.reveal; + return this.collapsible( + r === null || r.multipliersShown > 0, + html`
+ ${multipliers.map((m, k) => { + const shown = r === null || k < r.multipliersShown; + // Each line in its own bonus's colours. + const accent = this.accentOf(m); + return html`${this.bonusText(m.key, m.permille)}`; + })} +
`, + ); + } + + private renderBreakdown(data: GameXpEligible): TemplateResult { + const lines = visibleXpLines(data.breakdown); + if (lines.length === 0) return html``; + const r = this.reveal; + const values = r?.lineValues ?? finalLineValues(data); + const applying = r?.applying ?? null; + return html`
+ ${lines.map((line, i) => { + const shown = r === null || i < r.linesShown; + // Once a multiplier has turned a card over it keeps the colour: + // lime for a bonus, blue for a cut. + const tone = + values[i] > line.amount + ? "xp-card-boosted" + : values[i] < line.amount + ? "xp-card-cut" + : ""; + // During a multiplier the wipe crosses the cards in order. + const wiping = applying !== null && shown; + const wipe = wiping + ? `xp-card-wipe ${applying === "cut" ? "xp-wipe-cut" : ""}` + : ""; + const style = wiping + ? `--wipe-delay: ${Math.round(wipeStart(i, lines.length) * WIPE_MS)}ms; --wipe-ms: ${Math.round(WIPE_CARD_SHARE * WIPE_MS)}ms` + : undefined; + return this.renderXpCard( + translateText(LINE_LABEL_KEYS[line.key]), + translateText("progression.xp_total", { xp: formatXp(values[i]) }), + `text-yellow-300 border-yellow-400/20 ${tone} ${wipe}`, + { line: line.key, shown, style }, + ); + })} +
`; + } + + // --------------------------------------------------------------------------- + // Compact panel (GameStatsModal, a past game): a static summary. + // --------------------------------------------------------------------------- + + private renderCompact(data: GameXpEligible): TemplateResult { + const { after } = data; + return this.frame( + html` + ${this.renderStyles()} +
+
+ +
+
+ ${after.legend + ? translateText("progression.legend") + : translateText("progression.level", { level: after.level })} +
+
+ ${xpProgressText(after.xpInLevel, after.xpForNext)} +
+
+
+
+
+ ${translateText("progression.xp_heading")} +
+
+ ${translateText("progression.xp_total", { + xp: formatXp(data.breakdown.total), + })} +
+
+
+
+ ${xpBar(levelFraction(after.xpInLevel, after.xpForNext) * 100)} +
+ ${this.renderBonuses(data)} ${this.renderBreakdown(data)} + ${data.breakdown.leftEarly + ? html`

+ ${translateText("progression.left_early")} +

` + : nothing} + `, + "result", + this.panelAccent(data), + ); + } + + render() { + const s = this.view; + switch (s.kind) { + case "hidden": + return nothing; + case "signed_out": + return this.renderNote("progression.sign_in_to_earn", "signed_out"); + case "awaiting_end": + return this.renderNote("progression.awaiting_end", "awaiting_end"); + case "calculating": + return this.renderCalculating(); + case "result": + if (!s.data.eligible) { + return this.renderNote( + ineligibleReasonKey(s.data.reason), + "ineligible", + ); + } + return this.compact + ? this.renderCompact(s.data) + : this.renderFull(s.data); + } + } +} diff --git a/src/client/components/LevelBadge.ts b/src/client/components/LevelBadge.ts new file mode 100644 index 0000000000..1432b7d2ee --- /dev/null +++ b/src/client/components/LevelBadge.ts @@ -0,0 +1,378 @@ +import { html, LitElement, nothing, svg, SVGTemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { + clampPrestige, + levelBand, + MAX_LEVEL, + PrestigeTier, + prestigeTier, +} 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. +// +// Drawn on a 32x32 grid so it scales from the 16px nav badge up to the profile +// header without separate art. + +interface BandStyle { + // Frame outline on the 32x32 grid. + shape: SVGTemplateResult; + // Vertical centre of the number within the frame. + textY: number; + // Shrinks the number for frames with less interior room. + fontScale: number; + // For frames that are narrow where the number sits: two-digit numbers are + // condensed to this width instead of being shrunk, so they stay tall. + twoDigitWidth?: number; + // Tailwind fill/stroke classes (kept literal so the class scanner sees them). + colors: string; +} + +function starPoints( + points: number, + outer: number, + inner: number, + rotationDeg = -90, + cy = 16, +): string { + const out: string[] = []; + for (let i = 0; i < points * 2; i++) { + const r = i % 2 === 0 ? outer : inner; + const a = ((rotationDeg + (i * 180) / points) * Math.PI) / 180; + out.push( + `${(16 + r * Math.cos(a)).toFixed(2)},${(cy + r * Math.sin(a)).toFixed(2)}`, + ); + } + return out.join(" "); +} + +const CROWN = "M2 9 L9 15 L16 4 L23 15 L30 9 L27 29 H5 Z"; + +// Index = levelBand(level): 1–9, 10–19, … 90–99, then 100. +const BANDS: readonly BandStyle[] = [ + // 1–9: circle + { + shape: svg``, + textY: 16.5, + fontScale: 1, + colors: "fill-slate-600 stroke-slate-300", + }, + // 10–19: rounded square + { + shape: svg``, + textY: 16.5, + fontScale: 1, + colors: "fill-emerald-700 stroke-emerald-300", + }, + // 20–29: diamond + { + shape: svg``, + textY: 16.5, + fontScale: 0.95, + twoDigitWidth: 15, + colors: "fill-teal-700 stroke-teal-300", + }, + // 30–39: shield + { + shape: svg``, + textY: 15, + fontScale: 0.95, + colors: "fill-sky-700 stroke-sky-300", + }, + // 40–49: hexagon + { + shape: svg``, + textY: 16.5, + fontScale: 0.95, + colors: "fill-blue-700 stroke-blue-300", + }, + // 50–59: octagon + { + shape: svg``, + textY: 16.5, + fontScale: 1, + colors: "fill-indigo-700 stroke-indigo-300", + }, + // 60–69: banner with a notched tail + { + shape: svg``, + textY: 13.5, + fontScale: 0.95, + colors: "fill-violet-700 stroke-violet-300", + }, + // 70–79: eight-point star + { + shape: svg``, + textY: 16.5, + fontScale: 0.85, + colors: "fill-fuchsia-700 stroke-fuchsia-300", + }, + // 80–89: rosette (twelve shallow points) + { + shape: svg``, + textY: 16.5, + fontScale: 0.95, + colors: "fill-rose-700 stroke-rose-300", + }, + // 90–99: winged + { + shape: svg``, + textY: 16.5, + fontScale: 0.9, + colors: "fill-orange-700 stroke-orange-300", + }, + // 100: crown + { + shape: svg``, + textY: 21, + fontScale: 0.95, + colors: "fill-amber-600 stroke-yellow-200", + }, +]; + +function displayLevel(level: number): number { + if (!Number.isFinite(level)) return 1; + return Math.min(MAX_LEVEL, Math.max(1, Math.floor(level))); +} + +/** "Level 42", "Level 42, Prestige 3" or "Legend". */ +export function levelBadgeLabel( + level: number, + prestige: number, + legend: boolean, +): string { + if (legend) return translateText("progression.legend"); + const levelText = translateText("progression.level", { + level: displayLevel(level), + }); + const p = clampPrestige(prestige); + if (p === 0) return levelText; + return `${levelText}, ${translateText("progression.prestige", { prestige: p })}`; +} + +@customElement("level-badge") +export class LevelBadge extends LitElement { + @property({ type: Number }) level = 1; + @property({ type: Number }) prestige = 0; + @property({ type: Boolean }) legend = false; + // Rendered size in CSS pixels (square). 16 is the smallest supported. + @property({ type: Number }) size = 24; + + createRenderRoot() { + return this; + } + + /** Accessible name, e.g. "Level 42, Prestige 3". */ + label(): string { + return levelBadgeLabel(this.level, this.prestige, this.legend); + } + + private displayLevel(): number { + return displayLevel(this.level); + } + + private renderNumber(band: BandStyle): SVGTemplateResult { + const text = String(this.displayLevel()); + // At nav size every pixel of glyph counts: let the number run a little + // past the tighter frames, and thin the outline that keeps it readable + // on light fills. + const small = this.size < 24; + const base = + text.length === 1 ? 19 : text.length === 2 ? 16 : small ? 14 : 12; + const fontSize = + base * (small ? Math.max(band.fontScale, 0.95) : band.fontScale); + return svg`${text}`; + } + + // 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``; + } + } + + // One to three gems along the top of the emblem: the rank WITHIN its group + // (P1/P2/P3 share a ring, P4/P5/P6 a double ring, …), so ranks read apart + // at a glance and at sizes too small for the numeral tab. P10 is its own + // group and needs none. + private renderRankGems( + prestige: number, + tier: PrestigeTier, + ): SVGTemplateResult | typeof nothing { + if (tier === "none" || tier === "radiant") return nothing; + const count = ((prestige - 1) % 3) + 1; + const angles = + count === 1 ? [-90] : count === 2 ? [-104, -76] : [-118, -90, -62]; + const r = tier === "sunburst" ? 15.2 : 14.6; + return svg`${angles.map((deg) => { + const a = (deg * Math.PI) / 180; + const x = (16 + r * Math.cos(a)).toFixed(2); + const y = 16 + r * Math.sin(a); + const points = [ + `${x},${(y - 2.9).toFixed(2)}`, + `${(Number(x) + 2.3).toFixed(2)},${y.toFixed(2)}`, + `${x},${(y + 2.9).toFixed(2)}`, + `${(Number(x) - 2.3).toFixed(2)},${y.toFixed(2)}`, + ].join(" "); + return svg``; + })}`; + } + + private renderPrestigeTab( + prestige: number, + ): SVGTemplateResult | typeof nothing { + // Below 24px the numeral is unreadable; the emblem's shape still says + // which group and the accessible name carries the exact rank. + if (this.size < 24) return nothing; + return svg` + = 10 ? 8.5 : 10} y="25" width=${prestige >= 10 ? 15 : 12} height="7.5" rx="2" + class="fill-zinc-900 stroke-yellow-300" stroke-width="1"> + = 10 ? 12 : nothing} + lengthAdjust="spacingAndGlyphs" + >P${prestige}`; + } + + // The level frame and number, shrunk inside the prestige emblem when there + // is one. + private renderFrame(band: BandStyle, prestiged: boolean): SVGTemplateResult { + const inner = svg` + + ${band.shape} + + ${this.renderNumber(band)}`; + if (!prestiged) return inner; + // A little larger at nav size, where the number needs every pixel. + const scale = this.size < 24 ? 0.78 : 0.72; + return svg` + ${inner} + `; + } + + private renderLegend(): SVGTemplateResult { + return svg` + + + `; + } + + render() { + 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)]" + : ""; + 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} + `} + `; + } +} diff --git a/src/client/components/NavAccountMenu.ts b/src/client/components/NavAccountMenu.ts index ba4c30edea..e9d247e550 100644 --- a/src/client/components/NavAccountMenu.ts +++ b/src/client/components/NavAccountMenu.ts @@ -1,7 +1,7 @@ import { html, LitElement, nothing, render, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { UserMeResponse } from "../../core/ApiSchemas"; +import { Progress, UserMeResponse } from "../../core/ApiSchemas"; import { hasLinkedIdentity } from "../AccountIdentity"; import { logOut } from "../Auth"; import { crazyGamesSDK, type CrazyGamesUser } from "../CrazyGamesSDK"; @@ -11,9 +11,13 @@ import { updateAccountNavButton, } from "../NavAccountButton"; import { closeMobileSidebar } from "../Navigation"; +import { levelFraction } from "../Progression"; import { steamSDK } from "../SteamSDK"; import { playerProfileUrl } from "../utilities/PlayerProfileUrl"; import { copyToClipboard, showToast, translateText } from "../Utils"; +import "./LevelBadge"; +import { levelBadgeLabel } from "./LevelBadge"; +import { xpBar, xpProgressText } from "./XpBar"; type MenuItem = { key: string; @@ -373,6 +377,52 @@ export class NavAccountMenu extends LitElement { `; } + /** The signed-in player's level, or null when there is none to show. */ + private progress(): Progress | null { + if (!this.isSignedIn() || this.userMeResponse === false) return null; + return this.userMeResponse.player.progress ?? null; + } + + // Level badge beside the avatar, plus a thin XP bar along the trigger's + // bottom edge. Absent entirely when /users/@me carries no progress. + private renderLevel( + variant: "desktop" | "mobile", + ): TemplateResult | typeof nothing { + const progress = this.progress(); + if (progress === null) return nothing; + const label = levelBadgeLabel( + progress.level, + progress.prestige, + progress.legend, + ); + const title = `${label} · ${xpProgressText(progress.xpInLevel, progress.xpForNext)}`; + const percent = levelFraction(progress.xpInLevel, progress.xpForNext) * 100; + const mobile = variant === "mobile"; + return html` + + + + + `; + } + // Avatar / spinner / person icon / email badge, shared by both triggers. // `ids` is populated for the desktop instance only — NavAccountButton and // CrazyGamesAccountButton drive that one by id — while the data-account-* @@ -453,6 +503,7 @@ export class NavAccountMenu extends LitElement { data-i18n-aria-label="main.account" data-i18n-title="main.account" > + ${this.renderLevel("desktop")} ${this.renderIdentityIcons({ ids: true, iconClass: "w-5 h-5", @@ -489,6 +540,7 @@ export class NavAccountMenu extends LitElement { iconClass: "w-7 h-7", badgeClass: "absolute -bottom-0.5 -right-0.5", })} + ${this.renderLevel("mobile")}
- + ${this.actionButton( + "quiet", + translateText("win_modal.exit"), + () => this._handleExit(), + "exit", + )} ${this.isRankedGame - ? html` - - ` + ? this.actionButton( + "main", + translateText("win_modal.requeue"), + () => this._handleRequeue(), + "requeue", + ) : null} - + : translateText("win_modal.spectate"), + () => this.hide(), + "keep", + )}
+ ${this.renderActionStyles()} `; } + // The modal's own buttons, textured like the store's tiles and buy buttons: + // a tinted gradient with a coloured border, and on hover they lift, glow + // and a light streak sweeps across. "main" is the blue action (keep playing, + // play again); "quiet" is leaving. + private actionButton( + tone: "main" | "quiet", + label: string, + onClick: () => void, + action: string, + ): TemplateResult { + return html``; + } + + private renderActionStyles(): TemplateResult { + return html``; + } + innerHtml() { // The Steam desktop build has nothing to wishlist — fall through to the // other promos so the box is never empty. @@ -280,6 +420,69 @@ export class WinModal extends LitElement implements Controller { this.requestUpdate(); } + disconnectedCallback(): void { + this.xpAbort?.abort(); + super.disconnectedCallback(); + } + + /** + * Fills the XP section. `gameOver` is false when the player died while the + * game carries on: the server only awards XP once the game is archived, so + * that just says so, and the poll starts when the game really ends. + */ + private async updateXp(gameOver: boolean): Promise { + if (this.xpPolling) return; + try { + const game = this.game; + // Spectators and replay viewers have no XP of their own. + if (game.config().isReplay() || !game.myPlayer()) return; + const me = await getUserMe(); + if (!(await this.isSignedIn(me))) { + // Only pitch XP when progression is actually on. There is no + // /users/@me progress to go by here, so ask the public config (404 + // when progression is off; any failure also says nothing). + const config = await fetchProgressionConfig(); + if (config === false || this.xpPolling) return; + this.xpView = { kind: "signed_out" }; + return; + } + // /users/@me carries progress whenever progression is on (level 1 + // before a first scored game); absent means off: no section at all. + if (!me || me.player.progress === undefined) return; + // The game may have ended while this was resolving; the end-of-game + // call owns the section from then on. + if (this.xpPolling) return; + if (!gameOver) { + this.xpView = { kind: "awaiting_end" }; + return; + } + this.xpPolling = true; + this.xpView = { kind: "calculating" }; + this.xpAbort = new AbortController(); + const result = await pollGameXp(game.gameID(), { + signal: this.xpAbort.signal, + }); + // Timed out, signed out or unreadable: hide rather than show an error + // (or, worse, a zero). + this.xpView = + result === null ? { kind: "hidden" } : { kind: "result", data: result }; + } catch (err) { + console.warn("WinModal: XP section failed", err); + this.xpView = { kind: "hidden" }; + } + } + + // Same rule as the account modal: a linked identity, or a CrazyGames + // profile whose token exchange produced a session. + private async isSignedIn( + me: Awaited> | null, + ): Promise { + if (!me) return false; + if (hasLinkedIdentity(me.user)) return true; + if (!crazyGamesSDK.isOnCrazyGames?.()) return false; + return (await crazyGamesSDK.getUserProfile()) !== null; + } + private _handleExit() { this.hide(); window.location.href = homeHref(); @@ -316,10 +519,12 @@ export class WinModal extends LitElement implements Controller { this.hasShownDeathModal = true; this._title = translateText("win_modal.died"); this.eventBus.emit(new PlaySoundEffectEvent("defeat")); + void this.updateXp(false); this.show(); } const updates = this.game.updatesSinceLastTick(); const winUpdates = updates?.[GameUpdateType.Win] ?? []; + if (winUpdates.length > 0) void this.updateXp(true); winUpdates.forEach((wu) => { if (wu.winner === undefined) { // Match cancelled (e.g. a ranked 2v2 that didn't fill or fully diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 6ee224ade7..2a3b196806 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -105,6 +105,101 @@ export const RewardSchema = z.object({ }); export type Reward = z.infer; +// Player levels / XP ("progression"). Purely cosmetic: levels never affect +// gameplay, and XP is computed server-side only — the client just displays it. +// Level 1..100, prestige 0..10; `legend` is level 100 at the last prestige. +export const ProgressSchema = z.object({ + prestige: z.number(), + level: z.number(), + // XP into the current level; 0 at level 100. + xpInLevel: z.number(), + // XP needed for the next level; 0 at level 100. + xpForNext: z.number(), + lifetimeXp: z.number(), + legend: z.boolean(), + canPrestige: z.boolean(), +}); +export type Progress = z.infer; + +export const ProgressPositionSchema = z.object({ + prestige: z.number(), + level: z.number(), + xpInLevel: z.number(), + xpForNext: z.number(), +}); +export type ProgressPosition = z.infer; + +export const XpBreakdownSchema = z.object({ + // Left while still alive: the per-game, placement and win XP are forfeited. + leftEarly: z.boolean(), + played: z.number(), + time: z.number(), + placement: z.number(), + win: z.number(), + firstWin: z.number(), + feats: z.number(), + // Before multipliers. + subtotal: z.number(), + // Multipliers in permille: 1000 is 1x. + gamePermille: z.number(), + subscriberPermille: z.number(), + // XP actually awarded. + total: z.number(), +}); +export type XpBreakdown = 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. +export const GameXpEligibleSchema = z.object({ + gameId: z.string(), + eligible: z.literal(true), + breakdown: XpBreakdownSchema, + before: ProgressPositionSchema, + after: ProgressSchema, + // Every level crossed this game, in order. + levelsReached: z + .array(z.object({ prestige: z.number(), level: z.number() })) + .optional() + .default([]), +}); +export const GameXpIneligibleSchema = z.object({ + gameId: z.string(), + eligible: z.literal(false), + reason: z.string(), +}); +export const GameXpResponseSchema = z.discriminatedUnion("eligible", [ + GameXpEligibleSchema, + GameXpIneligibleSchema, +]); +export type GameXpEligible = z.infer; +export type GameXpIneligible = z.infer; +export type GameXpResponse = z.infer; + +// GET /public/player/:publicId/progress — another player's level. No auth. +export const PublicProgressSchema = z.object({ + prestige: z.number(), + level: z.number(), + lifetimeXp: z.number(), + legend: z.boolean(), +}); +export type PublicProgress = z.infer; + +// GET /public/progression/config — the level curve. No auth, cacheable. +export const ProgressionConfigSchema = z.object({ + version: z.number(), + maxLevel: z.number(), + maxPrestige: z.number(), + levels: z.array( + z.object({ + level: z.number(), + xpToNext: z.number(), + cumulativeXp: z.number(), + }), + ), +}); +export type ProgressionConfig = z.infer; + const CurrencyBalancesSchema = z.object({ soft: z.coerce.number(), hard: z.coerce.number(), @@ -249,6 +344,10 @@ export const UserMeResponseSchema = z.object({ currency: CurrencyBalancesSchema.optional(), // Unclaimed rewards — NOT included in `currency` balances until claimed. rewards: RewardSchema.array().optional(), + // Level / XP. Absent when progression is off (or on an API that predates + // it): every level UI hides itself then. Optional rather than defaulted, + // so "absent" never renders as "level 1, 0 XP". + progress: ProgressSchema.optional(), clans: z .array( z.object({ diff --git a/tests/client/LevelBadge.test.ts b/tests/client/LevelBadge.test.ts new file mode 100644 index 0000000000..05a54f8da9 --- /dev/null +++ b/tests/client/LevelBadge.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, +})); + +import { LevelBadge } from "../../src/client/components/LevelBadge"; +import { + apportionXp, + ineligibleReasonKey, + levelBand, + levelFraction, + levelRewardReasonKey, + multiplierPercent, + subscriberTierOf, + visibleMultipliers, + visibleXpLines, +} from "../../src/client/Progression"; +import type { XpBreakdown } from "../../src/core/ApiSchemas"; + +if (!customElements.get("level-badge")) { + customElements.define("level-badge", LevelBadge); +} + +describe("levelBand", () => { + it("groups levels in tens, with level 100 on its own", () => { + expect(levelBand(1)).toBe(0); + expect(levelBand(9)).toBe(0); + expect(levelBand(10)).toBe(1); + expect(levelBand(19)).toBe(1); + expect(levelBand(20)).toBe(2); + expect(levelBand(55)).toBe(5); + expect(levelBand(90)).toBe(9); + expect(levelBand(99)).toBe(9); + expect(levelBand(100)).toBe(10); + }); + + it("clamps surprising input to a drawable band", () => { + expect(levelBand(0)).toBe(0); + expect(levelBand(-5)).toBe(0); + expect(levelBand(150)).toBe(10); + expect(levelBand(Number.NaN)).toBe(0); + }); +}); + +describe("", () => { + let el: LevelBadge | undefined; + + afterEach(() => { + el?.remove(); + el = undefined; + }); + + async function render(props: Partial): Promise { + el?.remove(); + el = document.createElement("level-badge") as LevelBadge; + Object.assign(el, props); + document.body.appendChild(el); + await el.updateComplete; + return el; + } + + // The frame's outline, as markup — two bands with the same outline would + // differ only by colour, which colour-blind players can't rely on. + async function frameShape(level: number): Promise { + const badge = await render({ level }); + const frame = badge.querySelector("svg > g"); + expect(frame).not.toBeNull(); + return (frame!.innerHTML ?? "") + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + } + + it("draws a differently shaped frame for every band", async () => { + const levels = [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + const shapes = new Set(); + for (const level of levels) shapes.add(await frameShape(level)); + expect(shapes.size).toBe(levels.length); + }); + + it("keeps one frame within a band", async () => { + expect(await frameShape(40)).toBe(await frameShape(49)); + }); + + it("shows the level number and an accessible name", async () => { + const badge = await render({ level: 42 }); + const svg = badge.querySelector("svg")!; + expect(svg.querySelector("text")?.textContent).toBe("42"); + expect(svg.getAttribute("aria-label")).toBe( + 'progression.level:{"level":42}', + ); + expect(svg.getAttribute("data-level-band")).toBe("4"); + }); + + it("renders at the requested size, never below 16px", async () => { + let badge = await render({ level: 5, size: 48 }); + expect(badge.querySelector("svg")?.getAttribute("width")).toBe("48"); + badge = await render({ level: 5, size: 8 }); + expect(badge.querySelector("svg")?.getAttribute("width")).toBe("16"); + }); + + const tierOf = async (props: Partial) => + (await render(props)) + .querySelector("svg") + ?.getAttribute("data-prestige-tier"); + + it("draws a prestige emblem whose shape changes by rank group", 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"); + + // Shape, not just colour: every tier's markup differs. + const markup = new Set(); + for (const prestige of [1, 4, 7, 10]) { + const badge = await render({ level: 12, prestige, size: 16 }); + markup.add( + badge + .querySelector("svg")! + .innerHTML.replace(//g, "") + .replace(/class="[^"]*"/g, ""), + ); + } + expect(markup.size).toBe(4); + }); + + 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("labels the rank where there is room, and always in the name", async () => { + let badge = await render({ level: 12, prestige: 3, size: 40 }); + const texts = [...badge.querySelectorAll("text")].map((t) => t.textContent); + expect(texts).toEqual(["12", "P3"]); + expect(badge.querySelector("svg")?.getAttribute("aria-label")).toContain( + "progression.prestige", + ); + + badge = await render({ level: 12, prestige: 3, size: 16 }); + expect( + [...badge.querySelectorAll("text")].map((t) => t.textContent), + ).toEqual(["12"]); + }); + + it("gives Legend its own look", async () => { + const badge = await render({ + level: 100, + prestige: 10, + legend: true, + size: 40, + }); + const svg = badge.querySelector("svg")!; + expect(svg.getAttribute("data-level-band")).toBe("legend"); + expect(svg.getAttribute("data-prestige-tier")).toBe("none"); + expect(svg.getAttribute("aria-label")).toBe("progression.legend"); + expect(svg.querySelector("text")).toBeNull(); + }); +}); + +describe("progression helpers", () => { + const breakdown: XpBreakdown = { + leftEarly: false, + played: 50, + time: 0, + placement: 25, + win: 0, + firstWin: 100, + feats: 0, + subtotal: 175, + gamePermille: 1000, + subscriberPermille: 1200, + total: 210, + }; + + it("lists only the non-zero breakdown lines, in order", () => { + expect(visibleXpLines(breakdown)).toEqual([ + { key: "played", amount: 50 }, + { key: "placement", amount: 25 }, + { key: "firstWin", amount: 100 }, + ]); + }); + + it("lists only the multipliers that are not 1x", () => { + expect(visibleMultipliers(breakdown)).toEqual([ + { key: "subscriber", permille: 1200 }, + ]); + expect( + visibleMultipliers({ ...breakdown, gamePermille: 250 }).map((m) => m.key), + ).toEqual(["game", "subscriber"]); + }); + + it("formats permille multipliers", () => { + expect(multiplierPercent(1200)).toBe(20); + expect(multiplierPercent(1250)).toBe(25); + expect(multiplierPercent(1125)).toBe(12.5); + expect(multiplierPercent(500)).toBe(-50); + expect(multiplierPercent(1000)).toBe(0); + }); + + it("recognises the subscription tiers by their XP boost", () => { + expect(subscriberTierOf(1500)?.tier).toBe("sovereign"); + expect(subscriberTierOf(1300)?.tier).toBe("warlord"); + expect(subscriberTierOf(1200)?.tier).toBe("vanguard"); + // Anything else is a plain subscriber bonus. + expect(subscriberTierOf(1250)).toBeNull(); + expect(subscriberTierOf(1000)).toBeNull(); + }); + + it("splits a multiplied award across the sources, adding up exactly", () => { + // 170 x1.25 = 212.5, awarded as 212. + const split = apportionXp([50, 20, 100], 212); + expect(split.reduce((a, b) => a + b, 0)).toBe(212); + expect(split).toEqual([62, 25, 125]); + // No multiplier: the sources as they are. + expect(apportionXp([50, 340, 90], 480)).toEqual([50, 340, 90]); + // A cut. + expect(apportionXp([50, 50], 25)).toEqual([13, 12]); + expect(apportionXp([0, 0], 10)).toEqual([0, 0]); + expect(apportionXp([10, 20], 0)).toEqual([0, 0]); + }); + + it("measures progress through a level, full at the cap", () => { + expect(levelFraction(50, 200)).toBe(0.25); + expect(levelFraction(0, 0)).toBe(1); + expect(levelFraction(500, 200)).toBe(1); + }); + + it("maps ineligible reasons, unknown ones to the generic line", () => { + expect(ineligibleReasonKey("too_short")).toBe( + "progression.ineligible_too_short", + ); + expect(ineligibleReasonKey("brand_new_reason")).toBe( + "progression.ineligible_generic", + ); + expect(ineligibleReasonKey("constructor")).toBe( + "progression.ineligible_generic", + ); + }); + + it("maps only the level reward reasons", () => { + expect(levelRewardReasonKey("level_up")).toBe( + "account_modal.reward_level_up", + ); + expect(levelRewardReasonKey("subscription_daily")).toBeUndefined(); + expect(levelRewardReasonKey("toString")).toBeUndefined(); + }); +}); diff --git a/tests/client/NavAccountMenuLevel.test.ts b/tests/client/NavAccountMenuLevel.test.ts new file mode 100644 index 0000000000..49848d8b56 --- /dev/null +++ b/tests/client/NavAccountMenuLevel.test.ts @@ -0,0 +1,105 @@ +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 { NavAccountMenu } from "../../src/client/components/NavAccountMenu"; +import type { Progress, UserMeResponse } from "../../src/core/ApiSchemas"; + +const progress: Progress = { + prestige: 1, + level: 23, + xpInLevel: 250, + xpForNext: 1000, + lifetimeXp: 50_000, + legend: false, + canPrestige: false, +}; + +function userMe(opts: { + progress?: Progress; + signedIn?: boolean; +}): UserMeResponse { + return { + user: opts.signedIn === false ? {} : { email: "player@example.com" }, + player: { + publicId: "p", + ...(opts.progress ? { progress: opts.progress } : {}), + }, + } as unknown as UserMeResponse; +} + +describe("nav-account-menu level", () => { + let el: NavAccountMenu; + + beforeEach(() => { + if (!customElements.get("nav-account-menu")) { + customElements.define("nav-account-menu", NavAccountMenu); + } + }); + + afterEach(() => { + el?.remove(); + }); + + async function mount( + variant: "desktop" | "mobile", + response: UserMeResponse | false, + ): Promise { + el?.remove(); + el = document.createElement("nav-account-menu") as NavAccountMenu; + el.variant = variant; + document.body.appendChild(el); + document.dispatchEvent( + new CustomEvent("userMeResponse", { detail: response }), + ); + await el.updateComplete; + } + + for (const variant of ["desktop", "mobile"] as const) { + it(`shows the badge and XP bar on the ${variant} trigger`, async () => { + await mount(variant, userMe({ progress })); + const level = el.querySelector("[data-account-level]"); + expect(level).not.toBeNull(); + const badge = level!.querySelector("level-badge") as HTMLElement & { + level: number; + prestige: number; + }; + expect(badge.level).toBe(23); + expect(badge.prestige).toBe(1); + const fill = el.querySelector( + "[data-account-xp-bar] [data-xp-bar-fill]", + ); + expect(fill?.style.width).toBe("25%"); + }); + } + + it("hides everything when /users/@me has no progress", async () => { + await mount("desktop", userMe({})); + expect(el.querySelector("[data-account-level]")).toBeNull(); + expect(el.querySelector("[data-account-xp-bar]")).toBeNull(); + }); + + it("hides the level for a signed-out session", async () => { + await mount("desktop", userMe({ progress, signedIn: false })); + expect(el.querySelector("[data-account-level]")).toBeNull(); + await mount("desktop", false); + expect(el.querySelector("[data-account-level]")).toBeNull(); + }); +}); diff --git a/tests/client/ProgressionModals.test.ts b/tests/client/ProgressionModals.test.ts new file mode 100644 index 0000000000..88af119e5f --- /dev/null +++ b/tests/client/ProgressionModals.test.ts @@ -0,0 +1,301 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const { + getUserMe, + fetchPublicPlayerProfile, + fetchPublicPlayerProgress, + fetchMyGameXp, +} = vi.hoisted(() => ({ + getUserMe: vi.fn(), + fetchPublicPlayerProfile: vi.fn(), + fetchPublicPlayerProgress: vi.fn(), + fetchMyGameXp: vi.fn(), +})); + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + copyToClipboard: vi.fn(), + showToast: vi.fn(), +})); + +vi.mock("../../src/client/Api", () => ({ + getUserMe, + fetchPublicPlayerProfile, +})); + +vi.mock("../../src/client/ProgressionApi", () => ({ + fetchPublicPlayerProgress, + fetchMyGameXp, +})); + +vi.mock( + "../../src/client/components/baseComponents/stats/PlayerStatsTree", + () => { + class FakePlayerStatsTreeView extends HTMLElement {} + if (!customElements.get("player-stats-tree-view")) { + customElements.define("player-stats-tree-view", FakePlayerStatsTreeView); + } + return { PlayerStatsTreeView: FakePlayerStatsTreeView }; + }, +); + +vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => { + class FakeGameInfoView extends HTMLElement {} + if (!customElements.get("game-info-view")) { + customElements.define("game-info-view", FakeGameInfoView); + } + return { GameInfoView: FakeGameInfoView }; +}); + +import { GameStatsModal } from "../../src/client/GameStatsModal"; +import { modalRouter } from "../../src/client/ModalRouter"; +import { initNavigation } from "../../src/client/Navigation"; +import { PlayerProfileModal } from "../../src/client/PlayerProfileModal"; + +type ModalShell = HTMLElement & { updateComplete: Promise }; + +const me = { + user: { email: "player@example.com" }, + player: { + publicId: "me", + progress: { + prestige: 2, + level: 64, + xpInLevel: 10, + xpForNext: 900, + lifetimeXp: 123_456, + legend: false, + canPrestige: false, + }, + }, +}; + +const eligibleXp = { + gameId: "g1", + eligible: true, + breakdown: { + leftEarly: false, + played: 50, + time: 0, + placement: 25, + win: 0, + firstWin: 0, + feats: 0, + subtotal: 75, + gamePermille: 1000, + subscriberPermille: 1000, + total: 75, + }, + before: { prestige: 0, level: 3, xpInLevel: 10, xpForNext: 300 }, + after: { + prestige: 0, + level: 3, + xpInLevel: 85, + xpForNext: 300, + lifetimeXp: 700, + legend: false, + canPrestige: false, + }, + levelsReached: [], +}; + +let playPage: HTMLElement; + +beforeAll(() => { + playPage = document.createElement("div"); + playPage.id = "page-play"; + document.body.appendChild(playPage); + initNavigation(); + modalRouter.register("profile", { + tag: "player-profile-modal", + pageId: "page-profile", + }); + modalRouter.register("stats", { + tag: "game-stats-modal", + pageId: "page-stats", + }); + if (!customElements.get("player-profile-modal")) { + customElements.define("player-profile-modal", PlayerProfileModal); + } + if (!customElements.get("game-stats-modal")) { + customElements.define("game-stats-modal", GameStatsModal); + } +}); + +afterAll(() => { + playPage.remove(); +}); + +async function mountModal( + tag: string, + id: string, +): Promise { + const modal = document.createElement(tag) as T & ModalShell; + modal.id = id; + modal.setAttribute("inline", ""); + modal.className = "hidden page-content"; + document.body.appendChild(modal); + await modal.updateComplete; + return modal; +} + +async function settled(modal: ModalShell): Promise { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; +} + +describe("player profile level", () => { + let modal: PlayerProfileModal & ModalShell; + + beforeEach(async () => { + vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) }); + fetchPublicPlayerProfile.mockResolvedValue({ + createdAt: "2026-01-01T00:00:00.000Z", + stats: { Public: {} }, + }); + getUserMe.mockResolvedValue(me); + fetchPublicPlayerProgress.mockReset(); + modal = await mountModal( + "player-profile-modal", + "page-profile", + ); + }); + + afterEach(() => { + window.showPage?.("page-play"); + modal.remove(); + history.replaceState(null, "", "/"); + vi.unstubAllGlobals(); + }); + + async function open(publicId: string): Promise { + history.replaceState(null, "", `/#modal=profile&publicID=${publicId}`); + expect(modalRouter.routeFromHash()).toBe(true); + await vi.waitFor(async () => { + await settled(modal); + expect(modal.querySelector("player-stats-tree-view")).not.toBeNull(); + }); + } + + it("shows another player's level from the public endpoint", async () => { + fetchPublicPlayerProgress.mockResolvedValue({ + prestige: 0, + level: 12, + lifetimeXp: 4200, + legend: false, + }); + await open("someone-else"); + const level = modal.querySelector("[data-profile-level]"); + expect(level).not.toBeNull(); + expect(level!.textContent).toContain('progression.level:{"level":12}'); + expect(level!.textContent).toContain("progression.lifetime_xp"); + expect(level!.textContent).not.toContain("progression.prestige"); + expect(fetchPublicPlayerProgress).toHaveBeenCalledWith("someone-else"); + }); + + it("uses /users/@me for your own profile", async () => { + await open("me"); + const level = modal.querySelector("[data-profile-level]"); + expect(level!.textContent).toContain('progression.level:{"level":64}'); + expect(level!.textContent).toContain('progression.prestige:{"prestige":2}'); + expect(fetchPublicPlayerProgress).not.toHaveBeenCalled(); + }); + + it("hides the level when there is no progress", async () => { + fetchPublicPlayerProgress.mockResolvedValue(false); + await open("no-progress"); + expect(modal.querySelector("[data-profile-level]")).toBeNull(); + }); +}); + +describe("game stats XP", () => { + let modal: GameStatsModal & ModalShell; + + beforeEach(async () => { + vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) }); + getUserMe.mockResolvedValue(me); + fetchMyGameXp.mockReset(); + modal = await mountModal("game-stats-modal", "page-stats"); + }); + + afterEach(() => { + window.showPage?.("page-play"); + modal.remove(); + history.replaceState(null, "", "/"); + vi.unstubAllGlobals(); + }); + + async function open(gameId: string): Promise { + history.replaceState(null, "", `/#modal=stats&gameID=${gameId}`); + expect(modalRouter.routeFromHash()).toBe(true); + await vi.waitFor(async () => { + await settled(modal); + expect(modal.querySelector("game-info-view")).not.toBeNull(); + }); + } + + async function panelState(): Promise { + const panel = modal.querySelector("game-xp-panel") as ModalShell | null; + await panel?.updateComplete; + return ( + panel?.querySelector("[data-xp-panel]")?.getAttribute("data-xp-state") ?? + null + ); + } + + it("shows the XP a past game earned", async () => { + fetchMyGameXp.mockResolvedValue({ status: "ok", data: eligibleXp }); + await open("g1"); + await vi.waitFor(async () => expect(await panelState()).toBe("result")); + const text = modal.querySelector("[data-xp-panel]")!.textContent!; + expect(text).toContain('progression.xp_total:{"xp":"75"}'); + expect(modal.querySelector("[data-xp-levelup]")).toBeNull(); + expect(fetchMyGameXp).toHaveBeenCalledWith("g1"); + }); + + it("hides an ineligible or unprocessed game", async () => { + fetchMyGameXp.mockResolvedValue({ + status: "ok", + data: { gameId: "g2", eligible: false, reason: "not_spawned" }, + }); + await open("g2"); + await vi.waitFor(() => expect(fetchMyGameXp).toHaveBeenCalled()); + await settled(modal); + expect(await panelState()).toBeNull(); + + fetchMyGameXp.mockResolvedValue({ status: "pending" }); + await open("g3"); + await vi.waitFor(() => + expect(fetchMyGameXp).toHaveBeenLastCalledWith("g3"), + ); + await settled(modal); + expect(await panelState()).toBeNull(); + }); + + it("does not ask when progression is off", async () => { + getUserMe.mockResolvedValue({ ...me, player: { publicId: "me" } }); + await open("g5"); + await settled(modal); + expect(fetchMyGameXp).not.toHaveBeenCalled(); + expect(await panelState()).toBeNull(); + }); + + it("does not ask for a signed-out viewer", async () => { + getUserMe.mockResolvedValue({ user: {}, player: { publicId: "anon" } }); + await open("g4"); + await settled(modal); + expect(fetchMyGameXp).not.toHaveBeenCalled(); + expect(await panelState()).toBeNull(); + }); +}); diff --git a/tests/client/ProgressionSchemas.test.ts b/tests/client/ProgressionSchemas.test.ts new file mode 100644 index 0000000000..a300cf4af8 --- /dev/null +++ b/tests/client/ProgressionSchemas.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { + GameXpResponseSchema, + ProgressionConfigSchema, + PublicProgressSchema, + UserMeResponseSchema, +} from "../../src/core/ApiSchemas"; + +const progress = { + prestige: 2, + level: 37, + xpInLevel: 120, + xpForNext: 900, + lifetimeXp: 123456, + legend: false, + canPrestige: false, +}; + +function userMe(player: Record = {}) { + return { + user: { email: "player@example.com" }, + player: { + publicId: "abc", + adfree: false, + unlimitedRanked: false, + canCreatePublicLobbies: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + ...player, + }, + }; +} + +const eligible = { + gameId: "gABCDEFGHI", + eligible: true, + breakdown: { + leftEarly: false, + played: 50, + time: 30, + placement: 40, + win: 100, + firstWin: 0, + feats: 0, + subtotal: 220, + gamePermille: 1250, + subscriberPermille: 1000, + total: 275, + }, + before: { prestige: 0, level: 4, xpInLevel: 300, xpForNext: 400 }, + after: { ...progress, prestige: 0, level: 5, xpInLevel: 175, xpForNext: 450 }, + levelsReached: [{ prestige: 0, level: 5 }], +}; + +describe("UserMeResponseSchema progress", () => { + it("parses a response from an API that predates progression", () => { + const parsed = UserMeResponseSchema.safeParse(userMe()); + expect(parsed.success).toBe(true); + expect(parsed.data?.player.progress).toBeUndefined(); + }); + + it("parses progress when present", () => { + const parsed = UserMeResponseSchema.parse(userMe({ progress })); + expect(parsed.player.progress).toEqual(progress); + }); + + it("rejects a malformed progress object rather than guessing", () => { + const parsed = UserMeResponseSchema.safeParse( + userMe({ progress: { level: "ten" } }), + ); + expect(parsed.success).toBe(false); + }); +}); + +describe("GameXpResponseSchema", () => { + it("parses an eligible result", () => { + const parsed = GameXpResponseSchema.parse(eligible); + expect(parsed.eligible).toBe(true); + if (!parsed.eligible) throw new Error("expected eligible"); + expect(parsed.breakdown.total).toBe(275); + expect(parsed.levelsReached).toEqual([{ prestige: 0, level: 5 }]); + }); + + it("defaults a missing levelsReached to none", () => { + const rest: Record = { ...eligible }; + delete rest.levelsReached; + const parsed = GameXpResponseSchema.parse(rest); + if (!parsed.eligible) throw new Error("expected eligible"); + expect(parsed.levelsReached).toEqual([]); + }); + + it("parses every documented ineligible reason", () => { + for (const reason of [ + "not_spawned", + "too_short", + "no_action", + "custom_settings", + "too_few_humans", + "daily_cap", + ]) { + const parsed = GameXpResponseSchema.parse({ + gameId: "g", + eligible: false, + reason, + }); + expect(parsed).toEqual({ gameId: "g", eligible: false, reason }); + } + }); + + it("keeps an unknown ineligible reason instead of failing the parse", () => { + const parsed = GameXpResponseSchema.safeParse({ + gameId: "g", + eligible: false, + reason: "something_new_next_year", + }); + expect(parsed.success).toBe(true); + }); + + it("rejects an eligible result with no breakdown", () => { + const parsed = GameXpResponseSchema.safeParse({ + gameId: "g", + eligible: true, + }); + expect(parsed.success).toBe(false); + }); +}); + +describe("public progression schemas", () => { + it("parses public progress", () => { + const data = { prestige: 10, level: 100, lifetimeXp: 9e6, legend: true }; + expect(PublicProgressSchema.parse(data)).toEqual(data); + }); + + it("parses the progression config", () => { + const data = { + version: 1, + maxLevel: 100, + maxPrestige: 10, + levels: [ + { level: 1, xpToNext: 100, cumulativeXp: 0 }, + { level: 2, xpToNext: 150, cumulativeXp: 100 }, + ], + }; + expect(ProgressionConfigSchema.parse(data)).toEqual(data); + }); +}); diff --git a/tests/client/RewardsPanel.test.ts b/tests/client/RewardsPanel.test.ts new file mode 100644 index 0000000000..e81c231f8c --- /dev/null +++ b/tests/client/RewardsPanel.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string) => key, +})); + +vi.mock("../../src/client/Api", () => ({ + claimAllRewards: vi.fn(), + claimReward: vi.fn(), + getUserMe: vi.fn(), + invalidateUserMe: vi.fn(), +})); + +vi.mock("../../src/client/InGameModal", () => ({ + showInGameAlert: vi.fn(), +})); + +import { RewardsPanel } from "../../src/client/components/RewardsPanel"; +import type { Reward } from "../../src/core/ApiSchemas"; + +if (!customElements.get("rewards-panel")) { + customElements.define("rewards-panel", RewardsPanel); +} + +function reward(reason: string, note: string | null = null): Reward { + return { id: reason, currencyType: "soft", amount: "100", reason, note }; +} + +describe(" reward labels", () => { + let el: RewardsPanel | undefined; + + afterEach(() => { + el?.remove(); + el = undefined; + }); + + async function labels(rewards: Reward[]): Promise { + el = document.createElement("rewards-panel") as RewardsPanel; + el.rewards = rewards; + document.body.appendChild(el); + await el.updateComplete; + return [...el.querySelectorAll("span.truncate")].map( + (s) => s.textContent?.trim() ?? "", + ); + } + + it("gives the progression reasons their own copy", async () => { + expect( + await labels([ + reward("level_up"), + reward("level_milestone"), + reward("prestige"), + ]), + ).toEqual([ + "account_modal.reward_level_up", + "account_modal.reward_level_milestone", + "account_modal.reward_prestige", + ]); + }); + + it("prefers the localized copy over a level reward's server note", async () => { + expect(await labels([reward("level_up", "Reached level 20")])).toEqual([ + "account_modal.reward_level_up", + ]); + }); + + it("still falls back to the note, then the reason, for unknown reasons", async () => { + expect( + await labels([ + reward("season_bonus", "Season 3 bonus"), + reward("mystery_reason"), + ]), + ).toEqual(["Season 3 bonus", "mystery_reason"]); + }); + + it("keeps the existing subscription copy", async () => { + expect(await labels([reward("subscription_daily")])).toEqual([ + "account_modal.reward_daily", + ]); + }); +}); diff --git a/tests/client/graphics/layers/WinModalTick.test.ts b/tests/client/graphics/layers/WinModalTick.test.ts index d558bcea00..42678eb670 100644 --- a/tests/client/graphics/layers/WinModalTick.test.ts +++ b/tests/client/graphics/layers/WinModalTick.test.ts @@ -172,13 +172,42 @@ describe("WinModal tick win handling", () => { await modal!.updateComplete; expect(modal!.isVisible).toBe(true); - const exit = modal!.querySelector( - "o-button[translationKey='win_modal.exit']", - ); + const exit = modal!.querySelector("[data-win-action='exit']"); expect(exit).not.toBeNull(); expect(exit!.parentElement!.classList.contains("hidden")).toBe(false); }); + it("puts leaving on the left and staying on the right, and staying closes the modal", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.mocked(fetchCosmetics).mockReturnValueOnce(new Promise(() => {})); + setup(makeGame({ winner: ["team", "Blue"], myTeam: "Blue" })); + document.body.appendChild(modal!); + void modal!.show(); + await modal!.updateComplete; + + const actions = [ + ...modal!.querySelectorAll("[data-win-action]"), + ]; + expect(actions.map((b) => b.getAttribute("data-win-action"))).toEqual([ + "exit", + "keep", + ]); + // Leaving is the quiet button, staying the main one. + expect(actions[0].classList.contains("win-action-quiet")).toBe(true); + expect(actions[1].classList.contains("win-action-main")).toBe(true); + + actions[1].click(); + await modal!.updateComplete; + expect(modal!.isVisible).toBe(false); + }); + it("ignores a player win whose winner is not a known player", () => { const events = setup( makeGame({ winner: ["player", "gone"], winnerPlayer: undefined }), diff --git a/tests/client/graphics/layers/WinModalXp.test.ts b/tests/client/graphics/layers/WinModalXp.test.ts new file mode 100644 index 0000000000..8451ff276d --- /dev/null +++ b/tests/client/graphics/layers/WinModalXp.test.ts @@ -0,0 +1,766 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { getUserMe, fetchProgressionConfig } = vi.hoisted(() => ({ + getUserMe: vi.fn(), + fetchProgressionConfig: vi.fn(), +})); + +vi.mock("../../../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + getGamesPlayed: vi.fn(() => 10), + isInIframe: vi.fn(() => false), + homeHref: vi.fn(() => "/"), + TUTORIAL_VIDEO_URL: "https://example.com/tutorial", +})); + +vi.mock("../../../../src/client/Api", () => ({ getUserMe })); + +vi.mock("../../../../src/client/Auth", () => ({ + getAuthHeader: vi.fn(async () => "Bearer test-token"), +})); + +vi.mock("../../../../src/client/ApiBase", () => ({ + getApiBase: () => "https://api.test", +})); + +// Only the config lookup is replaced: the poll and the XP fetch run for real +// against the stubbed fetch below. +vi.mock("../../../../src/client/ProgressionApi", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../../src/client/ProgressionApi") + >()), + fetchProgressionConfig, +})); + +vi.mock("../../../../src/client/Cosmetics", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../../src/client/Cosmetics") + >()), + fetchCosmetics: vi.fn(async () => null), + resolveCosmetics: vi.fn(() => []), +})); + +vi.mock("../../../../src/client/CrazyGamesSDK", () => ({ + crazyGamesSDK: { + happytime: vi.fn(), + requestAd: vi.fn(), + gameplayStop: vi.fn(), + isOnCrazyGames: vi.fn(() => false), + getUserProfile: vi.fn(async () => null), + }, +})); + +import type { GameXpPanel } from "../../../../src/client/components/GameXpPanel"; +import "../../../../src/client/hud/layers/WinModal"; +import type { WinModal } from "../../../../src/client/hud/layers/WinModal"; +import type { GameView } from "../../../../src/client/view"; +import { EventBus } from "../../../../src/core/EventBus"; +import { GameUpdateType } from "../../../../src/core/game/GameUpdates"; + +const GAME_ID = "gXPTEST01"; +const XP_URL = `https://api.test/users/@me/xp/${GAME_ID}`; + +// Progression on: /users/@me carries progress, level 1 before any XP. +const signedIn = { + user: { email: "player@example.com" }, + player: { + publicId: "me", + progress: { + prestige: 0, + level: 1, + xpInLevel: 0, + xpForNext: 150, + lifetimeXp: 0, + legend: false, + canPrestige: false, + }, + }, +}; +const anonymous = { user: {}, player: { publicId: "anon" } }; + +function eligible(overrides: Record = {}) { + return { + gameId: GAME_ID, + eligible: true, + breakdown: { + leftEarly: false, + played: 50, + time: 20, + placement: 0, + win: 100, + firstWin: 0, + feats: 0, + subtotal: 170, + gamePermille: 1250, + subscriberPermille: 1000, + total: 212, + }, + before: { prestige: 0, level: 4, xpInLevel: 300, xpForNext: 400 }, + after: { + prestige: 0, + level: 4, + xpInLevel: 512, + xpForNext: 400, + lifetimeXp: 2000, + legend: false, + canPrestige: false, + }, + levelsReached: [], + ...overrides, + }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// Answers the XP endpoint from a queue; once it runs dry, the last answer +// repeats. Anything else is a test bug. +function stubXpEndpoint(answers: (() => Response)[]) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url !== XP_URL) throw new Error(`unexpected fetch ${url}`); + const next = answers.length > 1 ? answers.shift()! : answers[0]; + return next(); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const notFound = () => new Response("", { status: 404 }); + +function makeGame(opts: { ended: boolean; alive?: boolean; replay?: boolean }) { + const winUpdate = { winner: ["team", "Red"], allPlayersStats: {} }; + let delivered = false; + return { + gameID: () => GAME_ID, + myPlayer: () => ({ + isAlive: () => opts.alive ?? true, + hasSpawned: () => true, + team: () => "Red", + clientID: () => "me", + }), + inSpawnPhase: () => false, + updatesSinceLastTick: () => { + if (!opts.ended || delivered) return {}; + delivered = true; + return { [GameUpdateType.Win]: [winUpdate] }; + }, + playerByClientID: () => undefined, + config: () => ({ + gameConfig: () => ({ rankedType: undefined }), + isReplay: () => opts.replay ?? false, + }), + } as unknown as GameView; +} + +describe("WinModal XP section", () => { + let modal: WinModal; + + beforeEach(() => { + vi.useFakeTimers(); + getUserMe.mockResolvedValue(signedIn); + fetchProgressionConfig.mockResolvedValue({ + version: 1, + maxLevel: 100, + maxPrestige: 10, + levels: [], + }); + vi.spyOn(history, "replaceState").mockImplementation(() => {}); + }); + + afterEach(() => { + modal?.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function mount(game: GameView): Promise { + modal = document.createElement("win-modal") as WinModal; + modal.game = game; + modal.eventBus = new EventBus(); + // Pin the promo slot to the pattern promo (the Steam wishlist one needs + // ResizeObserver, which jsdom lacks). + Object.assign(modal as unknown as { rand: number }, { rand: 0.75 }); + document.body.appendChild(modal); + modal.tick(); + await settle(); + } + + async function settle(ms = 0): Promise { + await vi.advanceTimersByTimeAsync(ms); + await modal.updateComplete; + const panel = modal.querySelector("game-xp-panel"); + await panel?.updateComplete; + } + + function panel(): HTMLElement | null { + return modal.querySelector("game-xp-panel [data-xp-panel]"); + } + + // The result animates in (a caption per XP source, the bar climbing). + // Tapping skips to the end, as a player can. + async function finishReveal(): Promise { + panel()?.click(); + await settle(); + } + + function xpState(): string { + return panel()?.getAttribute("data-xp-state") ?? "hidden"; + } + + it("shows calculating while the game is processed, then the breakdown", async () => { + const fetchMock = stubXpEndpoint([ + notFound, + notFound, + () => json(eligible()), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + + expect(xpState()).toBe("calculating"); + expect(panel()!.textContent).toContain("progression.calculating"); + // Never a number while pending. + expect(panel()!.textContent).not.toContain("progression.xp_total"); + + await settle(3_000); + expect(xpState()).toBe("calculating"); + + await settle(3_000); + expect(xpState()).toBe("result"); + expect(fetchMock).toHaveBeenCalledTimes(3); + await finishReveal(); + const text = panel()!.textContent!; + expect(text).toContain('progression.xp_total:{"xp":"212"}'); + // Non-zero lines only. + const lines = [...panel()!.querySelectorAll("[data-xp-line]")].map((el) => + el.getAttribute("data-xp-line"), + ); + expect(lines).toEqual(["played", "time", "win"]); + // Only multipliers other than 1x. + const multipliers = [ + ...panel()!.querySelectorAll("[data-xp-multiplier]"), + ].map((el) => el.getAttribute("data-xp-multiplier")); + expect(multipliers).toEqual(["game"]); + // Shown like a group bonus: "+25% XP (GAME TYPE)". + expect(text).toContain( + 'progression.bonus_line:{"percent":25,"source":"progression.multiplier_game"}', + ); + expect(panel()!.querySelector("[data-xp-levelup]")).toBeNull(); + expect(panel()!.querySelector("[data-xp-left-early]")).toBeNull(); + }); + + it("hides the section when the game is never processed in time", async () => { + const fetchMock = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(xpState()).toBe("calculating"); + + await settle(60_000); + expect(xpState()).toBe("hidden"); + expect(modal.querySelector("[data-xp-panel]")).toBeNull(); + const calls = fetchMock.mock.calls.length; + expect(calls).toBeGreaterThan(10); + + // And it stops asking. + await settle(30_000); + expect(fetchMock.mock.calls.length).toBe(calls); + }); + + it("shows one friendly line for an ineligible game", async () => { + stubXpEndpoint([ + () => json({ gameId: GAME_ID, eligible: false, reason: "too_short" }), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(xpState()).toBe("ineligible"); + expect(panel()!.textContent!.trim()).toBe( + "progression.ineligible_too_short", + ); + }); + + it("falls back to a generic line for an unknown reason", async () => { + stubXpEndpoint([ + () => json({ gameId: GAME_ID, eligible: false, reason: "new_rule" }), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(panel()!.textContent!.trim()).toBe("progression.ineligible_generic"); + }); + + it("explains a left-early forfeit", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + breakdown: { + ...eligible().breakdown, + leftEarly: true, + played: 0, + placement: 0, + win: 0, + total: 20, + }, + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(xpState()).toBe("result"); + expect( + panel()!.querySelector("[data-xp-left-early]")?.textContent, + ).toContain("progression.left_early"); + }); + + it("gives ordinary level-ups no card, just the caption and the badge", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 0, level: 4, xpInLevel: 300, xpForNext: 400 }, + after: { + ...eligible().after, + level: 7, + xpInLevel: 50, + xpForNext: 600, + }, + levelsReached: [ + { prestige: 0, level: 5 }, + { prestige: 0, level: 6 }, + { prestige: 0, level: 7 }, + ], + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(panel()!.querySelector("[data-xp-levelup]")).toBeNull(); + expect(panel()!.querySelector("[data-xp-caption]")!.textContent).toContain( + 'progression.level_reached:{"level":7}', + ); + // The bar runs from the level the player is now on to the next one. + const badgeLevel = (sel: string) => + ( + panel()!.querySelector(`${sel} level-badge`) as HTMLElement & { + level: number; + } + ).level; + expect(badgeLevel("[data-xp-current-badge]")).toBe(7); + expect(badgeLevel("[data-xp-next-badge]")).toBe(8); + }); + + it("gives a milestone reached this game its own card", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 0, level: 9, xpInLevel: 300, xpForNext: 330 }, + after: { + ...eligible().after, + level: 11, + xpInLevel: 10, + xpForNext: 370, + }, + levelsReached: [ + { prestige: 0, level: 10 }, + { prestige: 0, level: 11 }, + ], + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + // Passed 10 on the way to 11: the card is for 10, the milestone. + const cards = panel()!.querySelectorAll("[data-xp-levelup]"); + expect(cards).toHaveLength(1); + expect(cards[0].getAttribute("data-xp-milestone")).toBe("10"); + expect(cards[0].textContent).toContain("progression.new_milestone"); + expect(cards[0].textContent).toContain('progression.level:{"level":10}'); + }); + + it("never shows a prestige change as a level-up", async () => { + // A game can't prestige a player; an entry at another prestige is noise. + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 0, level: 5, xpInLevel: 0, xpForNext: 230 }, + levelsReached: [{ prestige: 1, level: 1 }], + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(xpState()).toBe("result"); + expect(panel()!.querySelector("[data-xp-levelup]")).toBeNull(); + }); + + it("gives becoming a Legend its own moment", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + before: { + prestige: 10, + level: 99, + xpInLevel: 4400, + xpForNext: 4510, + }, + after: { + ...eligible().after, + prestige: 10, + level: 100, + xpInLevel: 0, + xpForNext: 0, + legend: true, + lifetimeXp: 2106720, + }, + levelsReached: [{ prestige: 10, level: 100 }], + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + // The caption announces it; the card carries the lifetime XP. + expect(panel()!.querySelector("[data-xp-caption]")?.textContent).toContain( + "progression.legend_reached_title", + ); + expect(panel()!.querySelector("[data-xp-legend]")?.textContent).toContain( + "progression.legend_reached_body", + ); + expect(panel()!.querySelector("[data-xp-levelup]")).toBeNull(); + // The end of the track: the crown on the left, no next level. + const current = panel()!.querySelector( + "[data-xp-current-badge] level-badge", + ) as HTMLElement & { level: number; legend: boolean }; + expect(current.level).toBe(100); + expect(current.legend).toBe(true); + expect(panel()!.querySelector("[data-xp-next-badge]")).toBeNull(); + }); + + it("reveals the XP a source at a time, then settles on the award", async () => { + stubXpEndpoint([() => json(eligible())]); + await mount(makeGame({ ended: true })); + expect(xpState()).toBe("result"); + expect(panel()!.getAttribute("data-xp-revealing")).toBe("true"); + // Nothing counted yet. Every card's slot is laid out from the start, + // hidden, so the panel never changes size. + expect(panel()!.querySelector("[data-xp-total]")!.textContent).toContain( + '"xp":"0"', + ); + const hiddenSlots = () => + panel()!.querySelectorAll("[data-xp-slot-hidden]").length; + // A card for each of the three sources; the game-type multiplier is a + // bonus line under the counter, not a card. + expect(panel()!.querySelectorAll("[data-xp-card]")).toHaveLength(3); + expect(panel()!.querySelector("[data-xp-bonuses]")).not.toBeNull(); + expect(hiddenSlots()).toBe(4); + + // The captions roll through the sources in order, and each source's card + // lands as its caption finishes. + const captions: string[] = []; + const hiddenCounts: number[] = []; + for (let i = 0; i < 40; i++) { + await settle(100); + const text = + panel()?.querySelector("[data-xp-caption]")?.textContent ?? ""; + const last = captions[captions.length - 1]; + if (text.trim() !== "" && text !== last) captions.push(text); + if (panel()?.getAttribute("data-xp-revealing") !== "true") break; + hiddenCounts.push(hiddenSlots()); + } + const order = ["line_played", "line_time", "line_win"].map((key) => + captions.findIndex((c) => c.includes("progression." + key)), + ); + expect(order.every((i) => i >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + // One at a time: 4 hidden, then 3, 2, and the bonus line last. + expect([...new Set(hiddenCounts)].slice(0, 4)).toEqual([4, 3, 2, 1]); + // The multiplier gets its own caption; the award never does, it is the + // counter's number. + expect( + captions.some((c) => c.includes("progression.multiplier_game")), + ).toBe(true); + expect(captions.some((c) => c.includes('"xp":"212"'))).toBe(false); + + // Run it out: the counter lands on the award and every card is up. + await settle(20_000); + expect(panel()!.getAttribute("data-xp-revealing")).toBeNull(); + expect(panel()!.querySelector("[data-xp-total]")!.textContent).toContain( + '"xp":"212"', + ); + expect(hiddenSlots()).toBe(0); + }); + + it("applies a multiplier to the cards, which add up to the award", async () => { + stubXpEndpoint([() => json(eligible())]); + await mount(makeGame({ ended: true })); + const values = () => + [...panel()!.querySelectorAll("[data-xp-card]")] + .filter((c) => c.querySelector("[data-xp-line]")) + .map( + (c) => + JSON.parse( + c + .querySelector("[data-xp-card-value]")! + .textContent!.trim() + .replace("progression.xp_total:", ""), + ).xp, + ); + // Face values while the sources come in. + expect(values()).toEqual(["50", "20", "100"]); + // The boost wipes across the cards, turning them over first to last: + // at some point the first card is boosted while the last isn't yet. + let wiped = false; + let inOrder = false; + for (let i = 0; i < 200; i++) { + await settle(50); + if (panel()!.querySelector(".xp-card-wipe")) wiped = true; + const v = values(); + if (v[0] !== "50" && v[2] === "100") inOrder = true; + if (panel()?.getAttribute("data-xp-revealing") !== "true") break; + } + expect(wiped).toBe(true); + expect(inOrder).toBe(true); + // 170 x1.25, awarded as 212, split across the cards. + expect(values()).toEqual(["62", "25", "125"]); + }); + + it("keeps no space for a milestone or a bonus until the reveal gets to it", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 0, level: 9, xpInLevel: 300, xpForNext: 330 }, + after: { + ...eligible().after, + level: 10, + xpInLevel: 182, + xpForNext: 370, + }, + levelsReached: [{ prestige: 0, level: 10 }], + }), + ), + ]); + await mount(makeGame({ ended: true })); + const space = (sel: string) => + panel()! + .querySelector(sel)! + .closest("[data-xp-collapsible]")! + .getAttribute("data-xp-collapsible"); + // Closed at the start: nothing hints at what is coming. + expect(space("[data-xp-levelup]")).toBe("closed"); + expect(space("[data-xp-bonuses]")).toBe("closed"); + // The milestone opens its space when the bar reaches it, before the + // bonus does. + let milestoneFirst = false; + for (let i = 0; i < 400; i++) { + await settle(50); + if (panel()?.getAttribute("data-xp-revealing") !== "true") break; + if ( + space("[data-xp-levelup]") === "open" && + space("[data-xp-bonuses]") === "closed" + ) { + milestoneFirst = true; + } + } + expect(milestoneFirst).toBe(true); + expect(space("[data-xp-levelup]")).toBe("open"); + expect(space("[data-xp-bonuses]")).toBe("open"); + }); + + it("shows a subscription tier's boost under its name, in its colours", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + breakdown: { + ...eligible().breakdown, + gamePermille: 1000, + subscriberPermille: 1500, + total: 255, + }, + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + const line = panel()!.querySelector("[data-xp-multiplier='subscriber']")!; + expect(line.getAttribute("data-xp-tier")).toBe("sovereign"); + expect(line.textContent).toContain( + 'progression.bonus_line:{"percent":50,"source":"progression.multiplier_sovereign"}', + ); + // Sovereign's gold, on the line and across the panel's boosted cards. + expect(line.getAttribute("style")).toContain("--accent-a: #ffc713"); + expect(panel()!.getAttribute("style")).toContain("--accent-b: #ffde90"); + }); + + it("shows an unknown subscriber boost as a plain subscriber bonus", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + breakdown: { + ...eligible().breakdown, + gamePermille: 1000, + subscriberPermille: 1250, + total: 212, + }, + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + const line = panel()!.querySelector("[data-xp-multiplier='subscriber']")!; + expect(line.getAttribute("data-xp-tier")).toBeNull(); + expect(line.textContent).toContain("progression.multiplier_subscriber"); + expect(panel()!.getAttribute("style")).toBeNull(); + }); + + it("rolls the closing caption in once, not again when the reveal ends", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 0, level: 4, xpInLevel: 300, xpForNext: 400 }, + after: { + ...eligible().after, + level: 5, + xpInLevel: 10, + xpForNext: 430, + }, + levelsReached: [{ prestige: 0, level: 5 }], + }), + ), + ]); + await mount(makeGame({ ended: true })); + // Wait for the closing "Level 5!" caption, while still revealing. + let closing: Element | null = null; + for (let i = 0; i < 400 && closing === null; i++) { + await settle(50); + const el = panel()!.querySelector("[data-xp-caption-step]"); + if ( + panel()!.getAttribute("data-xp-revealing") === "true" && + el?.textContent?.includes("progression.level_reached") + ) { + closing = el; + } + } + expect(closing).not.toBeNull(); + await settle(20_000); + expect(panel()!.getAttribute("data-xp-revealing")).toBeNull(); + // The same element: no second roll-in. + expect(panel()!.querySelector("[data-xp-caption-step]")).toBe(closing); + }); + + it("pauses on every level reached during the reveal", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 0, level: 4, xpInLevel: 300, xpForNext: 400 }, + after: { + ...eligible().after, + level: 7, + xpInLevel: 50, + xpForNext: 600, + }, + levelsReached: [ + { prestige: 0, level: 5 }, + { prestige: 0, level: 6 }, + { prestige: 0, level: 7 }, + ], + }), + ), + ]); + await mount(makeGame({ ended: true })); + const moments: string[] = []; + for (let i = 0; i < 400; i++) { + await settle(50); + const moment = panel() + ?.querySelector("[data-xp-caption-levelup]") + ?.getAttribute("data-xp-caption-levelup"); + if (moment && moment !== moments[moments.length - 1]) { + moments.push(moment); + // The bar is full and the badge shows the new level. + expect( + panel()! + .querySelector("[data-xp-bar]")! + .getAttribute("aria-valuenow"), + ).toBe("100"); + // The level being reached is the right-hand badge, with the one + // before it still on the left. + const badgeLevel = (sel: string) => + ( + panel()!.querySelector(`${sel} level-badge`) as HTMLElement & { + level: number; + } + ).level; + expect(badgeLevel("[data-xp-next-badge]")).toBe(Number(moment)); + expect(badgeLevel("[data-xp-current-badge]")).toBe(Number(moment) - 1); + } + if (panel()?.getAttribute("data-xp-revealing") !== "true") break; + } + expect(moments).toEqual(["5", "6", "7"]); + }); + + it("skips to the end when the panel is tapped", async () => { + stubXpEndpoint([() => json(eligible())]); + await mount(makeGame({ ended: true })); + expect(panel()!.getAttribute("data-xp-revealing")).toBe("true"); + panel()!.click(); + await settle(); + expect(panel()!.getAttribute("data-xp-revealing")).toBeNull(); + expect(panel()!.querySelector("[data-xp-total]")!.textContent).toContain( + '"xp":"212"', + ); + }); + + it("asks a signed-out player to sign in, without polling", async () => { + getUserMe.mockResolvedValue(anonymous); + const fetchMock = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(xpState()).toBe("signed_out"); + expect(panel()!.textContent).toContain("progression.sign_in_to_earn"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("says nothing at all when progression is off", async () => { + fetchProgressionConfig.mockResolvedValue(false); + getUserMe.mockResolvedValue(false); + const fetchMock = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(xpState()).toBe("hidden"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("says nothing to a signed-in player when /users/@me has no progress", async () => { + getUserMe.mockResolvedValue({ ...signedIn, player: { publicId: "me" } }); + const fetchMock = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + await finishReveal(); + expect(xpState()).toBe("hidden"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + 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 })); + expect(xpState()).toBe("awaiting_end"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("stays hidden in a replay", async () => { + const fetchMock = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true, replay: true })); + expect(xpState()).toBe("hidden"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From 6ff9abf78c649b380928acf15f1057bf7d63f279 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 1 Oct 2026 00:26:36 +0100 Subject: [PATCH 02/17] Strip Lit's comment markers in the badge tests as nodes, not by regex CodeQL flags the regex (incomplete multi-character sanitization). These tests only compare SVG markup, but removing the comment nodes through the DOM is simpler to read and leaves nothing to flag. Co-Authored-By: Claude Opus 5.5 --- tests/client/LevelBadge.test.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/client/LevelBadge.test.ts b/tests/client/LevelBadge.test.ts index 05a54f8da9..d613bcb676 100644 --- a/tests/client/LevelBadge.test.ts +++ b/tests/client/LevelBadge.test.ts @@ -61,16 +61,24 @@ describe("", () => { return el; } + // An element's markup without Lit's comment markers, which differ between + // renders of the same template. Removed as nodes, not by pattern. + function markupWithoutComments(el: Element): string { + const clone = el.cloneNode(true) as Element; + const walker = document.createTreeWalker(clone, NodeFilter.SHOW_COMMENT); + const comments: Node[] = []; + while (walker.nextNode()) comments.push(walker.currentNode); + for (const comment of comments) comment.parentNode?.removeChild(comment); + return clone.innerHTML; + } + // The frame's outline, as markup — two bands with the same outline would // differ only by colour, which colour-blind players can't rely on. async function frameShape(level: number): Promise { const badge = await render({ level }); const frame = badge.querySelector("svg > g"); expect(frame).not.toBeNull(); - return (frame!.innerHTML ?? "") - .replace(//g, "") - .replace(/\s+/g, " ") - .trim(); + return markupWithoutComments(frame!).replace(/\s+/g, " ").trim(); } it("draws a differently shaped frame for every band", async () => { @@ -119,10 +127,10 @@ describe("", () => { for (const prestige of [1, 4, 7, 10]) { const badge = await render({ level: 12, prestige, size: 16 }); markup.add( - badge - .querySelector("svg")! - .innerHTML.replace(//g, "") - .replace(/class="[^"]*"/g, ""), + markupWithoutComments(badge.querySelector("svg")!).replace( + /class="[^"]*"/g, + "", + ), ); } expect(markup.size).toBe(4); From 0ef94baf337b28319a9a4675eedcd4b2608ac33f Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 1 Oct 2026 15:55:53 +0100 Subject: [PATCH 03/17] Harden player levels UI from PR review - Parse a malformed /users/@me progress object as absent instead of failing the whole profile (which made the player look signed out). - Decide "signed out" for the XP panel from auth state: a /users/@me failure with a live session now hides the panel rather than telling a signed-in player to sign in. The rule lives in ProgressionAccount and is shared by WinModal and GameStatsModal, so both accept a CrazyGames session. - Show a player who was already a Legend with the Legend frame for the whole reveal. - Remove each poll wait's abort listener when its timer fires. - Load the profile's level independently of the profile itself, still dropping a response for a superseded load. - Add a keyboard-reachable skip button for the reveal, and give the XP progress bars an accessible name and value text. - Translate the prestige tab's "P{prestige}" label. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 3 + src/client/GameStatsModal.ts | 10 +- src/client/PlayerProfileModal.ts | 16 ++- src/client/ProgressionAccount.ts | 34 +++++ src/client/ProgressionApi.ts | 21 +-- src/client/components/GameXpPanel.ts | 31 ++++- src/client/components/LevelBadge.ts | 2 +- src/client/components/XpBar.ts | 13 +- src/client/hud/layers/WinModal.ts | 22 +-- src/core/ApiSchemas.ts | 6 +- tests/client/LevelBadge.test.ts | 2 +- tests/client/ProgressionApi.test.ts | 125 ++++++++++++++++++ tests/client/ProgressionModals.test.ts | 62 +++++++++ tests/client/ProgressionSchemas.test.ts | 12 +- .../client/graphics/layers/WinModalXp.test.ts | 92 ++++++++++++- 15 files changed, 403 insertions(+), 48 deletions(-) create mode 100644 src/client/ProgressionAccount.ts create mode 100644 tests/client/ProgressionApi.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 625ad9949a..041f61c676 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1808,8 +1808,11 @@ "new_milestone": "New milestone!", "penalty_line": "−{percent}% XP ({source})", "prestige": "Prestige {prestige}", + "prestige_short": "P{prestige}", "sign_in_to_earn": "Sign in to earn XP and level up.", + "skip_reveal": "Skip", "tap_to_skip": "Tap to skip", + "xp_bar_label": "Level progress", "xp_heading": "XP earned", "xp_progress": "{current} / {next} XP", "xp_total": "+{xp} XP" diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts index 723e9dfee2..08b110e73e 100644 --- a/src/client/GameStatsModal.ts +++ b/src/client/GameStatsModal.ts @@ -1,13 +1,12 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { hasLinkedIdentity } from "./AccountIdentity"; -import { getUserMe } from "./Api"; import "./components/baseComponents/stats/GameInfoView"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; import "./components/GameXpPanel"; import type { GameXpPanelState } from "./components/GameXpPanel"; import { modalHeader } from "./components/ui/ModalHeader"; +import { resolveXpAccount } from "./ProgressionAccount"; import { fetchMyGameXp } from "./ProgressionApi"; import { translateText } from "./Utils"; @@ -68,10 +67,11 @@ export class GameStatsModal extends BaseModal { private async loadXp(gameId: string): Promise { const gen = ++this.xpGeneration; try { - const me = await getUserMe(); - if (!me || !hasLinkedIdentity(me.user)) return; + // The same "signed in" rule as the end-of-game panel. + const account = await resolveXpAccount(); + if (account.kind !== "signed_in") return; // No progress on /users/@me means progression is off. - if (me.player.progress === undefined) return; + if (account.me.player.progress === undefined) return; const result = await fetchMyGameXp(gameId); if (gen !== this.xpGeneration || this.gameId !== gameId) return; if (result.status === "ok" && result.data.eligible) { diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 690d6e9790..e94379775c 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -336,19 +336,23 @@ export class PlayerProfileModal extends BaseModal { private async loadProfile(publicId: string): Promise { const gen = ++this.loadGeneration; - const [profile, progress] = await Promise.all([ - fetchPublicPlayerProfile(publicId), - this.loadProgress(publicId), - ]); // Drop a superseded response: a newer load started, or the modal moved to a // different player. onClose no longer clears publicId, so the id check alone // can't reject a stale same-player load started before an earlier close. - if (gen !== this.loadGeneration || this.publicId !== publicId) return; + const current = () => + gen === this.loadGeneration && this.publicId === publicId; + // The level is a nice-to-have: it lands whenever it arrives and never + // holds up the profile (its request can take up to its own timeout). + // Only shown alongside a loaded profile (see renderProfile). + void this.loadProgress(publicId).then((progress) => { + if (current()) this.progress = progress; + }); + const profile = await fetchPublicPlayerProfile(publicId); + if (!current()) return; this.loading = false; this.statsTree = profile === false ? null : profile.stats; this.username = profile === false ? null : (profile.username ?? null); this.clans = profile === false ? [] : (profile.clans ?? []); - this.progress = profile === false ? null : progress; } // Intentionally preserves publicId/statsTree/history cache/scroll: the page diff --git a/src/client/ProgressionAccount.ts b/src/client/ProgressionAccount.ts new file mode 100644 index 0000000000..b03b75f310 --- /dev/null +++ b/src/client/ProgressionAccount.ts @@ -0,0 +1,34 @@ +import type { UserMeResponse } from "../core/ApiSchemas"; +import { hasLinkedIdentity } from "./AccountIdentity"; +import { getUserMe } from "./Api"; +import { isLoggedIn } from "./Auth"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; + +// Who is asking about XP. Shared by every XP surface (the end-of-game panel, +// the past-game stats modal) so they agree on what "signed in" means. +export type XpAccount = + // A real account: a linked identity, or a CrazyGames profile whose token + // exchange produced a session. Same rule as the account modal. + | { kind: "signed_in"; me: UserMeResponse } + // No session, or only a guest one (POST /auth/refresh with no cookie mints + // a guest account, so a session alone is not a sign-in). + | { kind: "signed_out" } + // There is a session but /users/@me failed (5xx, timeout, unreadable): this + // says nothing about the account, so it is neither of the above. + | { kind: "unknown" }; + +export async function resolveXpAccount(): Promise { + // Auth state first, so that `false` from getUserMe below can only mean a + // transient failure — Api.ts returns the same `false` for both. + if (!(await isLoggedIn())) return { kind: "signed_out" }; + const me = await getUserMe(); + if (me === false) return { kind: "unknown" }; + if (hasLinkedIdentity(me.user)) return { kind: "signed_in", me }; + if ( + crazyGamesSDK.isOnCrazyGames?.() && + (await crazyGamesSDK.getUserProfile()) !== null + ) { + return { kind: "signed_in", me }; + } + return { kind: "signed_out" }; +} diff --git a/src/client/ProgressionApi.ts b/src/client/ProgressionApi.ts index 14475e905e..b058f0acf8 100644 --- a/src/client/ProgressionApi.ts +++ b/src/client/ProgressionApi.ts @@ -84,16 +84,19 @@ export async function pollGameXp( if (result.status === "ok") return result.data; if (result.status === "unavailable") return null; if (Date.now() + intervalMs > deadline) return null; + const signal = opts.signal; await new Promise((resolve) => { - const timer = setTimeout(resolve, intervalMs); - opts.signal?.addEventListener( - "abort", - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); + // Whichever fires first tears down the other, so a long poll never + // piles one abort listener per wait onto the caller's signal. + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, intervalMs); + signal?.addEventListener("abort", onAbort, { once: true }); }); } } diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index d5244dcab6..f3c4808541 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -240,7 +240,10 @@ export class GameXpPanel extends LitElement { position: start, level: Math.min(MAX_LEVEL, Math.floor(start)), levelUp: null, - legend: false, + // A player who was already a Legend before this game shows the Legend + // frame throughout; one who becomes one this game gets it at the + // level-100 moment. + legend: data.after.legend && !reachedLegendThisGame(data), counted: 0, xpText: "before", linesShown: 0, @@ -504,7 +507,7 @@ export class GameXpPanel extends LitElement { data-xp-state=${stateName} data-xp-revealing=${revealing ? "true" : nothing} style=${style ?? nothing} - class="mb-4 rounded-lg bg-black/30 p-3 text-left text-white ${revealing + class="relative mb-4 rounded-lg bg-black/30 p-3 text-left text-white ${revealing ? "cursor-pointer" : ""}" aria-live="polite" @@ -512,6 +515,21 @@ export class GameXpPanel extends LitElement { title=${revealing ? translateText("progression.tap_to_skip") : nothing} @click=${() => this.skipReveal()} > + ${revealing + ? // Tapping anywhere skips; this is the same for the keyboard. Out of + // sight until focused, so the panel looks the same for a pointer. + html`` + : nothing} ${content} `; } @@ -631,6 +649,7 @@ export class GameXpPanel extends LitElement { private renderSegmentedBar( fillPercent: number, flashing: boolean, + valueText: string, ): TemplateResult { return html`
- ${this.renderSegmentedBar(fill, levelUp !== null)} + ${this.renderSegmentedBar(fill, levelUp !== null, progressText)}
- ${xpBar(levelFraction(after.xpInLevel, after.xpForNext) * 100)} + ${xpBar(levelFraction(after.xpInLevel, after.xpForNext) * 100, { + valueText: xpProgressText(after.xpInLevel, after.xpForNext), + })}
${this.renderBonuses(data)} ${this.renderBreakdown(data)} ${data.breakdown.leftEarly diff --git a/src/client/components/LevelBadge.ts b/src/client/components/LevelBadge.ts index 1432b7d2ee..d04cf15aab 100644 --- a/src/client/components/LevelBadge.ts +++ b/src/client/components/LevelBadge.ts @@ -309,7 +309,7 @@ export class LevelBadge extends LitElement { class="fill-yellow-200" textLength=${prestige >= 10 ? 12 : nothing} lengthAdjust="spacingAndGlyphs" - >P${prestige}`; + >${translateText("progression.prestige_short", { prestige })}`; } // The level frame and number, shrunk inside the prestige emblem when there diff --git a/src/client/components/XpBar.ts b/src/client/components/XpBar.ts index 3f5fb72b8a..3ca0df078a 100644 --- a/src/client/components/XpBar.ts +++ b/src/client/components/XpBar.ts @@ -1,4 +1,4 @@ -import { html, TemplateResult } from "lit"; +import { html, nothing, TemplateResult } from "lit"; import { translateText } from "../Utils"; // Shared bits of the level / XP UI: number formatting and the progress bar. @@ -19,19 +19,28 @@ export function xpProgressText(xpInLevel: number, xpForNext: number): string { /** * A thin XP bar. `percent` is 0..100. `durationMs` overrides the fill's * transition length when animating (the post-game reveal times each segment). + * `valueText` is what a screen reader announces for the value (typically + * xpProgressText); without it, the percentage. */ export function xpBar( percent: number, - opts: { animate?: boolean; heightClass?: string; durationMs?: number } = {}, + opts: { + animate?: boolean; + heightClass?: string; + durationMs?: number; + valueText?: string; + } = {}, ): TemplateResult { return html`
> | null, - ): Promise { - if (!me) return false; - if (hasLinkedIdentity(me.user)) return true; - if (!crazyGamesSDK.isOnCrazyGames?.()) return false; - return (await crazyGamesSDK.getUserProfile()) !== null; - } - private _handleExit() { this.hide(); window.location.href = homeHref(); diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 2a3b196806..5eee568b9b 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -346,8 +346,10 @@ export const UserMeResponseSchema = z.object({ rewards: RewardSchema.array().optional(), // Level / XP. Absent when progression is off (or on an API that predates // it): every level UI hides itself then. Optional rather than defaulted, - // so "absent" never renders as "level 1, 0 XP". - progress: ProgressSchema.optional(), + // so "absent" never renders as "level 1, 0 XP". A malformed object also + // reads as absent: drift in this cosmetic field must never fail the whole + // /users/@me parse, which would make the player look signed out. + progress: ProgressSchema.optional().catch(undefined), clans: z .array( z.object({ diff --git a/tests/client/LevelBadge.test.ts b/tests/client/LevelBadge.test.ts index d613bcb676..a7124c31b7 100644 --- a/tests/client/LevelBadge.test.ts +++ b/tests/client/LevelBadge.test.ts @@ -152,7 +152,7 @@ describe("", () => { it("labels the rank where there is room, and always in the name", async () => { let badge = await render({ level: 12, prestige: 3, size: 40 }); const texts = [...badge.querySelectorAll("text")].map((t) => t.textContent); - expect(texts).toEqual(["12", "P3"]); + expect(texts).toEqual(["12", 'progression.prestige_short:{"prestige":3}']); expect(badge.querySelector("svg")?.getAttribute("aria-label")).toContain( "progression.prestige", ); diff --git a/tests/client/ProgressionApi.test.ts b/tests/client/ProgressionApi.test.ts new file mode 100644 index 0000000000..e25aca8e2e --- /dev/null +++ b/tests/client/ProgressionApi.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Auth", () => ({ + getAuthHeader: vi.fn(async () => "Bearer test-token"), +})); + +vi.mock("../../src/client/ApiBase", () => ({ + getApiBase: () => "https://api.test", +})); + +import { + type GameXpFetchResult, + pollGameXp, +} from "../../src/client/ProgressionApi"; +import type { GameXpResponse } from "../../src/core/ApiSchemas"; + +const ineligible: GameXpResponse = { + gameId: "g1", + eligible: false, + reason: "too_short", +}; + +// Answers from a queue; once it runs dry, the last answer repeats. +function fetcherOf(answers: GameXpFetchResult[]) { + return vi.fn(async () => + answers.length > 1 ? answers.shift()! : answers[0], + ); +} + +// Counts the abort listeners currently attached to a signal. +function trackListeners(signal: AbortSignal) { + const live = new Set(); + const add = signal.addEventListener.bind(signal); + const remove = signal.removeEventListener.bind(signal); + vi.spyOn(signal, "addEventListener").mockImplementation( + (type: string, listener, opts) => { + if (type === "abort" && listener) live.add(listener); + add(type, listener, opts); + }, + ); + vi.spyOn(signal, "removeEventListener").mockImplementation( + (type: string, listener, opts) => { + if (type === "abort" && listener) live.delete(listener); + remove(type, listener, opts); + }, + ); + return () => live.size; +} + +describe("pollGameXp", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("asks again until the game is processed", async () => { + const fetcher = fetcherOf([ + { status: "pending" }, + { status: "pending" }, + { status: "ok", data: ineligible }, + ]); + const result = pollGameXp("g1", { intervalMs: 100, fetcher }); + await vi.advanceTimersByTimeAsync(1_000); + await expect(result).resolves.toEqual(ineligible); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("leaves no abort listener behind on the caller's signal", async () => { + const controller = new AbortController(); + const liveListeners = trackListeners(controller.signal); + const fetcher = fetcherOf([ + { status: "pending" }, + { status: "pending" }, + { status: "pending" }, + { status: "pending" }, + { status: "ok", data: ineligible }, + ]); + const result = pollGameXp("g1", { + intervalMs: 100, + signal: controller.signal, + fetcher, + }); + // Mid-poll there is at most the one wait's listener. + await vi.advanceTimersByTimeAsync(250); + expect(liveListeners()).toBeLessThanOrEqual(1); + await vi.advanceTimersByTimeAsync(1_000); + await expect(result).resolves.toEqual(ineligible); + expect(fetcher).toHaveBeenCalledTimes(5); + // Four waits, each one's listener removed when its timer fired. + expect(controller.signal.addEventListener).toHaveBeenCalledTimes(4); + expect(liveListeners()).toBe(0); + }); + + it("stops at once when the caller aborts mid-wait", async () => { + const controller = new AbortController(); + const fetcher = fetcherOf([{ status: "pending" }]); + const result = pollGameXp("g1", { + intervalMs: 10_000, + signal: controller.signal, + fetcher, + }); + await vi.advanceTimersByTimeAsync(0); + controller.abort(); + await vi.advanceTimersByTimeAsync(0); + await expect(result).resolves.toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("gives up at the deadline", async () => { + const fetcher = fetcherOf([{ status: "pending" }]); + const result = pollGameXp("g1", { + intervalMs: 100, + timeoutMs: 1_000, + fetcher, + }); + await vi.advanceTimersByTimeAsync(2_000); + await expect(result).resolves.toBeNull(); + expect(fetcher.mock.calls.length).toBeLessThanOrEqual(11); + }); +}); diff --git a/tests/client/ProgressionModals.test.ts b/tests/client/ProgressionModals.test.ts index 88af119e5f..14437f5e6d 100644 --- a/tests/client/ProgressionModals.test.ts +++ b/tests/client/ProgressionModals.test.ts @@ -14,11 +14,18 @@ const { fetchPublicPlayerProfile, fetchPublicPlayerProgress, fetchMyGameXp, + isLoggedIn, + crazyGamesSDK, } = vi.hoisted(() => ({ getUserMe: vi.fn(), fetchPublicPlayerProfile: vi.fn(), fetchPublicPlayerProgress: vi.fn(), fetchMyGameXp: vi.fn(), + isLoggedIn: vi.fn(async () => true), + crazyGamesSDK: { + isOnCrazyGames: vi.fn(() => false), + getUserProfile: vi.fn(async (): Promise => null), + }, })); vi.mock("../../src/client/Utils", () => ({ @@ -33,6 +40,10 @@ vi.mock("../../src/client/Api", () => ({ fetchPublicPlayerProfile, })); +vi.mock("../../src/client/Auth", () => ({ isLoggedIn })); + +vi.mock("../../src/client/CrazyGamesSDK", () => ({ crazyGamesSDK })); + vi.mock("../../src/client/ProgressionApi", () => ({ fetchPublicPlayerProgress, fetchMyGameXp, @@ -217,6 +228,43 @@ describe("player profile level", () => { await open("no-progress"); expect(modal.querySelector("[data-profile-level]")).toBeNull(); }); + + it("shows the profile without waiting for the level, which lands later", async () => { + let resolveProgress!: (p: unknown) => void; + fetchPublicPlayerProgress.mockReturnValue( + new Promise((resolve) => (resolveProgress = resolve)), + ); + await open("slow-progress"); + // The stats are up while the level is still on its way. + expect(modal.querySelector("[data-profile-level]")).toBeNull(); + resolveProgress({ + prestige: 0, + level: 33, + lifetimeXp: 9000, + legend: false, + }); + await vi.waitFor(async () => { + await settled(modal); + expect( + modal.querySelector("[data-profile-level]")?.textContent, + ).toContain('progression.level:{"level":33}'); + }); + }); + + it("never puts a stale level on another player's profile", async () => { + let resolveFirst!: (p: unknown) => void; + fetchPublicPlayerProgress.mockImplementation((publicId: string) => + publicId === "first" + ? new Promise((resolve) => (resolveFirst = resolve)) + : Promise.resolve(false), + ); + await open("first"); + await open("second"); + resolveFirst({ prestige: 0, level: 77, lifetimeXp: 1, legend: false }); + await new Promise((r) => setTimeout(r, 0)); + await settled(modal); + expect(modal.querySelector("[data-profile-level]")).toBeNull(); + }); }); describe("game stats XP", () => { @@ -291,6 +339,20 @@ describe("game stats XP", () => { expect(await panelState()).toBeNull(); }); + it("shows XP to a CrazyGames player, the same as the end-of-game panel", async () => { + getUserMe.mockResolvedValue({ ...me, user: {} }); + crazyGamesSDK.isOnCrazyGames.mockReturnValue(true); + crazyGamesSDK.getUserProfile.mockResolvedValue({ username: "cg" }); + fetchMyGameXp.mockResolvedValue({ status: "ok", data: eligibleXp }); + try { + await open("g6"); + await vi.waitFor(async () => expect(await panelState()).toBe("result")); + } finally { + crazyGamesSDK.isOnCrazyGames.mockReturnValue(false); + crazyGamesSDK.getUserProfile.mockResolvedValue(null); + } + }); + it("does not ask for a signed-out viewer", async () => { getUserMe.mockResolvedValue({ user: {}, player: { publicId: "anon" } }); await open("g4"); diff --git a/tests/client/ProgressionSchemas.test.ts b/tests/client/ProgressionSchemas.test.ts index a300cf4af8..eca496cc44 100644 --- a/tests/client/ProgressionSchemas.test.ts +++ b/tests/client/ProgressionSchemas.test.ts @@ -65,11 +65,19 @@ describe("UserMeResponseSchema progress", () => { expect(parsed.player.progress).toEqual(progress); }); - it("rejects a malformed progress object rather than guessing", () => { + it("drops a malformed progress object but still parses the profile", () => { const parsed = UserMeResponseSchema.safeParse( userMe({ progress: { level: "ten" } }), ); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); + expect(parsed.data?.player.progress).toBeUndefined(); + expect(parsed.data?.player.publicId).toBe("abc"); + }); + + it("drops a non-object progress value but still parses the profile", () => { + const parsed = UserMeResponseSchema.safeParse(userMe({ progress: "x" })); + expect(parsed.success).toBe(true); + expect(parsed.data?.player.progress).toBeUndefined(); }); }); diff --git a/tests/client/graphics/layers/WinModalXp.test.ts b/tests/client/graphics/layers/WinModalXp.test.ts index 8451ff276d..990a73feb0 100644 --- a/tests/client/graphics/layers/WinModalXp.test.ts +++ b/tests/client/graphics/layers/WinModalXp.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { getUserMe, fetchProgressionConfig } = vi.hoisted(() => ({ +const { getUserMe, fetchProgressionConfig, isLoggedIn } = vi.hoisted(() => ({ getUserMe: vi.fn(), fetchProgressionConfig: vi.fn(), + isLoggedIn: vi.fn(), })); vi.mock("../../../../src/client/Utils", () => ({ @@ -18,6 +19,7 @@ vi.mock("../../../../src/client/Api", () => ({ getUserMe })); vi.mock("../../../../src/client/Auth", () => ({ getAuthHeader: vi.fn(async () => "Bearer test-token"), + isLoggedIn, })); vi.mock("../../../../src/client/ApiBase", () => ({ @@ -164,6 +166,7 @@ describe("WinModal XP section", () => { beforeEach(() => { vi.useFakeTimers(); getUserMe.mockResolvedValue(signedIn); + isLoggedIn.mockResolvedValue(true); fetchProgressionConfig.mockResolvedValue({ version: 1, maxLevel: 100, @@ -443,6 +446,46 @@ describe("WinModal XP section", () => { expect(panel()!.querySelector("[data-xp-next-badge]")).toBeNull(); }); + it("shows a player who was already a Legend as one throughout the reveal", async () => { + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 10, level: 100, xpInLevel: 0, xpForNext: 0 }, + after: { + ...eligible().after, + prestige: 10, + level: 100, + xpInLevel: 0, + xpForNext: 0, + legend: true, + lifetimeXp: 2200000, + }, + levelsReached: [], + }), + ), + ]); + await mount(makeGame({ ended: true })); + const currentLegend = () => + ( + panel()!.querySelector("[data-xp-current-badge] level-badge") as + | (HTMLElement & { legend: boolean; level: number }) + | null + )?.legend; + expect(panel()!.getAttribute("data-xp-revealing")).toBe("true"); + expect(currentLegend()).toBe(true); + // And every frame of the reveal after it, not just the first. + for (let i = 0; i < 400; i++) { + await settle(50); + if (panel()?.getAttribute("data-xp-revealing") !== "true") break; + expect(currentLegend()).toBe(true); + } + expect(currentLegend()).toBe(true); + // Not this game's moment: no Legend card or caption. + expect(panel()!.querySelector("[data-xp-legend]")).toBeNull(); + expect(panel()!.querySelector("[data-xp-caption-legend]")).toBeNull(); + }); + it("reveals the XP a source at a time, then settles on the award", async () => { stubXpEndpoint([() => json(eligible())]); await mount(makeGame({ ended: true })); @@ -721,6 +764,31 @@ describe("WinModal XP section", () => { ); }); + it("skips from the keyboard with a real button", async () => { + stubXpEndpoint([() => json(eligible())]); + await mount(makeGame({ ended: true })); + const skip = panel()!.querySelector("[data-xp-skip]"); + expect(skip).not.toBeNull(); + expect(skip!.tagName).toBe("BUTTON"); + expect(skip!.textContent!.trim()).toBe("progression.skip_reveal"); + // A button's Enter / Space activation is a click. + skip!.click(); + await settle(); + expect(panel()!.getAttribute("data-xp-revealing")).toBeNull(); + expect(panel()!.querySelector("[data-xp-skip]")).toBeNull(); + }); + + it("names the progress bar for assistive tech", async () => { + stubXpEndpoint([() => json(eligible())]); + await mount(makeGame({ ended: true })); + await finishReveal(); + const bar = panel()!.querySelector("[data-xp-bar]")!; + expect(bar.getAttribute("aria-label")).toBe("progression.xp_bar_label"); + expect(bar.getAttribute("aria-valuetext")).toBe( + 'progression.xp_progress:{"current":"512","next":"400"}', + ); + }); + it("asks a signed-out player to sign in, without polling", async () => { getUserMe.mockResolvedValue(anonymous); const fetchMock = stubXpEndpoint([notFound]); @@ -731,8 +799,30 @@ describe("WinModal XP section", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("asks a player with no session at all to sign in", async () => { + isLoggedIn.mockResolvedValue(false); + getUserMe.mockResolvedValue(false); + const fetchMock = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + expect(xpState()).toBe("signed_out"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("says nothing, rather than 'sign in', when /users/@me fails for a session", async () => { + // A 5xx or a timeout: getUserMe answers false, as for a signed-out + // player, but there is a session, so this says nothing about the account. + getUserMe.mockResolvedValue(false); + fetchProgressionConfig.mockClear(); + const fetchMock = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + expect(xpState()).toBe("hidden"); + expect(fetchProgressionConfig).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("says nothing at all when progression is off", async () => { fetchProgressionConfig.mockResolvedValue(false); + isLoggedIn.mockResolvedValue(false); getUserMe.mockResolvedValue(false); const fetchMock = stubXpEndpoint([notFound]); await mount(makeGame({ ended: true })); From b899cb04d44d47b523e3a4792fab9cd4295c9070 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 2 Oct 2026 11:48:07 +0100 Subject: [PATCH 04/17] Address review on player levels: unverified games, off-screen reveals, abortable XP fetch - Explain games recorded ineligible as "unverified" (stats the votes did not agree on) instead of the generic line. - Only play the XP reveal while the win modal is on screen: a result that lands after it is dismissed shows in its final state, and dismissing it mid-reveal jumps to the end. A panel removed mid-reveal also ends it, so it never comes back half-revealed and aria-busy. - Pass pollGameXp's abort signal into the request itself (combined with its timeout via AbortSignal.any), so aborting cancels a request in flight. - Default the breakdown's per-source fields and leftEarly, so XP rows scored under the first XP rules (no `played`/`leftEarly`) still parse. - Read your own profile's level from the public endpoint, not the /users/@me copy cached at page load. - Put the level in the nav account button's accessible name, which its aria-label otherwise hid from screen readers. - Drop xpBar's unused animate/durationMs options and fix the milestone comment that mentioned a share button. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 2 + src/client/LangSelector.ts | 1 + src/client/PlayerProfileModal.ts | 11 ++--- src/client/Progression.ts | 7 +++- src/client/ProgressionApi.ts | 23 ++++++++-- src/client/components/GameXpPanel.ts | 26 ++++++++---- src/client/components/NavAccountMenu.ts | 18 +++++++- src/client/components/XpBar.ts | 22 +++------- src/client/hud/layers/WinModal.ts | 5 ++- src/core/ApiSchemas.ts | 18 ++++---- tests/client/LevelBadge.test.ts | 9 ++++ tests/client/NavAccountMenuLevel.test.ts | 19 +++++++++ tests/client/ProgressionApi.test.ts | 30 +++++++++++++ tests/client/ProgressionModals.test.ts | 23 +++++++--- tests/client/ProgressionSchemas.test.ts | 15 +++++++ .../client/graphics/layers/WinModalXp.test.ts | 42 +++++++++++++++++++ 16 files changed, 219 insertions(+), 52 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 15089f1195..63e7d4384b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1567,6 +1567,7 @@ }, "nav_account_menu": { "account_settings": "Account settings", + "account_with_level": "Account ({level})", "change_subscription": "Change subscription", "change_username": "Change username", "copy_profile_url": "Copy profile URL", @@ -1787,6 +1788,7 @@ "ineligible_not_spawned": "You didn't spawn, so this game didn't earn XP.", "ineligible_too_few_humans": "Not enough human players in this game to earn XP.", "ineligible_too_short": "This game was too short to earn XP.", + "ineligible_unverified": "This game's results couldn't be verified, so it didn't earn XP.", "left_early": "You left before being eliminated, so the game bonus, placement and win XP were forfeited.", "legend": "Legend", "legend_reached_body": "The top of the track. {xp} lifetime XP.", diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 2ed41541d0..feb9611a4c 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -277,6 +277,7 @@ export class LangSelector extends LitElement { "purchase-button", "custom-currency-card", "fluent-slider", + "nav-account-menu", "news-modal", "account-modal", "game-stats-modal", diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index e94379775c..f395fff1a7 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -7,7 +7,7 @@ import { type PlayerStatsTree, type PublicProgress, } from "../core/ApiSchemas"; -import { fetchPublicPlayerProfile, getUserMe } from "./Api"; +import { fetchPublicPlayerProfile } from "./Api"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTree"; @@ -263,14 +263,11 @@ export class PlayerProfileModal extends BaseModal { `; } - // Your own level comes from /users/@me (already cached for the session); - // anyone else's from the public endpoint. Either missing hides the summary. + // Everyone's level, your own included, comes from the public endpoint: the + // /users/@me copy is cached from page load and would miss the games played + // since. Missing progress hides the summary. private async loadProgress(publicId: string): Promise { try { - const me = await getUserMe(); - if (me && me.player.publicId === publicId) { - return me.player.progress ?? null; - } const progress = await fetchPublicPlayerProgress(publicId); return progress === false ? null : progress; } catch { diff --git a/src/client/Progression.ts b/src/client/Progression.ts index 2871ae2889..3d11fdcc6f 100644 --- a/src/client/Progression.ts +++ b/src/client/Progression.ts @@ -39,8 +39,9 @@ export function prestigeTier(prestige: number): PrestigeTier { return "radiant"; } -// Levels called out when reached: the same set the level-up screen offers a -// share button on. +// Levels called out when reached: the end-of-game XP panel gives one reached +// this game its own "new milestone" card (100 is the Legend card at the last +// prestige). export const MILESTONE_LEVELS: readonly number[] = [10, 25, 50, 75, 100]; export function isMilestoneLevel(level: number): boolean { @@ -190,6 +191,8 @@ const INELIGIBLE_REASON_KEYS: Record = { custom_settings: "progression.ineligible_custom_settings", too_few_humans: "progression.ineligible_too_few_humans", daily_cap: "progression.ineligible_daily_cap", + // The game's stats were not agreed on by the players' votes. + unverified: "progression.ineligible_unverified", }; export function ineligibleReasonKey(reason: string): string { diff --git a/src/client/ProgressionApi.ts b/src/client/ProgressionApi.ts index b058f0acf8..99f9afc793 100644 --- a/src/client/ProgressionApi.ts +++ b/src/client/ProgressionApi.ts @@ -22,9 +22,21 @@ export type GameXpFetchResult = // A conclusion: signed out, or a body we cannot read. Stop asking. | { status: "unavailable" }; +// The request's own timeout, plus the caller's signal when there is one, so +// aborting cancels a request already in flight rather than leaving it to run +// to its timeout. Without AbortSignal.any (older browsers), just the timeout. +function requestSignal(signal: AbortSignal | undefined): AbortSignal { + const timeout = AbortSignal.timeout(10_000); + if (signal === undefined || typeof AbortSignal.any !== "function") { + return timeout; + } + return AbortSignal.any([signal, timeout]); +} + // GET /users/@me/xp/:gameId — the XP the caller earned in one game. export async function fetchMyGameXp( gameId: string, + signal?: AbortSignal, ): Promise { try { const authorization = await getAuthHeader(); @@ -33,7 +45,7 @@ export async function fetchMyGameXp( `${getApiBase()}/users/@me/xp/${encodeURIComponent(gameId)}`, { headers: { Accept: "application/json", Authorization: authorization }, - signal: AbortSignal.timeout(10_000), + signal: requestSignal(signal), }, ); if (res.status === 404) return { status: "pending" }; @@ -51,6 +63,8 @@ export async function fetchMyGameXp( } return { status: "ok", data: parsed.data }; } catch (err) { + // The caller gave up: not a failure worth a warning. + if (signal?.aborted) return { status: "pending" }; console.warn("fetchMyGameXp: request failed", err); return { status: "pending" }; } @@ -61,7 +75,10 @@ export interface PollGameXpOptions { timeoutMs?: number; signal?: AbortSignal; // Injectable for tests. - fetcher?: (gameId: string) => Promise; + fetcher?: ( + gameId: string, + signal?: AbortSignal, + ) => Promise; } /** @@ -79,7 +96,7 @@ export async function pollGameXp( const deadline = Date.now() + timeoutMs; for (;;) { if (opts.signal?.aborted) return null; - const result = await fetcher(gameId); + const result = await fetcher(gameId, opts.signal); if (opts.signal?.aborted) return null; if (result.status === "ok") return result.data; if (result.status === "unavailable") return null; diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index f3c4808541..f3b4bd5881 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -137,6 +137,10 @@ export class GameXpPanel extends LitElement { @property({ attribute: false }) view: GameXpPanelState = { kind: "hidden" }; // Past-game view (GameStatsModal): a static summary, no reveal. @property({ type: Boolean }) compact = false; + // Whether anyone can see the panel (WinModal hides it with its modal). The + // reveal only plays on screen: a result that arrives while it is off screen + // shows in its final state, and going off screen mid-reveal ends it there. + @property({ attribute: false }) onScreen = true; // The reveal in progress, or null once it has finished (or when there is // none: the compact view, reduced motion, a skip). Null renders the final @@ -152,18 +156,22 @@ export class GameXpPanel extends LitElement { } disconnectedCallback(): void { - this.clearTimers(); + // 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(); super.disconnectedCallback(); } protected willUpdate(changed: PropertyValues): void { - if (!changed.has("view") && !changed.has("compact")) return; - const s = this.view; - if (s.kind === "result" && s.data.eligible) { - this.startReveal(s.data); - } else { - this.clearTimers(); - this.reveal = null; + if (changed.has("view") || changed.has("compact")) { + const s = this.view; + if (s.kind === "result" && s.data.eligible && this.onScreen) { + this.startReveal(s.data); + } else { + this.skipReveal(); + } + } else if (changed.has("onScreen") && !this.onScreen) { + this.skipReveal(); } } @@ -456,8 +464,8 @@ export class GameXpPanel extends LitElement { }); } + // Ends any reveal on its final state (a no-op when none is playing). private skipReveal(): void { - if (this.reveal === null) return; this.clearTimers(); this.barAnimate = false; this.reveal = null; diff --git a/src/client/components/NavAccountMenu.ts b/src/client/components/NavAccountMenu.ts index 7e28ba96a4..5d087bc6ef 100644 --- a/src/client/components/NavAccountMenu.ts +++ b/src/client/components/NavAccountMenu.ts @@ -387,6 +387,20 @@ export class NavAccountMenu extends LitElement { return this.userMeResponse.player.progress ?? null; } + // The trigger's accessible name. Its aria-label would otherwise hide the + // level badge inside it from screen readers, so the level is in it too. + private triggerLabel(): string { + const progress = this.progress(); + if (progress === null) return translateText("main.account"); + return translateText("nav_account_menu.account_with_level", { + level: levelBadgeLabel( + progress.level, + progress.prestige, + progress.legend, + ), + }); + } + // Level badge beside the avatar, plus a thin XP bar along the trigger's // bottom edge. Absent entirely when /users/@me carries no progress. private renderLevel( @@ -504,7 +518,7 @@ export class NavAccountMenu extends LitElement { @click=${this.handleTriggerClick} class="nav-menu-item relative h-10 rounded-full flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white" data-page="page-account" - data-i18n-aria-label="main.account" + aria-label=${this.triggerLabel()} data-i18n-title="main.account" > ${this.renderLevel("desktop")} @@ -535,7 +549,7 @@ export class NavAccountMenu extends LitElement { @click=${this.handleTriggerClick} class="nav-menu-item h-10 flex items-center justify-center gap-1 pl-1 pr-1.5 rounded-full text-white/90 cursor-pointer transition-colors" data-page="page-account" - data-i18n-aria-label="main.account" + aria-label=${this.triggerLabel()} data-i18n-title="main.account" > diff --git a/src/client/components/XpBar.ts b/src/client/components/XpBar.ts index 3ca0df078a..c23b2d4970 100644 --- a/src/client/components/XpBar.ts +++ b/src/client/components/XpBar.ts @@ -17,19 +17,13 @@ export function xpProgressText(xpInLevel: number, xpForNext: number): string { } /** - * A thin XP bar. `percent` is 0..100. `durationMs` overrides the fill's - * transition length when animating (the post-game reveal times each segment). - * `valueText` is what a screen reader announces for the value (typically - * xpProgressText); without it, the percentage. + * A thin XP bar. `percent` is 0..100. `valueText` is what a screen reader + * announces for the value (typically xpProgressText); without it, the + * percentage. */ export function xpBar( percent: number, - opts: { - animate?: boolean; - heightClass?: string; - durationMs?: number; - valueText?: string; - } = {}, + opts: { heightClass?: string; valueText?: string } = {}, ): TemplateResult { return html`
`; } diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index b0bff0404c..d592223604 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -86,7 +86,10 @@ export class WinModal extends LitElement implements Controller { ${this._title || ""}
- + ${this.innerHtml()}
+
+ ${progressText} From 6a758f1897bea8f43e5462e64c4139c743768672 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 19:31:10 +0100 Subject: [PATCH 11/17] Show the XP a past game earned under its summary card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stats modal's compact reveal panel becomes a static card between the game summary and the tabs: the total, the level change (with a Milestone chip and glow at 10/25/50/75/100) or the level and progress, each source at its base value in three columns (one on a phone), and each multiplier as its own line with the XP it added. The XP endpoint only returns the caller's own record, so any 200 is shown, including why a game didn't earn XP and the coming before_progression reason. A 404 shows "Calculating XP…" and polls only for the player's own game (account history or own profile) that ended in the last 10 minutes; otherwise it shows nothing, as before. The API rounds once over subtotal x game x subscriber, so a multiplier's amount is total - subtotal only when it is the only one; with both, the lines show no amount rather than a guessed split. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 5 + src/client/GameStatsModal.ts | 122 +++++-- src/client/PlayerProfileModal.ts | 6 +- src/client/Progression.ts | 37 ++ src/client/components/GameXpPanel.ts | 107 +----- src/client/components/PastGameXpCard.ts | 328 ++++++++++++++++++ src/client/components/XpBar.ts | 24 +- .../baseComponents/stats/GameInfoView.ts | 26 ++ tests/client/LevelBadge.test.ts | 42 +++ tests/client/PastGameXpCard.test.ts | 266 ++++++++++++++ tests/client/ProgressionModals.test.ts | 230 ++++++++++-- 11 files changed, 1036 insertions(+), 157 deletions(-) create mode 100644 src/client/components/PastGameXpCard.ts create mode 100644 tests/client/PastGameXpCard.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 761116903f..c70a4fab82 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1794,6 +1794,7 @@ "legend_reached_body": "The top of the track. {xp} lifetime XP.", "legend_reached_title": "You are a Legend!", "level": "Level {level}", + "level_change": "Level {from} → {to}", "level_reached": "Level {level}!", "lifetime_xp": "{xp} lifetime XP", "line_feats": "Feats", @@ -1803,6 +1804,7 @@ "line_time": "Time played", "line_win": "Win", "max_level": "Max level", + "milestone_chip": "Milestone", "multiplier_game": "Game type", "multiplier_sovereign": "Sovereign bonus", "multiplier_subscriber": "Subscriber bonus", @@ -1816,7 +1818,10 @@ "skip_reveal": "Skip", "tap_to_skip": "Tap to skip", "xp_bar_label": "Level progress", + "xp_before_levels": "Played before levels existed, so there's no XP for this game.", "xp_heading": "XP earned", + "xp_minus": "−{xp}", + "xp_plus": "+{xp}", "xp_progress": "{current} / {next} XP", "xp_total": "+{xp} XP" }, diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts index 08b110e73e..ac066f973a 100644 --- a/src/client/GameStatsModal.ts +++ b/src/client/GameStatsModal.ts @@ -1,24 +1,38 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import "./components/baseComponents/stats/GameInfoView"; +import type { GameInfoLoadedDetail } from "./components/baseComponents/stats/GameInfoView"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; -import "./components/GameXpPanel"; -import type { GameXpPanelState } from "./components/GameXpPanel"; +import "./components/PastGameXpCard"; +import type { PastGameXpView } from "./components/PastGameXpCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { resolveXpAccount } from "./ProgressionAccount"; -import { fetchMyGameXp } from "./ProgressionApi"; +import { fetchMyGameXp, pollGameXp } from "./ProgressionApi"; import { translateText } from "./Utils"; +// How long after a game ends a missing XP record still reads as "being +// scored" rather than "not yours": the server scores a game within moments, +// and retries a failed one well inside this. +export const RECENT_GAME_MS = 10 * 60_000; + @customElement("game-stats-modal") export class GameStatsModal extends BaseModal { protected routerName = "stats"; @state() private gameId: string | null = null; // The XP the signed-in player earned in this game, if any. - @state() private xpView: GameXpPanelState = { kind: "hidden" }; - private xpGeneration = 0; + @state() private xpView: PastGameXpView = { kind: "hidden" }; + // Cancels the current XP lookup (and its polling) on close or a new game. + private xpAbort: AbortController | null = null; private openedFrom: "account" | "clan" | "profile" | null = null; + // Whose profile the game was opened from, when opened from a profile. + private profilePublicId: string | null = null; + // The loaded game's end time, once GameInfoView has it (null: it failed + // to load), and whoever is waiting for it. Keyed by game, so it never + // answers for another one. + private gameEnd: { gameId: string; end: number | null } | null = null; + private gameEndWaiters: (() => void)[] = []; protected modalConfig() { return { maxWidth: "960px" }; @@ -46,8 +60,13 @@ export class GameStatsModal extends BaseModal { protected renderBody() { return html`
- - + `} + @game-info-loaded=${this.onGameInfoLoaded} + >
`; } @@ -57,50 +76,111 @@ export class GameStatsModal extends BaseModal { typeof args?.gameID === "string" && args.gameID.length > 0 ? args.gameID : null; - this.xpView = { kind: "hidden" }; - if (this.gameId !== null) void this.loadXp(this.gameId); + this.resetXp(); + if (this.gameId !== null) { + this.xpAbort = new AbortController(); + void this.loadXp(this.gameId, this.xpAbort.signal); + } } - // One look, no polling: a past game is either processed or it never will - // be. Only an eligible result is shown — this modal also opens other - // players' games, where "you didn't spawn" would read as a verdict on them. - private async loadXp(gameId: string): Promise { - const gen = ++this.xpGeneration; + private onGameInfoLoaded = (e: CustomEvent): void => { + this.gameEnd = { gameId: e.detail.gameId, end: e.detail.info?.end ?? null }; + const waiters = this.gameEndWaiters; + this.gameEndWaiters = []; + for (const wake of waiters) wake(); + }; + + // When the game ended, from the record GameInfoView loads (no second + // request for it). Null if it failed to load or the lookup was cancelled. + private async gameEndedAt( + gameId: string, + signal: AbortSignal, + ): Promise { + while (!signal.aborted) { + if (this.gameEnd?.gameId === gameId) return this.gameEnd.end; + await new Promise((resolve) => { + const wake = () => { + signal.removeEventListener("abort", wake); + resolve(); + }; + this.gameEndWaiters.push(wake); + signal.addEventListener("abort", wake, { once: true }); + }); + } + return null; + } + + // The endpoint only ever returns the signed-in player's own record, so any + // answer is theirs to see: the XP earned, or why the game didn't earn any + // (including a game played before levels existed). A 404 is either a game + // still being scored or someone else's game: it reads as "calculating" only + // for the player's own game, opened from their own history, that ended in + // the last few minutes — and then it is polled like the end-of-game screen. + // Anything else shows nothing. + private async loadXp(gameId: string, signal: AbortSignal): Promise { try { // The same "signed in" rule as the end-of-game panel. const account = await resolveXpAccount(); - if (account.kind !== "signed_in") return; + if (signal.aborted || account.kind !== "signed_in") return; // No progress on /users/@me means progression is off. if (account.me.player.progress === undefined) return; - const result = await fetchMyGameXp(gameId); - if (gen !== this.xpGeneration || this.gameId !== gameId) return; - if (result.status === "ok" && result.data.eligible) { + const result = await fetchMyGameXp(gameId, signal); + if (signal.aborted) return; + if (result.status === "ok") { this.xpView = { kind: "result", data: result.data }; + return; } + if (result.status === "unavailable") return; + const ownHistory = + this.openedFrom === "account" || + (this.openedFrom === "profile" && + this.profilePublicId !== null && + this.profilePublicId === account.me.player.publicId); + if (!ownHistory) return; + const end = await this.gameEndedAt(gameId, signal); + if (signal.aborted || end === null) return; + if (Date.now() - end > RECENT_GAME_MS) return; + this.xpView = { kind: "calculating" }; + const polled = await pollGameXp(gameId, { signal }); + if (signal.aborted) return; + this.xpView = + polled === null ? { kind: "hidden" } : { kind: "result", data: polled }; } catch (err) { console.warn("GameStatsModal: XP lookup failed", err); + if (!signal.aborted) this.xpView = { kind: "hidden" }; } } - protected onClose(): void { - this.xpGeneration++; + private resetXp(): void { + this.xpAbort?.abort(); + this.xpAbort = null; this.xpView = { kind: "hidden" }; + } + + protected onClose(): void { + this.resetXp(); this.gameId = null; this.openedFrom = null; + this.profilePublicId = null; } public openFromAccount(gameId: string): void { this.openedFrom = "account"; + this.profilePublicId = null; this.open({ gameID: gameId }); } public openFromClan(gameId: string): void { this.openedFrom = "clan"; + this.profilePublicId = null; this.open({ gameID: gameId }); } - public openFromProfile(gameId: string): void { + // `profilePublicId` is whose profile the game was listed on: the player's + // own profile counts as their own history. + public openFromProfile(gameId: string, profilePublicId?: string): void { this.openedFrom = "profile"; + this.profilePublicId = profilePublicId ?? null; this.open({ gameID: gameId }); } diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index f395fff1a7..0881cfa258 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -363,9 +363,11 @@ export class PlayerProfileModal extends BaseModal { private openGameStats(gameId: string): void { this.gamesScrollTop = this.modalEl?.getScrollTop() ?? 0; const statsModal = document.querySelector< - HTMLElement & { openFromProfile(gameId: string): void } + HTMLElement & { + openFromProfile(gameId: string, profilePublicId?: string): void; + } >("game-stats-modal"); - statsModal?.openFromProfile(gameId); + statsModal?.openFromProfile(gameId, this.publicId ?? undefined); } private viewGame(gameId: string): void { diff --git a/src/client/Progression.ts b/src/client/Progression.ts index 5322826bb9..331ecae915 100644 --- a/src/client/Progression.ts +++ b/src/client/Progression.ts @@ -132,6 +132,16 @@ export const XP_LINE_KEYS: readonly XpLineKey[] = [ "feats", ]; +// Each breakdown line's label. +export const XP_LINE_LABEL_KEYS: Record = { + played: "progression.line_played", + time: "progression.line_time", + placement: "progression.line_placement", + win: "progression.line_win", + firstGame: "progression.line_first_game", + feats: "progression.line_feats", +}; + /** The breakdown lines worth showing: the non-zero ones, in a fixed order. */ export function visibleXpLines( breakdown: XpBreakdown, @@ -155,6 +165,28 @@ export function visibleMultipliers( return out; } +/** + * The multipliers worth showing, each with the XP it added (negative for a + * cut), so that the sources and these lines add up to the total — or null + * where that can't be known. + * + * The API applies both multipliers in one step and rounds once: + * total = round(subtotal × game × subscriber). With one multiplier in play, + * what it added is exactly total − subtotal. With both, how much each added + * depends on which is applied first, and the breakdown doesn't say — so + * neither line gets an amount rather than one that might be wrong. + */ +export function multiplierAmounts( + breakdown: XpBreakdown, +): { key: "game" | "subscriber"; permille: number; amount: number | null }[] { + const multipliers = visibleMultipliers(breakdown); + const exact = multipliers.length === 1; + return multipliers.map((m) => ({ + ...m, + amount: exact ? breakdown.total - breakdown.subtotal : null, + })); +} + /** * Splits `total` across `amounts` in proportion, as whole numbers that add up * to exactly `total` (largest remainder). It is how the breakdown shows each @@ -181,6 +213,9 @@ export function apportionXp( return out; } +// The ineligibility reason for a game played before player levels existed. +export const BEFORE_PROGRESSION = "before_progression"; + // Ineligibility reasons with their own copy. Anything else — including reasons // the API adds later — gets the generic line. Keys spelled out in full so the // translation checks can see them. @@ -193,6 +228,8 @@ const INELIGIBLE_REASON_KEYS: Record = { daily_cap: "progression.ineligible_daily_cap", // The game's stats were not agreed on by the players' votes. unverified: "progression.ineligible_unverified", + // Played before player levels launched: never scored. + [BEFORE_PROGRESSION]: "progression.xp_before_levels", }; export function ineligibleReasonKey(reason: string): string { diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index 24e24d08ab..6259b8e23d 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -9,16 +9,16 @@ import { levelFraction, levelsReachedInGame, MAX_LEVEL, - multiplierPercent, reachedLegendThisGame, subscriberTierOf, visibleMultipliers, visibleXpLines, + XP_LINE_LABEL_KEYS, type XpLineKey, } from "../Progression"; import { translateText } from "../Utils"; import "./LevelBadge"; -import { formatXp, xpBar, xpProgressText } from "./XpBar"; +import { formatXp, xpBonusText, xpProgressText } from "./XpBar"; // What the XP section of a finished game is showing. The caller owns the // fetching; this element only renders, so every state can be set directly. @@ -33,15 +33,6 @@ export type GameXpPanelState = | { kind: "calculating" } | { kind: "result"; data: GameXpResponse }; -const LINE_LABEL_KEYS: Record = { - played: "progression.line_played", - time: "progression.line_time", - placement: "progression.line_placement", - win: "progression.line_win", - firstGame: "progression.line_first_game", - feats: "progression.line_feats", -}; - // Segments the big bar is drawn in. The gaps are decoration: the fill is one // continuous sweep underneath them. const BAR_SEGMENTS = 10; @@ -135,15 +126,13 @@ function finalLineValues(data: GameXpEligible): number[] { @customElement("game-xp-panel") export class GameXpPanel extends LitElement { @property({ attribute: false }) view: GameXpPanelState = { kind: "hidden" }; - // Past-game view (GameStatsModal): a static summary, no reveal. - @property({ type: Boolean }) compact = false; // Whether anyone can see the panel (WinModal hides it with its modal). The // reveal only plays on screen: a result that arrives while it is off screen // shows in its final state, and going off screen mid-reveal ends it there. @property({ attribute: false }) onScreen = true; // The reveal in progress, or null once it has finished (or when there is - // none: the compact view, reduced motion, a skip). Null renders the final + // none: reduced motion, a skip). Null renders the final // state, which is also where the reveal ends. @state() private reveal: RevealFrame | null = null; @state() private barAnimate = false; @@ -163,7 +152,7 @@ export class GameXpPanel extends LitElement { } protected willUpdate(changed: PropertyValues): void { - if (changed.has("view") || changed.has("compact")) { + if (changed.has("view")) { const s = this.view; if (s.kind === "result" && s.data.eligible && this.onScreen) { this.startReveal(s.data); @@ -199,7 +188,7 @@ export class GameXpPanel extends LitElement { // the end. private startReveal(data: GameXpEligible): void { this.clearTimers(); - if (this.compact || prefersReducedMotion()) { + if (prefersReducedMotion()) { this.reveal = null; return; } @@ -569,14 +558,14 @@ export class GameXpPanel extends LitElement { private captionContent(caption: Caption): TemplateResult { switch (caption.kind) { case "line": - return html`${translateText(LINE_LABEL_KEYS[caption.key])} + return html`${translateText(XP_LINE_LABEL_KEYS[caption.key])} ${translateText("progression.xp_total", { xp: formatXp(caption.amount), })}`; case "multiplier": - return html`${this.bonusText(caption.key, caption.permille)}`; + return html`${xpBonusText(caption.key, caption.permille)}`; case "heading": return html`${translateText("progression.xp_heading")}`; case "level_up": @@ -1315,21 +1304,6 @@ export class GameXpPanel extends LitElement {
`; } - // A multiplier as a line of its own, the way Overwatch shows a group - // bonus: "+100% XP (SOVEREIGN BONUS)", "+20% XP (SUBSCRIBER BONUS)" for a - // boost from no known tier, or "−50% XP (GAME TYPE)" for a cut. - private bonusText(key: "game" | "subscriber", permille: number): string { - const percent = multiplierPercent(permille); - const tier = key === "subscriber" ? subscriberTierOf(permille) : null; - return translateText( - percent >= 0 ? "progression.bonus_line" : "progression.penalty_line", - { - percent: Math.abs(percent), - source: translateText(`progression.multiplier_${tier?.tier ?? key}`), - }, - ); - } - // The multiplier lines under the counter. During the reveal their row // slides open when the first multiplier's wipe has crossed the cards, and // each line pops in as its multiplier lands. @@ -1360,7 +1334,7 @@ export class GameXpPanel extends LitElement { 1000 ? "xp-bonus-line" : "text-sky-300"} ${this.slideSlotClass(shown)}" - >${this.bonusText(m.key, m.permille)}
${xpBonusText(m.key, m.permille)}`; })}
`, @@ -1396,7 +1370,7 @@ export class GameXpPanel extends LitElement { ? `--wipe-delay: ${Math.round(wipeStart(i, lines.length) * WIPE_MS)}ms; --wipe-ms: ${Math.round(WIPE_CARD_SHARE * WIPE_MS)}ms` : undefined; return this.renderXpCard( - translateText(LINE_LABEL_KEYS[line.key]), + translateText(XP_LINE_LABEL_KEYS[line.key]), translateText("progression.xp_total", { xp: formatXp(values[i]) }), `text-yellow-300 border-yellow-400/20 ${tone} ${wipe}`, { line: line.key, shown, style }, @@ -1405,65 +1379,6 @@ export class GameXpPanel extends LitElement {
`; } - // --------------------------------------------------------------------------- - // Compact panel (GameStatsModal, a past game): a static summary. - // --------------------------------------------------------------------------- - - private renderCompact(data: GameXpEligible): TemplateResult { - const { after } = data; - return this.frame( - html` - ${this.renderStyles()} -
-
- -
-
- ${after.legend - ? translateText("progression.legend") - : translateText("progression.level", { level: after.level })} -
-
- ${xpProgressText(after.xpInLevel, after.xpForNext)} -
-
-
-
-
- ${translateText("progression.xp_heading")} -
-
- ${translateText("progression.xp_total", { - xp: formatXp(data.breakdown.total), - })} -
-
-
-
- ${xpBar(levelFraction(after.xpInLevel, after.xpForNext) * 100, { - valueText: xpProgressText(after.xpInLevel, after.xpForNext), - })} -
- ${this.renderBonuses(data)} ${this.renderBreakdown(data)} - ${data.breakdown.leftEarly - ? html`

- ${translateText("progression.left_early")} -

` - : nothing} - `, - "result", - this.panelAccent(data), - ); - } - render() { const s = this.view; switch (s.kind) { @@ -1482,9 +1397,7 @@ export class GameXpPanel extends LitElement { "ineligible", ); } - return this.compact - ? this.renderCompact(s.data) - : this.renderFull(s.data); + return this.renderFull(s.data); } } } diff --git a/src/client/components/PastGameXpCard.ts b/src/client/components/PastGameXpCard.ts new file mode 100644 index 0000000000..ffcdb142ee --- /dev/null +++ b/src/client/components/PastGameXpCard.ts @@ -0,0 +1,328 @@ +import { html, LitElement, nothing, svg, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { GameXpEligible, GameXpResponse } from "../../core/ApiSchemas"; +import { + BEFORE_PROGRESSION, + ineligibleReasonKey, + isMilestoneLevel, + levelsReachedInGame, + MAX_LEVEL, + multiplierAmounts, + reachedLegendThisGame, + subscriberTierOf, + visibleXpLines, + XP_LINE_LABEL_KEYS, +} from "../Progression"; +import { translateText } from "../Utils"; +import "./LevelBadge"; +import { formatXp, xpBonusText, xpProgressText } from "./XpBar"; + +// What a past game's XP card is showing. The caller (GameStatsModal) owns the +// fetching; this element only renders, so every state can be set directly. +export type PastGameXpView = + // Nothing to show: signed out, progression off, not processed, a failure. + | { kind: "hidden" } + // The player's own game, just finished: the server is still scoring it. + | { kind: "calculating" } + | { kind: "result"; data: GameXpResponse }; + +// A boost from no known tier, and any game-type boost: the default lime. +const DEFAULT_BOOST_COLORS: readonly [string, string] = ["#bef264", "#bef264"]; + +const BADGE_SIZE = 30; + +/** + * The XP a past game earned, as a static summary card under the game summary + * in the stats modal: the total, the level change (or the level the game left + * the player on), each source at its base value, and each multiplier as its + * own line. The end-of-game reveal is GameXpPanel. + */ +@customElement("past-game-xp-card") +export class PastGameXpCard extends LitElement { + @property({ attribute: false }) view: PastGameXpView = { kind: "hidden" }; + + createRenderRoot() { + return this; + } + + private frame(content: TemplateResult, stateName: string): TemplateResult { + return html`
+ ${content} +
`; + } + + private heading(extra = ""): TemplateResult { + return html`
+ ${translateText("progression.xp_heading")} +
`; + } + + // One line under the heading: calculating, didn't earn XP, or played + // before levels existed. + private renderNote( + icon: TemplateResult, + text: string, + stateName: string, + tone: string, + ): TemplateResult { + return this.frame( + html`
+ ${this.heading("flex-none")} +
+ ${icon} + ${text} +
+
`, + stateName, + ); + } + + private renderCalculating(): TemplateResult { + return this.renderNote( + html``, + translateText("progression.calculating"), + "calculating", + "text-white/70", + ); + } + + // A small circled glyph: "i" for a game that didn't earn XP, a dash for one + // played before levels existed. + private circledIcon(kind: "info" | "dash"): TemplateResult { + return html``; + } + + private signed(amount: number): string { + return translateText( + amount < 0 ? "progression.xp_minus" : "progression.xp_plus", + { xp: formatXp(Math.abs(amount)) }, + ); + } + + private badge( + level: number, + prestige: number, + legend = false, + ): TemplateResult { + return html``; + } + + // The right of the header: "46 → 47 Level 46 → 47", with a glow and a chip + // when the game reached a milestone; without a level-up, the level the game + // left the player on and their progress through it. + private renderLevel(data: GameXpEligible): TemplateResult { + const { before, after } = data; + const reached = levelsReachedInGame(data); + if (reached.length === 0) { + return html`
+ ${this.badge(after.level, after.prestige, after.legend)} +
+
+ ${after.legend + ? translateText("progression.legend") + : translateText("progression.level", { level: after.level })} +
+
+ ${xpProgressText(after.xpInLevel, after.xpForNext)} +
+
+
`; + } + const last = reached[reached.length - 1]; + const legend = reachedLegendThisGame(data); + const milestone = reached.some((l) => isMilestoneLevel(l.level)); + return html`
+ ${this.badge(before.level, before.prestige)} + + + ${this.badge( + last.level, + last.prestige, + legend && last.level >= MAX_LEVEL, + )} + + + ${translateText("progression.level_change", { + from: before.level, + to: last.level, + })} + + ${milestone + ? html` + ${translateText("progression.milestone_chip")} + ` + : nothing} +
`; + } + + // The multipliers, each its own line under the sources with the XP it + // added, so the lines add up to the total (see multiplierAmounts). A + // subscriber boost in its tier's colours. + private renderMultipliers(data: GameXpEligible): TemplateResult { + return html`${multiplierAmounts(data.breakdown).map((m) => { + const cut = m.permille < 1000; + const colors = + m.key === "subscriber" + ? (subscriberTierOf(m.permille)?.colors ?? DEFAULT_BOOST_COLORS) + : DEFAULT_BOOST_COLORS; + return html`
+ ${xpBonusText(m.key, m.permille)} + ${m.amount === null + ? nothing + : html`${this.signed(m.amount)}`} +
`; + })}`; + } + + private renderResult(data: GameXpEligible): TemplateResult { + const lines = visibleXpLines(data.breakdown); + return this.frame( + html` +
+
+ ${this.heading()} +
+ ${translateText("progression.xp_total", { + xp: formatXp(data.breakdown.total), + })} +
+
+ ${this.renderLevel(data)} +
+ ${lines.length > 0 + ? html`
+ ${lines.map( + (line) => + html`
+ ${translateText(XP_LINE_LABEL_KEYS[line.key])} + ${this.signed(line.amount)} +
`, + )} +
` + : nothing} + ${this.renderMultipliers(data)} + ${data.breakdown.leftEarly + ? html`

+ ${translateText("progression.left_early")} +

` + : nothing} + `, + "result", + ); + } + + render() { + const s = this.view; + switch (s.kind) { + case "hidden": + return nothing; + case "calculating": + return this.renderCalculating(); + case "result": + if (s.data.eligible) return this.renderResult(s.data); + if (s.data.reason === BEFORE_PROGRESSION) { + return this.renderNote( + this.circledIcon("dash"), + translateText(ineligibleReasonKey(s.data.reason)), + "before_levels", + "text-white/55", + ); + } + return this.renderNote( + this.circledIcon("info"), + translateText(ineligibleReasonKey(s.data.reason)), + "ineligible", + "text-white/70", + ); + } + } +} diff --git a/src/client/components/XpBar.ts b/src/client/components/XpBar.ts index c23b2d4970..360922c9fe 100644 --- a/src/client/components/XpBar.ts +++ b/src/client/components/XpBar.ts @@ -1,12 +1,34 @@ import { html, nothing, TemplateResult } from "lit"; +import { multiplierPercent, subscriberTierOf } from "../Progression"; import { translateText } from "../Utils"; -// Shared bits of the level / XP UI: number formatting and the progress bar. +// Shared bits of the level / XP UI: number formatting, multiplier lines and +// the progress bar. export function formatXp(amount: number): string { return Math.round(amount).toLocaleString(); } +/** + * A multiplier as a line of its own, the way Overwatch shows a group bonus: + * "+100% XP (SOVEREIGN BONUS)", "+20% XP (SUBSCRIBER BONUS)" for a boost from + * no known tier, or "−50% XP (GAME TYPE)" for a cut. + */ +export function xpBonusText( + key: "game" | "subscriber", + permille: number, +): string { + const percent = multiplierPercent(permille); + const tier = key === "subscriber" ? subscriberTierOf(permille) : null; + return translateText( + percent >= 0 ? "progression.bonus_line" : "progression.penalty_line", + { + percent: Math.abs(percent), + source: translateText(`progression.multiplier_${tier?.tier ?? key}`), + }, + ); +} + /** "1,234 / 5,000 XP", or "Max level" when there is no next level. */ export function xpProgressText(xpInLevel: number, xpForNext: number): string { if (!(xpForNext > 0)) return translateText("progression.max_level"); diff --git a/src/client/components/baseComponents/stats/GameInfoView.ts b/src/client/components/baseComponents/stats/GameInfoView.ts index 42dc86c8cc..57f38453e5 100644 --- a/src/client/components/baseComponents/stats/GameInfoView.ts +++ b/src/client/components/baseComponents/stats/GameInfoView.ts @@ -1,6 +1,7 @@ import { html, LitElement, + nothing, type PropertyValues, type TemplateResult, } from "lit"; @@ -25,12 +26,22 @@ import "../ranking/PlayerRow"; import "../ranking/RankingControls"; import { formatAbsoluteTime } from "./GameHistoryDates"; +export interface GameInfoLoadedDetail { + gameId: string; + info: GameEndInfo | null; +} + /** * Game-stats content for the Account > Games > Stats drill-down. */ @customElement("game-info-view") export class GameInfoView extends LitElement { @property({ type: String }) gameId: string | null = null; + // Shown right under the game summary card, once the game has loaded (the + // stats modal puts the player's XP there). + @property({ attribute: false }) afterSummary: + | TemplateResult + | typeof nothing = nothing; @state() private rankType = RankType.Lifetime; @state() private mapImage: string | null = null; @@ -249,6 +260,7 @@ export class GameInfoView extends LitElement {
+ ${this.afterSummary} `; } @@ -410,6 +422,17 @@ export class GameInfoView extends LitElement { return this.ranking.score(player, this.rankType); } + // Tells the host which game loaded (null: it failed to), e.g. so the stats + // modal can tell how long ago it ended. + private announceLoaded(gameId: string, info: GameEndInfo | null): void { + this.dispatchEvent( + new CustomEvent("game-info-loaded", { + detail: { gameId, info }, + bubbles: true, + }), + ); + } + private async fetchGame(gameId: string): Promise { const generation = ++this.loadGeneration; this.isLoadingGame = true; @@ -425,10 +448,12 @@ export class GameInfoView extends LitElement { if (generation !== this.loadGeneration) return; if (!session) { this.loadFailed = true; + this.announceLoaded(gameId, null); return; } this.gameInfo = session.info; + this.announceLoaded(gameId, session.info); this.ranking = new Ranking(session); this.updateRanking(); try { @@ -441,6 +466,7 @@ export class GameInfoView extends LitElement { if (generation === this.loadGeneration) { console.warn("Failed to load game:", err); this.loadFailed = true; + this.announceLoaded(gameId, null); } } finally { if (generation === this.loadGeneration) { diff --git a/tests/client/LevelBadge.test.ts b/tests/client/LevelBadge.test.ts index 500344a62a..3847a4ca81 100644 --- a/tests/client/LevelBadge.test.ts +++ b/tests/client/LevelBadge.test.ts @@ -13,6 +13,7 @@ import { levelBand, levelFraction, levelRewardReasonKey, + multiplierAmounts, multiplierPercent, subscriberTierOf, visibleMultipliers, @@ -211,6 +212,38 @@ describe("progression helpers", () => { ).toEqual(["game", "subscriber"]); }); + it("gives a multiplier its exact amount only when it is the only one", () => { + // The boost alone: what it added is total − subtotal. + expect(multiplierAmounts(breakdown)).toEqual([ + { key: "subscriber", permille: 1250, amount: 43 }, + ]); + // A game-type cut alone, the same way (negative). + expect( + multiplierAmounts({ + ...breakdown, + gamePermille: 500, + subscriberPermille: 1000, + total: 88, + }), + ).toEqual([{ key: "game", permille: 500, amount: -87 }]); + // Both: the API rounds once over the product, so how much each added + // depends on an order it doesn't record. No amounts, not a guess. + expect( + multiplierAmounts({ + ...breakdown, + gamePermille: 500, + subscriberPermille: 2000, + total: 175, + }), + ).toEqual([ + { key: "game", permille: 500, amount: null }, + { key: "subscriber", permille: 2000, amount: null }, + ]); + expect( + multiplierAmounts({ ...breakdown, subscriberPermille: 1000 }), + ).toEqual([]); + }); + it("formats permille multipliers", () => { expect(multiplierPercent(1200)).toBe(20); expect(multiplierPercent(1250)).toBe(25); @@ -268,6 +301,15 @@ describe("progression helpers", () => { ); }); + it("explains a game played before levels existed", () => { + expect(ineligibleReasonKey("before_progression")).toBe( + "progression.xp_before_levels", + ); + expect(en.progression.xp_before_levels).toBe( + "Played before levels existed, so there's no XP for this game.", + ); + }); + it("maps only the level reward reasons", () => { expect(levelRewardReasonKey("level_up")).toBe( "account_modal.reward_level_up", diff --git a/tests/client/PastGameXpCard.test.ts b/tests/client/PastGameXpCard.test.ts new file mode 100644 index 0000000000..0f225826a9 --- /dev/null +++ b/tests/client/PastGameXpCard.test.ts @@ -0,0 +1,266 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, +})); + +import { + PastGameXpCard, + type PastGameXpView, +} from "../../src/client/components/PastGameXpCard"; +import type { GameXpEligible } from "../../src/core/ApiSchemas"; + +if (!customElements.get("past-game-xp-card")) { + customElements.define("past-game-xp-card", PastGameXpCard); +} + +// A win with a Vanguard boost that takes the player from 46 to 47. +const levelUp: GameXpEligible = { + gameId: "g1", + eligible: true, + breakdown: { + leftEarly: false, + played: 50, + time: 280, + placement: 90, + win: 300, + firstGame: 200, + feats: 0, + subtotal: 920, + gamePermille: 1000, + subscriberPermille: 1250, + total: 1150, + }, + before: { prestige: 3, level: 46, xpInLevel: 1550, xpForNext: 2210 }, + after: { + prestige: 3, + level: 47, + xpInLevel: 444, + xpForNext: 2260, + lifetimeXp: 125_400, + legend: false, + canPrestige: false, + }, + levelsReached: [{ prestige: 3, level: 47 }], +}; + +const milestone: GameXpEligible = { + ...levelUp, + before: { prestige: 3, level: 49, xpInLevel: 1700, xpForNext: 2360 }, + after: { ...levelUp.after, level: 50, xpForNext: 2410 }, + levelsReached: [{ prestige: 3, level: 50 }], +}; + +const noLevelUp: GameXpEligible = { + ...levelUp, + breakdown: { + ...levelUp.breakdown, + time: 190, + placement: 20, + win: 0, + firstGame: 0, + subtotal: 260, + total: 325, + }, + before: { prestige: 3, level: 47, xpInLevel: 444, xpForNext: 2260 }, + after: { ...levelUp.after, xpInLevel: 769 }, + levelsReached: [], +}; + +let card: PastGameXpCard | null = null; + +async function mount(view: PastGameXpView): Promise { + card = document.createElement("past-game-xp-card") as PastGameXpCard; + card.view = view; + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +function q(selector: string): Element | null { + return card!.querySelector(selector); +} + +function stateOf(): string | null { + return q("[data-past-xp]")?.getAttribute("data-past-xp-state") ?? null; +} + +afterEach(() => { + card?.remove(); + card = null; +}); + +describe("", () => { + it("renders nothing while hidden", async () => { + await mount({ kind: "hidden" }); + expect(q("[data-past-xp]")).toBeNull(); + }); + + it("(a) shows the total, the level change and each source at its base value", async () => { + await mount({ kind: "result", data: levelUp }); + expect(stateOf()).toBe("result"); + expect(q("[data-past-xp-total]")!.textContent).toContain( + 'progression.xp_total:{"xp":"1,150"}', + ); + const change = q("[data-past-xp-levelup]")!; + expect(change.getAttribute("data-past-xp-levelup")).toBe("47"); + expect(change.textContent).toContain( + 'progression.level_change:{"from":46,"to":47}', + ); + const badges = [...change.querySelectorAll("level-badge")] as (Element & { + level: number; + })[]; + expect(badges.map((b) => b.level)).toEqual([46, 47]); + // Base values, not shares of the boosted total. + const lines = [...card!.querySelectorAll("[data-past-xp-line]")].map( + (l) => [l.getAttribute("data-past-xp-line"), l.textContent!.trim()], + ); + expect(lines).toEqual([ + ["played", expect.stringContaining('progression.xp_plus:{"xp":"50"}')], + ["time", expect.stringContaining('progression.xp_plus:{"xp":"280"}')], + ["placement", expect.stringContaining('progression.xp_plus:{"xp":"90"}')], + ["win", expect.stringContaining('progression.xp_plus:{"xp":"300"}')], + [ + "firstGame", + expect.stringContaining('progression.xp_plus:{"xp":"200"}'), + ], + ]); + // No milestone, no chip. + expect(q("[data-past-xp-milestone]")).toBeNull(); + }); + + it("lays the sources out in three columns, stacking on a phone", async () => { + await mount({ kind: "result", data: levelUp }); + const grid = q("[data-past-xp-lines]")!; + expect(grid.classList).toContain("grid-cols-1"); + expect(grid.classList).toContain("sm:grid-cols-3"); + }); + + it("gives the subscriber boost its own line, so the lines add up to the total", async () => { + await mount({ kind: "result", data: levelUp }); + const boost = q('[data-past-xp-multiplier="subscriber"]')!; + expect(boost.getAttribute("data-xp-tier")).toBe("vanguard"); + expect(boost.textContent).toContain("progression.bonus_line"); + expect( + boost.querySelector("[data-past-xp-multiplier-amount]")!.textContent, + ).toBe('progression.xp_plus:{"xp":"230"}'); + // 50 + 280 + 90 + 300 + 200 + 230 = 1,150. + }); + + it("shows a game-type cut with its amount when it is the only multiplier", async () => { + await mount({ + kind: "result", + data: { + ...noLevelUp, + breakdown: { + ...noLevelUp.breakdown, + gamePermille: 500, + subscriberPermille: 1000, + total: 130, + }, + }, + }); + expect(q('[data-past-xp-multiplier="subscriber"]')).toBeNull(); + const cut = q('[data-past-xp-multiplier="game"]')!; + expect(cut.textContent).toContain("progression.penalty_line"); + expect( + cut.querySelector("[data-past-xp-multiplier-amount]")!.textContent, + ).toBe('progression.xp_minus:{"xp":"130"}'); + }); + + it("leaves the amounts off when both multipliers apply, rather than guess the split", async () => { + await mount({ + kind: "result", + data: { + ...noLevelUp, + breakdown: { + ...noLevelUp.breakdown, + gamePermille: 500, + subscriberPermille: 2000, + total: 260, + }, + }, + }); + expect(q('[data-past-xp-multiplier="game"]')).not.toBeNull(); + expect(q('[data-past-xp-multiplier="subscriber"]')).not.toBeNull(); + expect(q("[data-past-xp-multiplier-amount]")).toBeNull(); + }); + + it("shows no multiplier line without a multiplier", async () => { + await mount({ + kind: "result", + data: { + ...noLevelUp, + breakdown: { + ...noLevelUp.breakdown, + subscriberPermille: 1000, + total: 260, + }, + }, + }); + expect(q("[data-past-xp-multiplier]")).toBeNull(); + }); + + it("marks a milestone level with a chip and a glow", async () => { + await mount({ kind: "result", data: milestone }); + expect(q("[data-past-xp-milestone]")!.textContent!.trim()).toBe( + "progression.milestone_chip", + ); + expect(q("[data-past-xp-new-badge]")!.className).toContain("shadow-"); + }); + + it("(b) without a level-up, shows the level and the progress through it", async () => { + await mount({ kind: "result", data: noLevelUp }); + expect(q("[data-past-xp-levelup]")).toBeNull(); + const level = q("[data-past-xp-level]")!; + expect(level.textContent).toContain('progression.level:{"level":47}'); + expect(level.textContent).toContain( + 'progression.xp_progress:{"current":"769","next":"2,260"}', + ); + expect(q("[data-past-xp-new-badge]")).toBeNull(); + }); + + it("(c) shows a spinner while calculating", async () => { + await mount({ kind: "calculating" }); + expect(stateOf()).toBe("calculating"); + expect(q("[data-past-xp-note]")!.textContent).toBe( + "progression.calculating", + ); + expect(q(".animate-spin")).not.toBeNull(); + }); + + it("(d) says why a game didn't earn XP, in one line", async () => { + await mount({ + kind: "result", + data: { gameId: "g1", eligible: false, reason: "too_short" }, + }); + expect(stateOf()).toBe("ineligible"); + expect(q("[data-past-xp-note]")!.textContent).toBe( + "progression.ineligible_too_short", + ); + expect(q("[data-past-xp-total]")).toBeNull(); + }); + + it("(e) says a game was played before levels existed", async () => { + await mount({ + kind: "result", + data: { gameId: "g1", eligible: false, reason: "before_progression" }, + }); + expect(stateOf()).toBe("before_levels"); + expect(q("[data-past-xp-note]")!.textContent).toBe( + "progression.xp_before_levels", + ); + }); + + it("notes a game the player left early", async () => { + await mount({ + kind: "result", + data: { + ...noLevelUp, + breakdown: { ...noLevelUp.breakdown, leftEarly: true }, + }, + }); + expect(q("[data-past-xp-left-early]")).not.toBeNull(); + }); +}); diff --git a/tests/client/ProgressionModals.test.ts b/tests/client/ProgressionModals.test.ts index 327fefa4e8..5e9c7e183e 100644 --- a/tests/client/ProgressionModals.test.ts +++ b/tests/client/ProgressionModals.test.ts @@ -14,6 +14,7 @@ const { fetchPublicPlayerProfile, fetchPublicPlayerProgress, fetchMyGameXp, + pollGameXp, isLoggedIn, crazyGamesSDK, } = vi.hoisted(() => ({ @@ -21,6 +22,7 @@ const { fetchPublicPlayerProfile: vi.fn(), fetchPublicPlayerProgress: vi.fn(), fetchMyGameXp: vi.fn(), + pollGameXp: vi.fn(), isLoggedIn: vi.fn(async () => true), crazyGamesSDK: { isOnCrazyGames: vi.fn(() => false), @@ -47,6 +49,7 @@ vi.mock("../../src/client/CrazyGamesSDK", () => ({ crazyGamesSDK })); vi.mock("../../src/client/ProgressionApi", () => ({ fetchPublicPlayerProgress, fetchMyGameXp, + pollGameXp, })); vi.mock( @@ -60,13 +63,29 @@ vi.mock( }, ); -vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => { - class FakeGameInfoView extends HTMLElement {} - if (!customElements.get("game-info-view")) { - customElements.define("game-info-view", FakeGameInfoView); - } - return { GameInfoView: FakeGameInfoView }; -}); +// Stands in for the real view: renders what goes under the game summary, and +// the tests say when the game "loaded" (see gameLoaded). +vi.mock( + "../../src/client/components/baseComponents/stats/GameInfoView", + async () => { + const { nothing, render } = await import("lit"); + class FakeGameInfoView extends HTMLElement { + gameId: string | null = null; + private extra: unknown = nothing; + set afterSummary(value: unknown) { + this.extra = value; + render(value, this); + } + get afterSummary(): unknown { + return this.extra; + } + } + if (!customElements.get("game-info-view")) { + customElements.define("game-info-view", FakeGameInfoView); + } + return { GameInfoView: FakeGameInfoView }; + }, +); import { GameStatsModal } from "../../src/client/GameStatsModal"; import { modalRouter } from "../../src/client/ModalRouter"; @@ -287,6 +306,7 @@ describe("game stats XP", () => { vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) }); getUserMe.mockResolvedValue(me); fetchMyGameXp.mockReset(); + pollGameXp.mockReset(); modal = await mountModal("game-stats-modal", "page-stats"); }); @@ -297,59 +317,197 @@ describe("game stats XP", () => { vi.unstubAllGlobals(); }); - async function open(gameId: string): Promise { - history.replaceState(null, "", `/#modal=stats&gameID=${gameId}`); - expect(modalRouter.routeFromHash()).toBe(true); + async function shown(): Promise { await vi.waitFor(async () => { await settled(modal); expect(modal.querySelector("game-info-view")).not.toBeNull(); }); } - async function panelState(): Promise { - const panel = modal.querySelector("game-xp-panel") as ModalShell | null; - await panel?.updateComplete; + async function open(gameId: string): Promise { + history.replaceState(null, "", `/#modal=stats&gameID=${gameId}`); + expect(modalRouter.routeFromHash()).toBe(true); + await shown(); + } + + // GameInfoView has loaded the game: it ended `agoMs` ago. + function gameLoaded(gameId: string, agoMs: number): void { + modal.querySelector("game-info-view")!.dispatchEvent( + new CustomEvent("game-info-loaded", { + detail: { gameId, info: { end: Date.now() - agoMs } }, + bubbles: true, + }), + ); + } + + async function cardState(): Promise { + await settled(modal); + const card = modal.querySelector("past-game-xp-card") as ModalShell | null; + await card?.updateComplete; return ( - panel?.querySelector("[data-xp-panel]")?.getAttribute("data-xp-state") ?? - null + card + ?.querySelector("[data-past-xp]") + ?.getAttribute("data-past-xp-state") ?? null ); } - it("shows the XP a past game earned", async () => { + // Lets a lookup that has nothing to show run to the end. + async function idle(): Promise { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); + } + + it("shows the XP a past game earned, under the game summary", async () => { fetchMyGameXp.mockResolvedValue({ status: "ok", data: eligibleXp }); await open("g1"); - await vi.waitFor(async () => expect(await panelState()).toBe("result")); - const text = modal.querySelector("[data-xp-panel]")!.textContent!; - expect(text).toContain('progression.xp_total:{"xp":"75"}'); - expect(modal.querySelector("[data-xp-levelup]")).toBeNull(); - expect(fetchMyGameXp).toHaveBeenCalledWith("g1"); + await vi.waitFor(async () => expect(await cardState()).toBe("result")); + const card = modal.querySelector("game-info-view past-game-xp-card")!; + expect(card.textContent).toContain('progression.xp_total:{"xp":"75"}'); + // Today's compact reveal panel is gone. + expect(modal.querySelector("game-xp-panel")).toBeNull(); + expect(fetchMyGameXp).toHaveBeenCalledWith("g1", expect.any(AbortSignal)); }); - it("hides an ineligible or unprocessed game", async () => { + it("explains a game that didn't earn XP: the record is always your own", async () => { fetchMyGameXp.mockResolvedValue({ status: "ok", data: { gameId: "g2", eligible: false, reason: "not_spawned" }, }); await open("g2"); - await vi.waitFor(() => expect(fetchMyGameXp).toHaveBeenCalled()); - await settled(modal); - expect(await panelState()).toBeNull(); + await vi.waitFor(async () => expect(await cardState()).toBe("ineligible")); + expect(modal.querySelector("[data-past-xp-note]")!.textContent).toBe( + "progression.ineligible_not_spawned", + ); + }); - fetchMyGameXp.mockResolvedValue({ status: "pending" }); - await open("g3"); - await vi.waitFor(() => - expect(fetchMyGameXp).toHaveBeenLastCalledWith("g3"), + it("says when a game was played before levels existed", async () => { + fetchMyGameXp.mockResolvedValue({ + status: "ok", + data: { gameId: "g7", eligible: false, reason: "before_progression" }, + }); + await open("g7"); + await vi.waitFor(async () => + expect(await cardState()).toBe("before_levels"), ); - await settled(modal); - expect(await panelState()).toBeNull(); + expect(modal.querySelector("[data-past-xp-note]")!.textContent).toBe( + "progression.xp_before_levels", + ); + }); + + describe("a game with no XP record yet (404)", () => { + beforeEach(() => { + fetchMyGameXp.mockResolvedValue({ status: "pending" }); + }); + + it("calculates for your own game from your account, just finished, then shows it", async () => { + let resolvePoll!: (r: unknown) => void; + pollGameXp.mockReturnValue( + new Promise((resolve) => (resolvePoll = resolve)), + ); + modal.openFromAccount("g1"); + await shown(); + gameLoaded("g1", 2 * 60_000); + await vi.waitFor(async () => + expect(await cardState()).toBe("calculating"), + ); + expect(modal.querySelector("[data-past-xp-note]")!.textContent).toBe( + "progression.calculating", + ); + expect(pollGameXp).toHaveBeenCalledWith("g1", { + signal: expect.any(AbortSignal), + }); + resolvePoll(eligibleXp); + await vi.waitFor(async () => expect(await cardState()).toBe("result")); + }); + + it("calculates for a just-finished game on your own profile", async () => { + pollGameXp.mockReturnValue(new Promise(() => {})); + modal.openFromProfile("g1", "me"); + await shown(); + gameLoaded("g1", 60_000); + await vi.waitFor(async () => + expect(await cardState()).toBe("calculating"), + ); + }); + + it("hides the section when the polling gives up", async () => { + pollGameXp.mockResolvedValue(null); + modal.openFromAccount("g1"); + await shown(); + gameLoaded("g1", 60_000); + await vi.waitFor(() => expect(pollGameXp).toHaveBeenCalled()); + await idle(); + expect(await cardState()).toBeNull(); + }); + + it("shows nothing for an older game of your own", async () => { + modal.openFromAccount("g1"); + await shown(); + gameLoaded("g1", 11 * 60_000); + await idle(); + expect(await cardState()).toBeNull(); + expect(pollGameXp).not.toHaveBeenCalled(); + }); + + it("shows nothing for a just-finished game on someone else's profile", async () => { + modal.openFromProfile("g1", "someone-else"); + await shown(); + gameLoaded("g1", 60_000); + await idle(); + expect(await cardState()).toBeNull(); + expect(pollGameXp).not.toHaveBeenCalled(); + }); + + it("shows nothing for a just-finished game opened from a clan or a link", async () => { + modal.openFromClan("g1"); + await shown(); + gameLoaded("g1", 60_000); + await idle(); + expect(await cardState()).toBeNull(); + + await open("g3"); + gameLoaded("g3", 60_000); + await idle(); + expect(await cardState()).toBeNull(); + expect(pollGameXp).not.toHaveBeenCalled(); + }); + + it("shows nothing when the game itself fails to load", async () => { + modal.openFromAccount("g1"); + await shown(); + modal.querySelector("game-info-view")!.dispatchEvent( + new CustomEvent("game-info-loaded", { + detail: { gameId: "g1", info: null }, + bubbles: true, + }), + ); + await idle(); + expect(await cardState()).toBeNull(); + expect(pollGameXp).not.toHaveBeenCalled(); + }); + + it("stops polling when the modal closes", async () => { + let signal!: AbortSignal; + pollGameXp.mockImplementation( + (_id: string, opts: { signal: AbortSignal }) => { + signal = opts.signal; + return new Promise(() => {}); + }, + ); + modal.openFromAccount("g1"); + await shown(); + gameLoaded("g1", 60_000); + await vi.waitFor(() => expect(pollGameXp).toHaveBeenCalled()); + modal.close(); + expect(signal.aborted).toBe(true); + }); }); it("does not ask when progression is off", async () => { getUserMe.mockResolvedValue({ ...me, player: { publicId: "me" } }); await open("g5"); - await settled(modal); + await idle(); expect(fetchMyGameXp).not.toHaveBeenCalled(); - expect(await panelState()).toBeNull(); + expect(await cardState()).toBeNull(); }); it("shows XP to a CrazyGames player, the same as the end-of-game panel", async () => { @@ -359,7 +517,7 @@ describe("game stats XP", () => { fetchMyGameXp.mockResolvedValue({ status: "ok", data: eligibleXp }); try { await open("g6"); - await vi.waitFor(async () => expect(await panelState()).toBe("result")); + await vi.waitFor(async () => expect(await cardState()).toBe("result")); } finally { crazyGamesSDK.isOnCrazyGames.mockReturnValue(false); crazyGamesSDK.getUserProfile.mockResolvedValue(null); @@ -369,8 +527,8 @@ describe("game stats XP", () => { it("does not ask for a signed-out viewer", async () => { getUserMe.mockResolvedValue({ user: {}, player: { publicId: "anon" } }); await open("g4"); - await settled(modal); + await idle(); expect(fetchMyGameXp).not.toHaveBeenCalled(); - expect(await panelState()).toBeNull(); + expect(await cardState()).toBeNull(); }); }); From 2edf46b6fa76def35661691c25d31ffcf8b5dfb8 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Mon, 5 Oct 2026 14:17:11 +0100 Subject: [PATCH 12/17] perf: carry the roster's level badge as one packed integer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lobby roster went out with levelBadge as a nested { level, prestige, legend } object: per badged entry its own header byte plus two varints and an extra object encode/decode step. lobby_info is encoded once per recipient every second, so a 150-player all-badged lobby paid that 22,500 times a broadcast (+25-41% broadcast time, +13% bytes). ClientInfo.levelBadge is now a single optional zb.uint(): bits 0-6 level (1..100), bits 7-10 prestige (0..10), bit 11 legend One varint byte at prestige 0, two at most (P10 L100 legend = 3428). Still the last field with its one header presence bit, so badge-less entries stay byte-identical. The field is new in this PR and not on main, so no deployed client or server speaks the old shape. - src/core/LevelBadgeWire.ts: LevelBadge, packLevelBadge (undefined for anything it cannot represent) and unpackLevelBadge (undefined for an out-of-range level/prestige, reserved bits, or a non-integer — never throws). The schema has no range so one bad value cannot fail the whole roster parse; the decoder drops it instead. - Server: Client keeps the readable badge and packs it once at construction (wireLevelBadge); NameVisibility sends that, so broadcasts do no packing. levelBadgeFromProgress now also drops level > 100 and prestige > 10. - Client: lobbyLevelBadge and LobbyPlayerView unpack back to { level, prestige, legend }; the badge components are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/client/LobbyRosterLevels.ts | 16 +-- src/client/components/LobbyPlayerView.ts | 3 +- src/core/LevelBadgeWire.ts | 71 +++++++++++++ src/core/Schemas.ts | 33 +++--- src/server/Client.ts | 9 +- src/server/LevelBadge.ts | 15 ++- src/server/NameVisibility.ts | 2 +- tests/client/LobbyRosterLevels.test.ts | 41 ++++++- .../LobbyPlayerView.levelBadge.test.ts | 21 ++-- .../layers/PlayerPanelLevelBadge.test.ts | 5 +- tests/core/ClientInfoLevelBadgeWire.test.ts | 63 ++++++++--- tests/core/LevelBadgeWire.test.ts | 100 ++++++++++++++++++ tests/server/RosterLevelBadge.test.ts | 56 ++++++++-- 13 files changed, 362 insertions(+), 73 deletions(-) create mode 100644 src/core/LevelBadgeWire.ts create mode 100644 tests/core/LevelBadgeWire.test.ts diff --git a/src/client/LobbyRosterLevels.ts b/src/client/LobbyRosterLevels.ts index cd33e8fd43..dec0816f15 100644 --- a/src/client/LobbyRosterLevels.ts +++ b/src/client/LobbyRosterLevels.ts @@ -1,4 +1,5 @@ -import { ClientID, ClientInfo, GameID, LevelBadge } from "../core/Schemas"; +import { LevelBadge, unpackLevelBadge } from "../core/LevelBadgeWire"; +import { ClientID, ClientInfo, GameID } from "../core/Schemas"; // Level badges from the last lobby roster this tab was sent, so in-game UI // can show them after the lobby is gone. The roster (lobby_info) only flows @@ -13,15 +14,18 @@ import { ClientID, ClientInfo, GameID, LevelBadge } from "../core/Schemas"; // // Per-viewer like the roster itself: anonymized entries arrive without a // badge, so nothing here can reveal one. +// +// Kept in wire form (packLevelBadge) — a roster arrives every second, a badge +// is read only when a panel opens — and unpacked on read. let rosterGameID: GameID | null = null; -let badges = new Map(); +let badges = new Map(); export function rememberLobbyRoster( gameID: GameID, clients: readonly ClientInfo[] | undefined, ): void { - const next = new Map(); + const next = new Map(); for (const c of clients ?? []) { if (c.levelBadge !== undefined) next.set(c.clientID, c.levelBadge); } @@ -30,12 +34,12 @@ export function rememberLobbyRoster( } // The player's badge in game `gameID`, or undefined when they have none (a -// guest, an anonymized name, progression off) or this tab never saw them in -// that game's lobby. +// guest, an anonymized name, progression off, a value out of range) or this +// tab never saw them in that game's lobby. export function lobbyLevelBadge( gameID: GameID, clientID: ClientID | null, ): LevelBadge | undefined { if (clientID === null || gameID !== rosterGameID) return undefined; - return badges.get(clientID); + return unpackLevelBadge(badges.get(clientID)); } diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 8f1f3d2c33..c93283254b 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -14,6 +14,7 @@ import { } from "../../core/game/Game"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { UserSettings } from "../../core/game/UserSettings"; +import { unpackLevelBadge } from "../../core/LevelBadgeWire"; import { ClientID, ClientInfo, @@ -614,7 +615,7 @@ export class LobbyTeamView extends LitElement { private visibleLevelBadge(client: ClientInfo): LevelBadge | undefined { const anonymized = this.userSettings.anonymousNames() && !this.isCurrentPlayer(client); - return anonymized ? undefined : client.levelBadge; + return anonymized ? undefined : unpackLevelBadge(client.levelBadge); } private anyVisibleLevelBadge(clients: readonly ClientInfo[]): boolean { diff --git a/src/core/LevelBadgeWire.ts b/src/core/LevelBadgeWire.ts new file mode 100644 index 0000000000..a88bcb5594 --- /dev/null +++ b/src/core/LevelBadgeWire.ts @@ -0,0 +1,71 @@ +// A player's level as a name badge: the display subset of the API's +// /users/@me `progress`. Display-only — the simulation never reads it, so it +// cannot desync. +export interface LevelBadge { + level: number; + prestige: number; + legend: boolean; +} + +// The lobby roster (ClientInfo.levelBadge) carries a badge as ONE small +// integer rather than a nested { level, prestige, legend } object. The roster +// goes to every client once a second, encoded once per recipient, so a +// 150-player lobby encodes 22,500 entries a second: a nested object cost each +// entry its own header byte plus two varints and a per-object encode/decode +// step; a packed varint is one or two bytes and a plain number. +// +// bits 0-6 level 1..100 +// bits 7-10 prestige 0..10 +// bit 11 legend +// +// Prestige 0 (most players) packs below 128, so one varint byte; anything +// else, up to legend at P10 L100 (3428), is two. +export const LEVEL_BADGE_MAX_LEVEL = 100; +export const LEVEL_BADGE_MAX_PRESTIGE = 10; +const LEVEL_BITS = 7; +const LEVEL_MASK = (1 << LEVEL_BITS) - 1; +const PRESTIGE_SHIFT = LEVEL_BITS; +const PRESTIGE_MASK = 0b1111; +const LEGEND_BIT = 1 << 11; +const ALL_BITS = (1 << 12) - 1; + +function inRange(level: number, prestige: number): boolean { + return ( + Number.isInteger(level) && + level >= 1 && + level <= LEVEL_BADGE_MAX_LEVEL && + Number.isInteger(prestige) && + prestige >= 0 && + prestige <= LEVEL_BADGE_MAX_PRESTIGE + ); +} + +// The wire form of a badge, or undefined when it holds anything the packing +// cannot represent (so no badge is sent rather than a wrong one). +export function packLevelBadge( + badge: LevelBadge | undefined, +): number | undefined { + if (badge === undefined) return undefined; + const { level, prestige, legend } = badge; + if (!inRange(level, prestige) || typeof legend !== "boolean") { + return undefined; + } + return level | (prestige << PRESTIGE_SHIFT) | (legend ? LEGEND_BIT : 0); +} + +// Back to { level, prestige, legend }. Undefined for no badge and for any +// value no server would send — out-of-range level or prestige, reserved bits +// set, not a whole number — so a bad roster entry shows no badge instead of +// a wrong one or an exception. +export function unpackLevelBadge( + packed: number | undefined, +): LevelBadge | undefined { + if (packed === undefined) return undefined; + if (!Number.isInteger(packed) || packed < 0 || packed > ALL_BITS) { + return undefined; + } + const level = packed & LEVEL_MASK; + const prestige = (packed >> PRESTIGE_SHIFT) & PRESTIGE_MASK; + if (!inRange(level, prestige)) return undefined; + return { level, prestige, legend: (packed & LEGEND_BIT) !== 0 }; +} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index a4ee2550b6..89bce95a8f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -304,16 +304,7 @@ export const ClanTagSchema = z .regex(/^[a-zA-Z0-9]{2,5}$/) .nullable(); -// A player's level as a name badge: the display subset of the API's -// /users/@me `progress`. Stamped onto the lobby roster by the game server from -// its own /users/@me lookup, never taken from the client. Display-only — the -// simulation never reads it, so it cannot desync. -export const LevelBadgeSchema = z.object({ - level: zb.uint(), - prestige: zb.uint(), - legend: z.boolean(), -}); -export type LevelBadge = z.infer; +export type { LevelBadge } from "./LevelBadgeWire"; export const ClientInfoSchema = z.object({ clientID: z.string(), @@ -331,15 +322,19 @@ export const ClientInfoSchema = z.object({ // preview can honour the pins instead of re-deriving teams that the server // will overrule at start. Absent when the game isn't matchmade. teamIndex: zb.uint().optional(), - // Signed-in players' level, for the badge next to their name. Absent for - // guests, when the API has progression off or the lookup failed, and on - // anonymized entries (a badge beside an anonymous name would point out the - // veterans). Must stay the LAST field and a single object: its one presence + // Signed-in players' level, for the badge next to their name: level, + // prestige and legend packed into one varint by packLevelBadge + // (LevelBadgeWire.ts); read it with unpackLevelBadge. Stamped by the game + // server from its own /users/@me lookup, never taken from the client. + // Display-only. Absent for guests, when the API has progression off or the + // lookup failed, and on anonymized entries (a badge beside an anonymous name + // would point out the veterans). Must stay the LAST field: its one presence // bit is the eighth and last bit of this object's one-byte header, so an // entry without it encodes byte-identically to one from before it existed, // and a pre-badge frame still decodes. Another field here would need a - // second header byte — fold it into the badge instead. - levelBadge: LevelBadgeSchema.optional(), + // second header byte. No range check here on purpose: one bad value must + // not fail the whole roster's parse, so unpackLevelBadge drops it instead. + levelBadge: zb.uint().optional(), }); export const GameInfoSchema = z.object({ @@ -463,9 +458,9 @@ export interface ClientInfo { spectator?: boolean; // Server-pinned team slot for matchmade team games; absent when not matchmade. teamIndex?: number; - // Server-stamped level for the name badge. Display-only; never set on - // anonymized entries. - levelBadge?: LevelBadge; + // Server-stamped level for the name badge, packed (packLevelBadge); read it + // with unpackLevelBadge. Display-only; never set on anonymized entries. + levelBadge?: number; } export enum LogSeverity { Debug = "DEBUG", diff --git a/src/server/Client.ts b/src/server/Client.ts index ca93f71556..a0f4aa307f 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -1,6 +1,7 @@ import WebSocket from "ws"; import { TokenPayload } from "../core/ApiSchemas"; import { Tick } from "../core/game/Game"; +import { packLevelBadge } from "../core/LevelBadgeWire"; import { ClientID, ClientPlatform, @@ -16,6 +17,10 @@ export class Client { public reportedWinner: Winner | null = null; + // levelBadge as the lobby roster carries it (ClientInfo.levelBadge), packed + // once here rather than for every entry of every per-recipient broadcast. + public readonly wireLevelBadge: number | undefined; + constructor( public readonly clientID: ClientID, public readonly persistentID: string, @@ -40,5 +45,7 @@ export class Client { // From the server's /users/@me lookup at join (levelBadgeFromProgress); // undefined for guests or when the API sent no progress. Display-only. public readonly levelBadge: LevelBadge | undefined = undefined, - ) {} + ) { + this.wireLevelBadge = packLevelBadge(levelBadge); + } } diff --git a/src/server/LevelBadge.ts b/src/server/LevelBadge.ts index f2c1cee734..9990abe7a6 100644 --- a/src/server/LevelBadge.ts +++ b/src/server/LevelBadge.ts @@ -1,5 +1,5 @@ import { Progress } from "../core/ApiSchemas"; -import { LevelBadge } from "../core/Schemas"; +import { LevelBadge, packLevelBadge } from "../core/LevelBadgeWire"; // The roster's level badge for a joining player, from the `progress` of the // game server's own /users/@me lookup — never from anything the client sent. @@ -7,17 +7,14 @@ import { LevelBadge } from "../core/Schemas"; // has progression off, or the field was malformed (UserMeResponseSchema // already reads a malformed object as absent). // -// The API types these as plain numbers, but the wire encodes level and -// prestige as unsigned varints, and a value the codec rejects would throw -// out of every lobby_info broadcast for the whole lobby. So anything that is -// not a whole level >= 1 and prestige >= 0 drops the badge instead. +// The API types these as plain numbers, but the roster packs them into one +// small integer (packLevelBadge: level 1..100, prestige 0..10), so anything +// it cannot pack drops the badge rather than sending a wrong one. export function levelBadgeFromProgress( progress: Progress | undefined, ): LevelBadge | undefined { if (progress === undefined) return undefined; const { level, prestige, legend } = progress; - if (!Number.isSafeInteger(level) || level < 1) return undefined; - if (!Number.isSafeInteger(prestige) || prestige < 0) return undefined; - if (typeof legend !== "boolean") return undefined; - return { level, prestige, legend }; + const badge = { level, prestige, legend }; + return packLevelBadge(badge) === undefined ? undefined : badge; } diff --git a/src/server/NameVisibility.ts b/src/server/NameVisibility.ts index 1fa217d73a..2dd3e50d1d 100644 --- a/src/server/NameVisibility.ts +++ b/src/server/NameVisibility.ts @@ -213,7 +213,7 @@ export class NameVisibility { // Only beside a real name — the anonymized branch above omits it, as a // level beside an anonymous name would point out the veterans. Kept on // a teammate reveal: it describes the teammate alone, like `verified`. - levelBadge: c.levelBadge, + levelBadge: c.wireLevelBadge, }; }); } diff --git a/tests/client/LobbyRosterLevels.test.ts b/tests/client/LobbyRosterLevels.test.ts index 2c44d5c3e6..e34cefe7bd 100644 --- a/tests/client/LobbyRosterLevels.test.ts +++ b/tests/client/LobbyRosterLevels.test.ts @@ -3,8 +3,11 @@ import { lobbyLevelBadge, rememberLobbyRoster, } from "../../src/client/LobbyRosterLevels"; +import { packLevelBadge } from "../../src/core/LevelBadgeWire"; const VET = { level: 100, prestige: 10, legend: true }; +// As the roster carries it. +const VET_WIRE = packLevelBadge(VET)!; describe("lobbyLevelBadge", () => { it("returns the badge from the last roster of that game", () => { @@ -13,7 +16,7 @@ describe("lobbyLevelBadge", () => { clientID: "vet00001", username: "Veteran", clanTag: null, - levelBadge: VET, + levelBadge: VET_WIRE, }, { clientID: "guest001", username: "Guest", clanTag: null }, ]); @@ -29,7 +32,7 @@ describe("lobbyLevelBadge", () => { clientID: "vet00001", username: "Veteran", clanTag: null, - levelBadge: VET, + levelBadge: VET_WIRE, }, ]); expect(lobbyLevelBadge("game0002", "vet00001")).toBeUndefined(); @@ -42,7 +45,7 @@ describe("lobbyLevelBadge", () => { clientID: "vet00001", username: "Veteran", clanTag: null, - levelBadge: VET, + levelBadge: VET_WIRE, }, ]); rememberLobbyRoster("game0001", [ @@ -56,3 +59,35 @@ describe("lobbyLevelBadge", () => { expect(lobbyLevelBadge("game0003", "vet00001")).toBeUndefined(); }); }); + +describe("lobbyLevelBadge decodes the packed roster value", () => { + it.each([ + { level: 1, prestige: 0, legend: false }, + { level: 57, prestige: 3, legend: false }, + { level: 100, prestige: 10, legend: false }, + { level: 100, prestige: 10, legend: true }, + ])("back to %o", (badge) => { + rememberLobbyRoster("game0004", [ + { + clientID: "pl000001", + username: "Player", + clanTag: null, + levelBadge: packLevelBadge(badge), + }, + ]); + expect(lobbyLevelBadge("game0004", "pl000001")).toEqual(badge); + }); + + it("reads a value no server sends as no badge, without throwing", () => { + const clients = [0, 101, 11 * 128 + 5, 4096 + 5, -1, 1.5].map((v, i) => ({ + clientID: `bad0000${i}`, + username: "Odd", + clanTag: null, + levelBadge: v, + })); + rememberLobbyRoster("game0005", clients); + for (const c of clients) { + expect(lobbyLevelBadge("game0005", c.clientID)).toBeUndefined(); + } + }); +}); diff --git a/tests/client/components/LobbyPlayerView.levelBadge.test.ts b/tests/client/components/LobbyPlayerView.levelBadge.test.ts index 25a010b5f9..b6a3e95701 100644 --- a/tests/client/components/LobbyPlayerView.levelBadge.test.ts +++ b/tests/client/components/LobbyPlayerView.levelBadge.test.ts @@ -3,9 +3,12 @@ import "../../../src/client/components/LobbyPlayerView"; import type { LobbyTeamView } from "../../../src/client/components/LobbyPlayerView"; import { GameMode, HumansVsNations } from "../../../src/core/game/Game"; import { UserSettings } from "../../../src/core/game/UserSettings"; +import { packLevelBadge } from "../../../src/core/LevelBadgeWire"; import type { ClientInfo, LevelBadge } from "../../../src/core/Schemas"; const BADGE: LevelBadge = { level: 42, prestige: 2, legend: false }; +// As the roster carries it. +const BADGE_WIRE = packLevelBadge(BADGE)!; function client( clientID: string, @@ -83,7 +86,7 @@ describe("lobby level badges", () => { it("puts a 24px badge in front of the name in FFA pills", async () => { const view = await mount({ currentClientID: "me", - clients: [client("me", { levelBadge: BADGE }), client("guest")], + clients: [client("me", { levelBadge: BADGE_WIRE }), client("guest")], }); // No alignment slot in the wrapped pills. @@ -93,7 +96,9 @@ describe("lobby level badges", () => { it("passes the badge's level, prestige and legend through", async () => { const view = await mount({ clients: [ - client("vet", { levelBadge: { level: 7, prestige: 3, legend: true } }), + client("vet", { + levelBadge: packLevelBadge({ level: 7, prestige: 3, legend: true }), + }), ], }); @@ -111,7 +116,7 @@ describe("lobby level badges", () => { const view = await mount({ clients: [ client("player"), - client("watcher", { spectator: true, levelBadge: BADGE }), + client("watcher", { spectator: true, levelBadge: BADGE_WIRE }), client("lurker", { spectator: true }), ], }); @@ -127,7 +132,7 @@ describe("lobby level badges", () => { const view = await mount({ gameMode: GameMode.Team, teamCount: HumansVsNations, - clients: [client("vet", { levelBadge: BADGE }), client("guest")], + clients: [client("vet", { levelBadge: BADGE_WIRE }), client("guest")], }); expect(playerRows(view)).toEqual([ @@ -163,7 +168,7 @@ describe("lobby level badges", () => { gameMode: GameMode.Team, teamCount: 2, clients: [ - client("vet", { levelBadge: BADGE }), + client("vet", { levelBadge: BADGE_WIRE }), client("a"), client("b"), client("c"), @@ -192,8 +197,8 @@ describe("lobby level badges", () => { const view = await mount({ currentClientID: "me", clients: [ - client("me", { levelBadge: BADGE }), - client("other", { levelBadge: BADGE }), + client("me", { levelBadge: BADGE_WIRE }), + client("other", { levelBadge: BADGE_WIRE }), ], }); @@ -208,7 +213,7 @@ describe("lobby level badges", () => { gameMode: GameMode.Team, teamCount: HumansVsNations, currentClientID: "me", - clients: [client("me"), client("other", { levelBadge: BADGE })], + clients: [client("me"), client("other", { levelBadge: BADGE_WIRE })], }); expect(view.querySelector("level-badge")).toBeNull(); diff --git a/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts b/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts index 250f60978b..28a00b98c8 100644 --- a/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts +++ b/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts @@ -39,6 +39,7 @@ import { rememberLobbyRoster } from "../../../../src/client/LobbyRosterLevels"; import { PlayerView } from "../../../../src/client/view"; import { PlayerType } from "../../../../src/core/game/Game"; import { UserSettings } from "../../../../src/core/game/UserSettings"; +import { packLevelBadge } from "../../../../src/core/LevelBadgeWire"; // With lit mocked, html`` yields { strings, values }: flatten one back into // markup so the identity row can be checked as text. @@ -82,13 +83,13 @@ describe("PlayerPanel - level badge", () => { clientID: "me", username: "me", clanTag: null, - levelBadge: { level: 12, prestige: 0, legend: false }, + levelBadge: packLevelBadge({ level: 12, prestige: 0, legend: false }), }, { clientID: "vet", username: "vet", clanTag: null, - levelBadge: { level: 42, prestige: 3, legend: true }, + levelBadge: packLevelBadge({ level: 42, prestige: 3, legend: true }), }, { clientID: "guest", username: "guest", clanTag: null }, ]); diff --git a/tests/core/ClientInfoLevelBadgeWire.test.ts b/tests/core/ClientInfoLevelBadgeWire.test.ts index bc534e1152..8849498646 100644 --- a/tests/core/ClientInfoLevelBadgeWire.test.ts +++ b/tests/core/ClientInfoLevelBadgeWire.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; +import { + LevelBadge, + packLevelBadge, + unpackLevelBadge, +} from "../../src/core/LevelBadgeWire"; import { ClanTagSchema, ClientInfo, @@ -16,6 +21,8 @@ import { } from "../../src/core/ZbinWire"; import { zb } from "../../zbin"; +const pack = (badge: LevelBadge): number => packLevelBadge(badge)!; + // The roster's level badge rides lobby_info as the last, optional field of // ClientInfo. These pin the wire contract that comment in Schemas.ts promises: // it round-trips, an entry without it is byte-identical to the pre-badge @@ -53,19 +60,34 @@ const FULL: ClientInfo = { }; describe("ClientInfo.levelBadge on the wire", () => { - it.each([ + it.each([ { level: 1, prestige: 0, legend: false }, { level: 37, prestige: 2, legend: false }, { level: 100, prestige: 10, legend: true }, - ])("round-trips a badge %o", (levelBadge) => { + ])("round-trips a badge %o as one packed integer", (badge) => { + const levelBadge = pack(badge); for (const base of [PLAIN, FULL]) { const entry = { ...base, levelBadge }; - expect(NewClientInfo.parseBytes(NewClientInfo.serialize(entry))).toEqual( - entry, - ); + const back = NewClientInfo.parseBytes(NewClientInfo.serialize(entry)); + expect(back).toEqual(entry); + expect(unpackLevelBadge(back.levelBadge)).toEqual(badge); } }); + it("costs one byte per badge at prestige 0 and two at most", () => { + const size = (levelBadge?: number) => + NewClientInfo.serialize({ ...PLAIN, levelBadge }).byteLength; + const none = size(); + expect(size(pack({ level: 1, prestige: 0, legend: false })) - none).toBe(1); + expect(size(pack({ level: 100, prestige: 0, legend: false })) - none).toBe( + 1, + ); + expect(size(pack({ level: 1, prestige: 1, legend: false })) - none).toBe(2); + expect(size(pack({ level: 100, prestige: 10, legend: true })) - none).toBe( + 2, + ); + }); + it("round-trips an entry without one, leaving it absent", () => { for (const entry of [PLAIN, FULL]) { const back = NewClientInfo.parseBytes(NewClientInfo.serialize(entry)); @@ -92,14 +114,22 @@ describe("ClientInfo.levelBadge on the wire", () => { }); it("rejects a badge the codec cannot carry rather than mis-encoding it", () => { - // The server sanitises API values before stamping (levelBadgeFromProgress) - // because of this: an invalid badge throws out of the whole broadcast. - expect(() => - NewClientInfo.serialize({ - ...PLAIN, - levelBadge: { level: 1.5, prestige: 0, legend: false }, - }), - ).toThrow(); + // Never reached in practice: the server only sends packLevelBadge output. + for (const levelBadge of [1.5, -1]) { + expect(() => NewClientInfo.serialize({ ...PLAIN, levelBadge })).toThrow(); + } + }); + + it("decodes a packed value no server sends, which the accessor then drops", () => { + // The schema puts no range on the field, so one bad entry cannot fail + // the whole roster's parse; unpackLevelBadge reads it as no badge. + for (const levelBadge of [0, 101, 11 * 128 + 5, 4096 + 5, 2 ** 40]) { + const back = NewClientInfo.parseBytes( + NewClientInfo.serialize({ ...PLAIN, levelBadge }), + ); + expect(back.levelBadge).toBe(levelBadge); + expect(unpackLevelBadge(back.levelBadge)).toBeUndefined(); + } }); it("carries badges through a full lobby_info frame", () => { @@ -110,13 +140,16 @@ describe("ClientInfo.levelBadge on the wire", () => { gameID: "gM3xQ1zR", serverTime: 1_700_000_000_000, clients: [ - { ...PLAIN, levelBadge: { level: 12, prestige: 1, legend: false } }, + { + ...PLAIN, + levelBadge: pack({ level: 12, prestige: 1, legend: false }), + }, FULL, { clientID: "Zz8wVu5t", username: "charlie", clanTag: null, - levelBadge: { level: 100, prestige: 10, legend: true }, + levelBadge: pack({ level: 100, prestige: 10, legend: true }), }, ], }, diff --git a/tests/core/LevelBadgeWire.test.ts b/tests/core/LevelBadgeWire.test.ts new file mode 100644 index 0000000000..4879355b4f --- /dev/null +++ b/tests/core/LevelBadgeWire.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + LEVEL_BADGE_MAX_LEVEL, + LEVEL_BADGE_MAX_PRESTIGE, + LevelBadge, + packLevelBadge, + unpackLevelBadge, +} from "../../src/core/LevelBadgeWire"; +import { ClientInfoSchema } from "../../src/core/Schemas"; +import { zb } from "../../zbin"; + +// The roster carries a badge as one packed integer: level in bits 0-6, +// prestige in bits 7-10, legend in bit 11. + +// Bytes a packed badge adds to a roster entry (its presence bit is in the +// header byte the entry has anyway). +const Entry = zb.object(ClientInfoSchema.shape); +const BASE = { clientID: "aB3dEf7h", username: "alpha", clanTag: null }; +const varintBytes = (levelBadge: number) => + Entry.serialize({ ...BASE, levelBadge }).byteLength - + Entry.serialize(BASE).byteLength; + +describe("packLevelBadge / unpackLevelBadge", () => { + it.each<[LevelBadge, number]>([ + [{ level: 1, prestige: 0, legend: false }, 1], + [{ level: 100, prestige: 0, legend: false }, 100], + [{ level: 1, prestige: 1, legend: false }, 129], + [{ level: 37, prestige: 2, legend: false }, 37 + 2 * 128], + [{ level: 100, prestige: 10, legend: false }, 100 + 10 * 128], + [{ level: 100, prestige: 10, legend: true }, 100 + 10 * 128 + 2048], + ])("packs %o as %i and back", (badge, packed) => { + expect(packLevelBadge(badge)).toBe(packed); + expect(unpackLevelBadge(packed)).toEqual(badge); + }); + + it("round-trips every level and prestige, with and without legend", () => { + for (let prestige = 0; prestige <= LEVEL_BADGE_MAX_PRESTIGE; prestige++) { + for (let level = 1; level <= LEVEL_BADGE_MAX_LEVEL; level++) { + for (const legend of [false, true]) { + const badge = { level, prestige, legend }; + expect(unpackLevelBadge(packLevelBadge(badge))).toEqual(badge); + } + } + } + }); + + it("is one varint byte at prestige 0 and two at most", () => { + expect( + varintBytes(packLevelBadge({ level: 1, prestige: 0, legend: false })!), + ).toBe(1); + expect( + varintBytes(packLevelBadge({ level: 100, prestige: 0, legend: false })!), + ).toBe(1); + expect( + varintBytes(packLevelBadge({ level: 1, prestige: 1, legend: false })!), + ).toBe(2); + expect( + varintBytes(packLevelBadge({ level: 100, prestige: 10, legend: true })!), + ).toBe(2); + }); + + it("packs nothing it cannot represent", () => { + for (const bad of [ + { level: 0, prestige: 0, legend: false }, + { level: 101, prestige: 0, legend: false }, + { level: 128, prestige: 0, legend: false }, + { level: -1, prestige: 0, legend: false }, + { level: 12.5, prestige: 0, legend: false }, + { level: Number.NaN, prestige: 0, legend: false }, + { level: 1, prestige: -1, legend: false }, + { level: 1, prestige: 11, legend: false }, + { level: 1, prestige: 16, legend: false }, + { level: 1, prestige: 1.5, legend: false }, + { level: 1, prestige: 0, legend: "yes" as unknown as boolean }, + ]) { + expect(packLevelBadge(bad)).toBeUndefined(); + } + expect(packLevelBadge(undefined)).toBeUndefined(); + }); + + it("decodes values no server sends as no badge, without throwing", () => { + for (const bad of [ + 0, // level 0 + 101, // level 101 + 127, // level 127 + 128, // prestige 1, level 0 + 11 * 128 + 5, // prestige 11 + 15 * 128 + 5, // prestige 15 + 2048, // legend alone, level 0 + 4096 + 5, // a reserved bit + 2 ** 40, // far out of range + -1, + 1.5, + Number.NaN, + ]) { + expect(unpackLevelBadge(bad)).toBeUndefined(); + } + expect(unpackLevelBadge(undefined)).toBeUndefined(); + }); +}); diff --git a/tests/server/RosterLevelBadge.test.ts b/tests/server/RosterLevelBadge.test.ts index 63c94f7459..1b5ae7c242 100644 --- a/tests/server/RosterLevelBadge.test.ts +++ b/tests/server/RosterLevelBadge.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UserMeResponseSchema } from "../../src/core/ApiSchemas"; import { GameType } from "../../src/core/game/Game"; +import { + packLevelBadge, + unpackLevelBadge, +} from "../../src/core/LevelBadgeWire"; import { GameConfig, GameInfo, @@ -69,7 +73,8 @@ describe("levelBadgeFromProgress: stamping from /users/@me", () => { }); it("stamps nothing for values the wire cannot carry", () => { - // These would otherwise throw out of every lobby_info broadcast. + // The roster packs a badge into one integer (level 1..100, prestige + // 0..10); anything outside that is dropped rather than sent wrong. for (const bad of [ { level: 0 }, { level: -1 }, @@ -78,11 +83,24 @@ describe("levelBadgeFromProgress: stamping from /users/@me", () => { { prestige: -1 }, { prestige: 1.5 }, { level: Number.MAX_SAFE_INTEGER + 2 }, + { level: 101 }, + { level: 128 }, + { prestige: 11 }, + { prestige: 16 }, ]) { expect(levelBadgeFromProgress({ ...progress, ...bad })).toBeUndefined(); } }); + it("stamps the edge values", () => { + for (const edge of [ + { level: 1, prestige: 0, legend: false }, + { level: 100, prestige: 10, legend: true }, + ]) { + expect(levelBadgeFromProgress({ ...progress, ...edge })).toEqual(edge); + } + }); + it("stamps nothing for a guest (no lookup at all)", () => { expect(levelBadgeFromProgress(undefined)).toBeUndefined(); }); @@ -92,6 +110,9 @@ const VET: LevelBadge = { level: 100, prestige: 10, legend: true }; const MID: LevelBadge = { level: 37, prestige: 2, legend: false }; const byId = (info: GameInfo, id: string) => info.clients!.find((c) => c.clientID === id)!; +// The entry's badge, unpacked from its wire form. +const badgeOf = (info: GameInfo, id: string) => + unpackLevelBadge(byId(info, id).levelBadge); describe("lobby roster carries the server-stamped badge", () => { beforeEach(() => vi.useFakeTimers()); @@ -136,8 +157,10 @@ describe("lobby roster carries the server-stamped badge", () => { it("names on: every signed-in player's badge, none for the guest", () => { const info = lobby().gameInfo("guest001"); - expect(byId(info, "vet00001").levelBadge).toEqual(VET); - expect(byId(info, "mid00001").levelBadge).toEqual(MID); + // On the roster as the packed integer, decoding to the stamped badge. + expect(byId(info, "vet00001").levelBadge).toBe(packLevelBadge(VET)); + expect(badgeOf(info, "vet00001")).toEqual(VET); + expect(badgeOf(info, "mid00001")).toEqual(MID); expect(byId(info, "guest001").levelBadge).toBeUndefined(); }); @@ -153,7 +176,7 @@ describe("lobby roster carries the server-stamped badge", () => { undefined, ); if (back.type !== "lobby_info") throw new Error("wrong type"); - expect(byId(back.lobby, "vet00001").levelBadge).toEqual(VET); + expect(badgeOf(back.lobby, "vet00001")).toEqual(VET); expect(byId(back.lobby, "guest001").levelBadge).toBeUndefined(); }); @@ -169,7 +192,7 @@ describe("lobby roster carries the server-stamped badge", () => { it("anonymizeNames: a player still sees their own badge", () => { const info = lobby({ anonymizeNames: true }).gameInfo("vet00001"); - expect(byId(info, "vet00001").levelBadge).toEqual(VET); + expect(badgeOf(info, "vet00001")).toEqual(VET); expect(byId(info, "mid00001").levelBadge).toBeUndefined(); }); @@ -178,8 +201,8 @@ describe("lobby roster carries the server-stamped badge", () => { anonymizeNames: true, nameReveals: ["guest001"], }).gameInfo("guest001"); - expect(byId(info, "vet00001").levelBadge).toEqual(VET); - expect(byId(info, "mid00001").levelBadge).toEqual(MID); + expect(badgeOf(info, "vet00001")).toEqual(VET); + expect(badgeOf(info, "mid00001")).toEqual(MID); }); it("anonymizeNames: a pinned teammate's badge shows with their real name", () => { @@ -188,7 +211,7 @@ describe("lobby roster carries the server-stamped badge", () => { ["g-pub"], ]).gameInfo("mid00001"); expect(byId(info, "vet00001").username).toBe("Veteran"); - expect(byId(info, "vet00001").levelBadge).toEqual(VET); + expect(badgeOf(info, "vet00001")).toEqual(VET); }); it("anonymizeNames: the public HTTP view (no viewer) shows no badges", () => { @@ -196,3 +219,20 @@ describe("lobby roster carries the server-stamped badge", () => { for (const c of info.clients!) expect(c.levelBadge).toBeUndefined(); }); }); + +describe("Client packs its badge once, at construction", () => { + it("holds the wire form beside the readable one", () => { + const c = makeClient({ clientID: "vet00001", levelBadge: VET }); + expect(c.levelBadge).toEqual(VET); + expect(c.wireLevelBadge).toBe(packLevelBadge(VET)); + }); + + it("has no wire badge for a guest or a badge it cannot pack", () => { + expect(makeClient({ clientID: "guest001" }).wireLevelBadge).toBeUndefined(); + const odd = makeClient({ + clientID: "odd00001", + levelBadge: { level: 101, prestige: 0, legend: false }, + }); + expect(odd.wireLevelBadge).toBeUndefined(); + }); +}); From f6bf42888eba101439ef09e75a98801cba27b6ba Mon Sep 17 00:00:00 2001 From: iamlewis Date: Mon, 5 Oct 2026 16:30:49 +0100 Subject: [PATCH 13/17] perf: draw a full lobby's level badges over a few frames A 150-player lobby put 150 badges (300 in team mode, with the team cards) on screen in one frame, and the browser's style and layout of all those SVGs made the roster's first paint long. The badge drawing itself is cheap now; the layout is what's left. Lobby badges now carry a `stagger` attribute. When more than 8 of them arrive in one render (a lobby opening, a big batch of joins, a switch to team mode), each first renders an empty square the size of the badge, and a shared requestAnimationFrame loop (LevelBadgeFill) draws them a few per frame, nearest the viewport first. The rows frame lays out only the rows. After that, each frame draws a batch and measures its script plus forced layout, and steers the next batch to about 5 ms. A few badges arriving at once (a player joining, a badge changing) are drawn straight away, as before, also while a fill is running. The square is the drawn badge's exact box inside the same host element, so nothing moves when a badge is drawn. Badges taken off the page leave the queue, and the loop cancels its frame once nothing is waiting. Streamer mode and anonymised rosters are unchanged: those rows never get a badge element. Co-Authored-By: Claude Opus 5.5 --- src/client/components/LevelBadge.ts | 50 +++- src/client/components/LevelBadgeFill.ts | 163 +++++++++++ src/client/components/LobbyPlayerView.ts | 4 + .../LobbyPlayerView.badgeFill.test.ts | 269 ++++++++++++++++++ 4 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 src/client/components/LevelBadgeFill.ts create mode 100644 tests/client/components/LobbyPlayerView.badgeFill.test.ts diff --git a/src/client/components/LevelBadge.ts b/src/client/components/LevelBadge.ts index d04cf15aab..027d31f7f7 100644 --- a/src/client/components/LevelBadge.ts +++ b/src/client/components/LevelBadge.ts @@ -8,6 +8,13 @@ import { prestigeTier, } from "../Progression"; import { translateText } from "../Utils"; +import { + badgeArrived, + dequeueBadge, + queueBadge, + shouldStagger, + type StaggeredBadge, +} from "./LevelBadgeFill"; // 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 @@ -162,17 +169,47 @@ export function levelBadgeLabel( } @customElement("level-badge") -export class LevelBadge extends LitElement { +export class LevelBadge extends LitElement implements StaggeredBadge { @property({ type: Number }) level = 1; @property({ type: Number }) prestige = 0; @property({ type: Boolean }) legend = false; // Rendered size in CSS pixels (square). 16 is the smallest supported. @property({ type: Number }) size = 24; + // With the `stagger` attribute (long lists): when many badges appear at + // once, this one first holds its square empty and is drawn on a later + // frame (see LevelBadgeFill). Decided once, at its first render. + private waiting = false; + createRenderRoot() { return this; } + connectedCallback(): void { + super.connectedCallback(); + if (!this.hasUpdated && this.hasAttribute("stagger")) badgeArrived(); + else if (this.waiting) queueBadge(this); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + if (this.waiting) dequeueBadge(this); + } + + protected willUpdate(): void { + if (!this.hasUpdated && this.hasAttribute("stagger") && shouldStagger()) { + this.waiting = true; + queueBadge(this); + } + } + + /** Draws a badge that is waiting its turn in a long list. */ + draw(): void { + if (!this.waiting) return; + this.waiting = false; + this.requestUpdate(); + } + /** Accessible name, e.g. "Level 42, Prestige 3". */ label(): string { return levelBadgeLabel(this.level, this.prestige, this.legend); @@ -344,9 +381,18 @@ export class LevelBadge extends LitElement { } render() { + const px = Math.max(16, this.size); + if (this.waiting) { + // The drawn badge's exact box, so nothing moves when it is drawn. + return html``; + } 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 diff --git a/src/client/components/LevelBadgeFill.ts b/src/client/components/LevelBadgeFill.ts new file mode 100644 index 0000000000..4181147c72 --- /dev/null +++ b/src/client/components/LevelBadgeFill.ts @@ -0,0 +1,163 @@ +// Staggered drawing for long lists of level badges. +// +// A badge is cheap to build but not to lay out: a full 150-player lobby puts +// 150 badges (300 in team mode, with the team cards) on screen at once, and +// the browser's style and layout of all those SVGs in one frame is what made +// the roster's first paint slow. Badges marked `stagger` that arrive many in +// one render are therefore drawn a few per frame instead: each first holds its +// square empty (so nothing moves when it is drawn) and this loop draws them, +// nearest the viewport first, sized so each frame stays inside a small +// budget. A few badges arriving at once (a player joining, a badge changing) +// are drawn straight away, as before. + +/** What the loop needs from a badge waiting to be drawn. */ +export interface StaggeredBadge { + /** Draws the badge now in place of its empty square. */ + draw(): void; + readonly updateComplete: Promise; + readonly isConnected: boolean; + getBoundingClientRect(): DOMRect; +} + +// Up to this many staggered badges arriving in one render are drawn +// straight away: about a millisecond or two of layout. +export const STAGGER_ABOVE = 8; +// Each frame's share of the drawing: its script plus the style and layout it +// causes, measured, steers how many badges the next frame draws. +const FRAME_BUDGET_MS = 5; +const FIRST_BATCH = 12; +const MIN_BATCH = 4; +const MAX_BATCH = 64; + +// Waiting badges, in arrival (document) order until sorted. +const waiting = new Set(); +let frame: number | null = null; +// New badges have arrived since the queue was last ordered. +let unsorted = false; +let batch = FIRST_BATCH; +// Staggered badges put on the page and not yet rendered: one render of a +// list. Counted afresh once that render's badges have all decided. +let arrivals = 0; +let countDone = false; +let idleWaiters: (() => void)[] = []; + +/** Counts a staggered badge put on the page, before its first render. */ +export function badgeArrived(): void { + arrivals++; +} + +/** + * At a staggered badge's first render: true when it arrived with too many + * others to draw them all at once. A list's render puts all its badges on + * the page before any of them renders (each renders in its own microtask, + * queued as it is attached), so every badge of that render gets the same + * answer; the count starts afresh in a microtask queued behind theirs. + */ +export function shouldStagger(): boolean { + if (!countDone) { + countDone = true; + queueMicrotask(() => { + arrivals = 0; + countDone = false; + }); + } + return arrivals > STAGGER_ABOVE; +} + +/** Queues a badge to be drawn on a later frame. */ +export function queueBadge(badge: StaggeredBadge): void { + waiting.add(badge); + unsorted = true; + schedule(); +} + +/** Takes a badge out of the queue (removed from the page before its turn). */ +export function dequeueBadge(badge: StaggeredBadge): void { + if (!waiting.delete(badge) || waiting.size > 0) return; + if (frame !== null) { + cancelAnimationFrame(frame); + frame = null; + } + settle(); +} + +/** Badges still waiting to be drawn. */ +export function pendingBadges(): number { + return waiting.size; +} + +/** True while a frame is booked to draw waiting badges. */ +export function badgeFrameScheduled(): boolean { + return frame !== null; +} + +/** Resolves once every waiting badge has been drawn (or removed). */ +export function badgesDrawn(): Promise { + if (waiting.size === 0) return Promise.resolve(); + return new Promise((resolve) => idleWaiters.push(resolve)); +} + +function schedule(): void { + frame ??= requestAnimationFrame(() => { + frame = null; + void tick(); + }); +} + +function settle(): void { + batch = FIRST_BATCH; + const waiters = idleWaiters; + idleWaiters = []; + for (const resolve of waiters) resolve(); +} + +// How far a badge is from the visible part of the page, in pixels. +function offscreenDistance(badge: StaggeredBadge): number { + const r = badge.getBoundingClientRect(); + const h = window.innerHeight; + if (r.bottom < 0) return -r.bottom; + if (r.top > h) return r.top - h; + return 0; +} + +async function tick(): Promise { + if (unsorted) { + // The frame the new rows arrive in: order them nearest the viewport + // first and draw nothing yet, so that frame lays out only the rows. The + // rects are read before anything changes here, from the layout this + // frame needs anyway. + unsorted = false; + const order = [...waiting] + .map((badge) => ({ badge, d: offscreenDistance(badge) })) + .sort((a, b) => a.d - b.d); + waiting.clear(); + for (const { badge } of order) waiting.add(badge); + schedule(); + return; + } + const start = performance.now(); + const drawn: StaggeredBadge[] = []; + for (const badge of waiting) { + if (drawn.length >= batch) break; + waiting.delete(badge); + if (!badge.isConnected) continue; + badge.draw(); + drawn.push(badge); + } + if (drawn.length > 0) { + await Promise.all(drawn.map((b) => b.updateComplete)); + // Lay the new badges out now rather than after this callback, so their + // real cost is measured; the browser would do the same work this frame. + drawn[drawn.length - 1].getBoundingClientRect(); + const cost = performance.now() - start; + const scale = FRAME_BUDGET_MS / Math.max(cost, 0.25); + batch = Math.round( + Math.min( + MAX_BATCH, + Math.max(MIN_BATCH, batch * Math.min(2, Math.max(0.5, scale))), + ), + ); + } + if (waiting.size > 0) schedule(); + else if (frame === null) settle(); +} diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index c93283254b..46d9ae02e7 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -626,6 +626,9 @@ export class LobbyTeamView extends LitElement { // without one gets an empty space the same size instead, so the names in a // column line up; column layouts pass it only when someone in the list has // a visible badge, so a list with no badges renders exactly as before. + // `stagger`: a full lobby's badges arriving at once are drawn over the + // next few frames instead of all in one (see LevelBadgeFill); a few at a + // time, like a player joining, are drawn straight away. private renderLevelBadge(client: ClientInfo, slot: boolean) { const badge = this.visibleLevelBadge(client); if (badge === undefined) { @@ -637,6 +640,7 @@ export class LobbyTeamView extends LitElement { : html``; } return html` { + const i = from + k; + return { + clientID: `c${i}`, + username: `Player${i}`, + clanTag: null, + levelBadge: packLevelBadge({ + level: 1 + ((i * 37) % 100), + prestige: i % 11, + legend: i === 7, + }), + }; + }); +} + +const badges = (view: Element) => + Array.from(view.querySelectorAll("level-badge")); +const waitingSquares = (view: Element) => + view.querySelectorAll("level-badge > [data-badge-waiting]"); +const drawnBadges = (view: Element) => + view.querySelectorAll("level-badge > svg[role=img]"); + +// Mounts the view and lets every badge finish its first render, but no +// animation frame: what the first paint shows. +async function mount( + clients: ClientInfo[], + props: Partial = {}, +): Promise { + const view = document.createElement("lobby-player-view") as LobbyTeamView; + view.gameMode = GameMode.FFA; + view.teamCount = 2; + Object.assign(view, props, { clients }); + document.body.append(view); + await view.updateComplete; + await Promise.all(badges(view).map((b) => b.updateComplete)); + return view; +} + +async function update(view: LobbyTeamView, clients: ClientInfo[]) { + view.clients = clients; + await view.updateComplete; + await Promise.all(badges(view).map((b) => b.updateComplete)); +} + +// A 150-player roster is slow to build in jsdom (300 badges in team mode), +// more so with the rest of the suite running alongside. +describe("lobby badges arriving many at once", { timeout: 20_000 }, () => { + // jsdom's frames are 16 ms apart and its layout-free "frames" are slow to + // build badges in, which would make a 300-badge fill take seconds; frames + // here come back as soon as the event loop is free. + let cancel: ReturnType; + beforeEach(() => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation( + (cb) => setTimeout(() => cb(performance.now()), 0) as unknown as number, + ); + cancel = vi + .spyOn(window, "cancelAnimationFrame") + .mockImplementation((id) => clearTimeout(id)); + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + }); + + afterEach(async () => { + document.body.replaceChildren(); + await badgesDrawn(); + vi.restoreAllMocks(); + }); + + for (const [mode, perPlayer] of [ + [GameMode.FFA, 1], + [GameMode.Team, 2], + ] as const) { + describe(`${mode}`, () => { + it("draws 150 players' badges over later frames, then all of them", async () => { + const view = await mount(roster(150), { gameMode: mode }); + + // First paint: every row has its badge element, holding its square. + expect(badges(view).length).toBe(150 * perPlayer); + expect(waitingSquares(view).length).toBe(150 * perPlayer); + expect(drawnBadges(view).length).toBe(0); + expect(badgeFrameScheduled()).toBe(true); + + await badgesDrawn(); + + expect(waitingSquares(view).length).toBe(0); + expect(drawnBadges(view).length).toBe(150 * perPlayer); + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + // Each one is the player's own badge. + const labels = badges(view) + .slice(0, 3) + .map((b) => b.querySelector("svg")?.getAttribute("aria-label")); + expect(labels).toEqual([ + "progression.level", + "progression.level, progression.prestige", + "progression.level, progression.prestige", + ]); + }); + }); + } + + it("draws an 8-player FFA lobby straight away", async () => { + expect(8).toBeLessThanOrEqual(STAGGER_ABOVE); + const view = await mount(roster(8)); + + expect(drawnBadges(view).length).toBe(8); + expect(waitingSquares(view).length).toBe(0); + expect(pendingBadges()).toBe(0); + }); + + it("staggers an 8-player team lobby's 16 badges and completes", async () => { + const view = await mount(roster(8), { gameMode: GameMode.Team }); + + expect(waitingSquares(view).length).toBe(16); + await badgesDrawn(); + expect(drawnBadges(view).length).toBe(16); + expect(waitingSquares(view).length).toBe(0); + }); + + it("holds exactly the drawn badge's box while waiting", async () => { + const view = await mount(roster(150)); + const first = badges(view)[0]; + const square = first.querySelector("[data-badge-waiting]")!; + const hostClass = first.className; + + expect(square.style.width).toBe("24px"); + expect(square.style.height).toBe("24px"); + expect(square.classList.contains("block")).toBe(true); + expect(square.getAttribute("aria-hidden")).toBe("true"); + // The badge sits in front of the name from the first paint on. + expect(first.parentElement!.firstElementChild).toBe(first); + expect(first.parentElement!.textContent).toContain("Player0"); + + await badgesDrawn(); + + const svg = first.querySelector("svg")!; + expect(svg.getAttribute("width")).toBe("24"); + expect(svg.getAttribute("height")).toBe("24"); + expect(svg.classList.contains("block")).toBe(true); + // Same host element, same classes: the row around it is unchanged. + expect(badges(view)[0]).toBe(first); + expect(first.className).toBe(hostClass); + }); + + it("draws a single join straight away, also during a fill", async () => { + const players = roster(150); + const view = await mount(players); + expect(pendingBadges()).toBe(150); + + await update(view, [...players, ...roster(1, 150)]); + + const joined = badges(view)[badges(view).length - 1]; + expect(joined.querySelector("svg[role=img]")).not.toBeNull(); + expect(pendingBadges()).toBe(150); + + await badgesDrawn(); + await update(view, [...players, ...roster(2, 150)]); + const last = badges(view)[badges(view).length - 1]; + expect(last.querySelector("svg[role=img]")).not.toBeNull(); + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + }); + + it("redraws a changed badge on a drawn list straight away", async () => { + const players = roster(150); + const view = await mount(players); + await badgesDrawn(); + + const changed = players.map((c, i) => + i === 3 + ? { + ...c, + levelBadge: packLevelBadge({ + level: 1, + prestige: 0, + legend: true, + }), + } + : c, + ); + await update(view, changed); + + expect( + badges(view)[3].querySelector("svg")?.getAttribute("data-level-band"), + ).toBe("legend"); + expect(pendingBadges()).toBe(0); + }); + + it("shows no other player's badge with anonymous names on", async () => { + new UserSettings().toggleRandomName(); + const view = await mount(roster(150), { currentClientID: "c0" }); + + // Only the viewer's own badge, drawn at once (it arrives alone). + expect(badges(view).length).toBe(1); + expect(drawnBadges(view).length).toBe(1); + expect(pendingBadges()).toBe(0); + }); + + it("shows no badges, and holds no squares, for an anonymised roster", async () => { + // The server sends anonymised entries without a level badge. + const anonymous = roster(150).map(({ levelBadge: _, ...c }) => c); + const view = await mount(anonymous, { + gameMode: GameMode.Team, + anonymizeNames: true, + }); + + expect(badges(view).length).toBe(0); + expect(view.querySelector(".lobby-level-slot")).toBeNull(); + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + }); + + it("cancels the pending frames when the list is removed mid-fill", async () => { + cancel.mockClear(); + const view = await mount(roster(150), { gameMode: GameMode.Team }); + // Let the fill get going: the ordering frame, then a drawing frame. + await new Promise((r) => requestAnimationFrame(r)); + await new Promise((r) => requestAnimationFrame(r)); + await new Promise((r) => setTimeout(r, 0)); + const waitingBefore = pendingBadges(); + expect(waitingBefore).toBeGreaterThan(0); + expect(waitingBefore).toBeLessThan(300); + + view.remove(); + + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + expect(cancel).toHaveBeenCalled(); + // Nothing is drawn into the removed list afterwards. + const stillWaiting = waitingSquares(view).length; + for (let i = 0; i < 3; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + expect(waitingSquares(view).length).toBe(stillWaiting); + await expect(badgesDrawn()).resolves.toBeUndefined(); + }); + + it("draws a list re-attached mid-fill once it is back", async () => { + const view = await mount(roster(150)); + view.remove(); + expect(pendingBadges()).toBe(0); + + document.body.append(view); + expect(pendingBadges()).toBe(150); + await badgesDrawn(); + expect(drawnBadges(view).length).toBe(150); + }); +}); From d1ae3787d27e4be10593c08975f63034eae512b0 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Tue, 6 Oct 2026 16:53:02 +0100 Subject: [PATCH 14/17] Point the XP popup test at EventBus's new home in @openfront/shared Main moved EventBus out of engine-lib (#5806). Co-Authored-By: Claude Opus 5.5 --- tests/client/graphics/layers/WinModalXp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/graphics/layers/WinModalXp.test.ts b/tests/client/graphics/layers/WinModalXp.test.ts index fd49e692e3..117763663f 100644 --- a/tests/client/graphics/layers/WinModalXp.test.ts +++ b/tests/client/graphics/layers/WinModalXp.test.ts @@ -54,7 +54,7 @@ vi.mock("../../../../src/client/CrazyGamesSDK", () => ({ })); import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; -import { EventBus } from "@openfront/engine-lib/EventBus"; +import { EventBus } from "@openfront/shared/EventBus"; import type { GameXpPanel } from "../../../../src/client/components/GameXpPanel"; import "../../../../src/client/hud/layers/WinModal"; import type { WinModal } from "../../../../src/client/hud/layers/WinModal"; From 5fec34ede96c6089166dca75a1be626da6164de0 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 8 Oct 2026 14:23:30 +0100 Subject: [PATCH 15/17] fix: tolerate malformed row level fields; stagger list badges; let names truncate Review fixes for the leaderboard and clan member level badges. - RowLevelFields: each field is range-checked (level 1-100, prestige 0-10, whole numbers) and `.catch(undefined)`, so one malformed or out-of-range value no longer fails the whole ladder or member-list parse, and a bad level can't render as a level-1 badge. - LevelBadgeSlot: a row shows a badge only when all three fields are present, so a row with one dropped field shows none rather than a guessed prestige or legend flag. Says why the ranked leaderboard badges only prestiged players and Legends. - List badges are `stagger`ed, like the lobby roster's, so a page of them is drawn over a few frames. - `min-w-0` on the beside the badge (clan member rows, the leaderboard rows and the pinned row) so the name can truncate. playerNameLink takes an optional class for this. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 26 +++++++-- src/client/components/LevelBadgeSlot.ts | 39 +++++++++++--- src/client/components/clan/ClanShared.ts | 7 ++- .../leaderboard/LeaderboardPlayerList.ts | 9 ++-- src/client/components/ui/PlayerNameLink.ts | 5 +- tests/ApiSchemas.test.ts | 53 +++++++++++++++++-- tests/client/LeaderboardModal.test.ts | 43 +++++++++++++++ tests/client/clan/ClanApiSchemas.test.ts | 30 +++++++++-- tests/client/clan/ClanMemberLevels.test.ts | 32 +++++++++++ 9 files changed, 220 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 7598851943..76442c1377 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -12,6 +12,10 @@ import { } from "@openfront/engine-api/game/GameTypes"; import { z } from "zod"; import { base64urlToUuid } from "./Base64"; +import { + LEVEL_BADGE_MAX_LEVEL, + LEVEL_BADGE_MAX_PRESTIGE, +} from "./LevelBadgeWire"; const RequiredClanTagSchema = ClanTagSchema.unwrap(); @@ -195,11 +199,25 @@ export type PublicProgress = z.infer; // A player's level as stamped onto rows in other players' lists (the ranked // leaderboard, clan member lists), for drawing a level badge. Sent together // for a player with progress; absent for one without, while progression is -// off, and from an API that predates them. +// off, and from an API that predates them. Display-only, so a malformed or +// out-of-range value reads as absent (that row shows no badge) rather than +// failing the whole list's parse or drawing a level the player doesn't have. export const RowLevelFields = { - level: z.number().optional(), - prestige: z.number().optional(), - legend: z.boolean().optional(), + level: z + .number() + .int() + .min(1) + .max(LEVEL_BADGE_MAX_LEVEL) + .optional() + .catch(undefined), + prestige: z + .number() + .int() + .min(0) + .max(LEVEL_BADGE_MAX_PRESTIGE) + .optional() + .catch(undefined), + legend: z.boolean().optional().catch(undefined), }; // GET /public/progression/config — the level curve. No auth, cacheable. diff --git a/src/client/components/LevelBadgeSlot.ts b/src/client/components/LevelBadgeSlot.ts index edbf39816f..d9fd3d66ed 100644 --- a/src/client/components/LevelBadgeSlot.ts +++ b/src/client/components/LevelBadgeSlot.ts @@ -4,7 +4,8 @@ import "./LevelBadge"; /** * A row's level fields as the API sends them on leaderboard and clan member * rows: all three present for a player with progress, all absent otherwise - * (no progress yet, or progression switched off). + * (no progress yet, or progression switched off). The schema drops a + * malformed field on its own, so a row can also arrive with only some. */ export interface LevelFields { level?: number; @@ -12,14 +13,32 @@ export interface LevelFields { legend?: boolean; } +/** + * The row's badge, or undefined unless all three fields are present: with + * one dropped as malformed the row shows no badge rather than a guessed one. + */ +function rowBadge(row: LevelFields): Required | undefined { + const { level, prestige, legend } = row; + if (level === undefined || prestige === undefined || legend === undefined) { + return undefined; + } + return { level, prestige, legend }; +} + /** True when the row carries a level to draw. */ export function hasLevel(row: LevelFields): boolean { - return row.level !== undefined; + return rowBadge(row) !== undefined; } -/** True when the row has prestiged at least once or is a Legend. */ +/** + * True when the row has prestiged at least once or is a Legend. The ranked + * leaderboard badges only these players, so that the badge stays rare enough + * there to mean something and the board doesn't get cluttered; clan member + * lists and the lobby roster badge anyone with a level. + */ export function isPrestigedOrLegend(row: LevelFields): boolean { - return hasLevel(row) && (row.legend === true || (row.prestige ?? 0) > 0); + const badge = rowBadge(row); + return badge !== undefined && (badge.legend || badge.prestige > 0); } /** @@ -27,6 +46,8 @@ export function isPrestigedOrLegend(row: LevelFields): boolean { * an empty slot of the same width so names stay lined up with the rows that * do have one. With `reserveSlot` false (no row in the list has a badge), * renders nothing at all, so lists look unchanged until levels appear. + * Badges are `stagger`ed: a page of them arriving at once is drawn over a + * few frames (see LevelBadgeFill). */ export function levelBadgeSlot( row: LevelFields, @@ -34,12 +55,14 @@ export function levelBadgeSlot( reserveSlot: boolean, size = 24, ): TemplateResult | typeof nothing { - if (show && row.level !== undefined) { + const badge = show ? rowBadge(row) : undefined; + if (badge !== undefined) { return html``; } diff --git a/src/client/components/clan/ClanShared.ts b/src/client/components/clan/ClanShared.ts index 61e09678a3..ad259467e0 100644 --- a/src/client/components/clan/ClanShared.ts +++ b/src/client/components/clan/ClanShared.ts @@ -417,7 +417,12 @@ export function renderMemberRow(
${levelBadgeSlot(member, hasLevel(member), levelSlot)} - ${playerNameLink(host, member.username, member.publicId)} + ${playerNameLink( + host, + member.username, + member.publicId, + "min-w-0", + )}
${levelBadgeSlot(entry, isPrestigedOrLegend(entry), badgeSlot)} ${levelBadgeSlot(player, isPrestigedOrLegend(player), badgeSlot)} ` wired to - * dispatchViewProfile from `host`. + * dispatchViewProfile from `host`. `className` goes on the `` + * itself, e.g. `min-w-0` so it can shrink as a flex item and the name truncate. */ export function playerNameLink( host: HTMLElement, username: string | null | undefined, publicId: string, + className = "", ): TemplateResult { return html` { ).toMatchObject(level); }); - it("rejects a ranked entry with a malformed level", () => { + it.each([ + ["a string level", { level: "42" }], + ["a null prestige", { prestige: null }], + ["a numeric legend", { legend: 1 }], + ["level 0", { level: 0 }], + ["level 101", { level: 101 }], + ["a fractional level", { level: 4.5 }], + ["a negative prestige", { prestige: -1 }], + ["prestige 11", { prestige: 11 }], + ])("drops %s instead of rejecting the entry", (_, bad) => { + const result = RankedLeaderboardEntrySchema.parse({ + ...ranked, + ...level, + ...bad, + }); + const [key] = Object.keys(bad) as (keyof typeof level)[]; + expect(result[key]).toBeUndefined(); + expect(result.public_id).toBe("abc123"); + }); + + it("keeps the edges of the level range", () => { expect( - RankedLeaderboardEntrySchema.safeParse({ ...ranked, level: "42" }) - .success, - ).toBe(false); + RankedLeaderboardEntrySchema.parse({ + ...ranked, + level: 1, + prestige: 0, + legend: false, + }), + ).toMatchObject({ level: 1, prestige: 0 }); + expect( + RankedLeaderboardEntrySchema.parse({ + ...ranked, + level: 100, + prestige: 10, + legend: true, + }), + ).toMatchObject({ level: 100, prestige: 10 }); + }); + + it("still parses a ladder with one malformed row", () => { + const result = RankedLeaderboardResponseSchema.parse({ + "1v1": [ + { ...ranked, ...level }, + { ...ranked, public_id: "bad", ...level, legend: 1 }, + ], + }); + expect(result["1v1"]).toHaveLength(2); + expect(result["1v1"][0]).toMatchObject(level); + expect(result["1v1"][1].legend).toBeUndefined(); }); it("parses a mapped entry with and without level fields", () => { diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index 2e32467384..6c4131b769 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -133,6 +133,7 @@ beforeEach(() => { }); import { RankedType } from "@openfront/engine-api/game/GameTypes"; +import { RankedLeaderboardResponseSchema } from "@openfront/shared/ApiSchemas"; import "../../src/client/components/baseComponents/Modal"; import { LeaderboardModal } from "../../src/client/LeaderboardModal"; @@ -778,6 +779,48 @@ describe("LeaderboardModal", () => { expect(leading(tfoot)).toEqual({ "p-zero": "slot" }); }); + it("shows no badge for a row whose level fields were malformed", async () => { + // Through the real schema: one bad row loses its badge, not the ladder. + const ladder = RankedLeaderboardResponseSchema.parse({ + "1v1": [ + entry(1, "p-prestiged", prestiged), + { ...entry(2, "p-bad-legend", prestiged), legend: 1 }, + { ...entry(3, "p-level-zero", prestiged), level: 0 }, + { ...entry(4, "p-null-prestige", legend), prestige: null }, + ], + }); + await load(ladder); + + expect(leading(tbody())).toEqual({ + "p-prestiged": "badge", + "p-bad-legend": "slot", + "p-level-zero": "slot", + "p-null-prestige": "slot", + }); + }); + + it("staggers the badges and lets the names shrink beside them", async () => { + const playerList = await load( + { "1v1": [entry(1, "p-prestiged", prestiged), entry(2, "p-zero")] }, + "p-zero", + ); + playerList.showStickyUser = true; + await playerList.updateComplete; + + expect( + tbody()!.querySelector("level-badge")!.hasAttribute("stagger"), + ).toBe(true); + // A flex item's min-width defaults to its content, which would stop the + // truncating name from shrinking beside the badge. + const names = modal.querySelectorAll( + "leaderboard-player-list player-name", + ); + expect(names.length).toBe(3); + for (const name of Array.from(names)) { + expect(name.classList.contains("min-w-0")).toBe(true); + } + }); + it("leaves the pinned row unchanged when no row has a badge", async () => { const playerList = await load( { "1v1": [entry(1, "p-zero", unprestiged)] }, diff --git a/tests/client/clan/ClanApiSchemas.test.ts b/tests/client/clan/ClanApiSchemas.test.ts index c7b1dcdc43..afa1f34324 100644 --- a/tests/client/clan/ClanApiSchemas.test.ts +++ b/tests/client/clan/ClanApiSchemas.test.ts @@ -10,6 +10,7 @@ import { ClanInfoSchema, ClanJoinRequestSchema, ClanMemberSchema, + ClanMembersResponseSchema, } from "@openfront/shared/ClanApiSchemas"; import { describe, expect, it } from "vitest"; @@ -131,8 +132,8 @@ describe("ClanMemberSchema", () => { expect(result).toMatchObject({ level: 100, prestige: 10, legend: true }); }); - it("rejects a malformed legend flag", () => { - const result = ClanMemberSchema.safeParse({ + it("drops a malformed legend flag instead of rejecting the member", () => { + const result = ClanMemberSchema.parse({ role: "member", joinedAt: "2024-03-01T09:30:00.000Z", publicId: "abc123", @@ -140,7 +141,30 @@ describe("ClanMemberSchema", () => { prestige: 0, legend: "yes", }); - expect(result.success).toBe(false); + expect(result).toMatchObject({ publicId: "abc123", level: 5 }); + expect(result.legend).toBeUndefined(); + }); + + it("still parses a member list with one malformed row", () => { + const row = (publicId: string, prestige: unknown) => ({ + role: "member", + joinedAt: "2024-03-01T09:30:00.000Z", + publicId, + level: 40, + prestige, + legend: false, + }); + const result = ClanMembersResponseSchema.parse({ + results: [row("good", 2), row("bad", null), row("high", 400)], + total: 3, + page: 1, + limit: 10, + }); + expect(result.results.map((m) => m.prestige)).toEqual([ + 2, + undefined, + undefined, + ]); }); it("rejects null publicId", () => { diff --git a/tests/client/clan/ClanMemberLevels.test.ts b/tests/client/clan/ClanMemberLevels.test.ts index 244a6659bb..ec4f2e8872 100644 --- a/tests/client/clan/ClanMemberLevels.test.ts +++ b/tests/client/clan/ClanMemberLevels.test.ts @@ -96,6 +96,38 @@ describe("renderMemberRow level badge", () => { it("renders the name alone when no slot is asked for", () => { expect(leading(renderRow(member("a")))).toEqual({ a: "name" }); }); + + it("shows no badge for a member with a field dropped as malformed", () => { + // The schema reads a malformed field as absent; a partial level must not + // be drawn with a guessed prestige or legend flag. + for (const partial of [ + { level: 33, legend: false }, + { level: 33, prestige: 4 }, + { prestige: 4, legend: true }, + ]) { + expect(membersHaveLevels([member("a", partial)])).toBe(false); + expect(leading(renderRow(member("a", partial), true))).toEqual({ + a: "slot", + }); + } + }); + + it("staggers the badge", () => { + const host = renderRow(member("a", prestiged), true); + expect(host.querySelector("level-badge")!.hasAttribute("stagger")).toBe( + true, + ); + }); + + it("keeps the name truncating beside the badge", () => { + const host = renderRow(member("a", prestiged), true); + const name = host.querySelector("player-name")!; + // A flex item's min-width defaults to its content, which would stop the + // truncating name from shrinking beside the badge. + expect(name.classList.contains("min-w-0")).toBe(true); + expect(name.parentElement!.classList.contains("min-w-0")).toBe(true); + expect(name.parentElement!.classList.contains("flex")).toBe(true); + }); }); describe("ClanDetailView member levels", () => { From 1f01c8568c5ad7286a184ea4e0507a7ffbcca2a2 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 8 Oct 2026 14:26:25 +0100 Subject: [PATCH 16/17] Honour "hide my level" on the roster badge; own badge after a refresh - The game server stamps no roster badge for a player whose /users/@me says levelHidden (absent reads as shown, malformed as hidden), so this branch no longer depends on the opt-out PR to respect the setting. The schema field, levelBadgeForPlayer and its tests match #5771. - The in-game player panel falls back to the viewer's own /users/@me progress for their own badge when the tab never saw the lobby roster (mid-game refresh or late join). The viewer still sees their own badge when they hide it from others, as their profile does. - A waiting in the stagger queue is aria-hidden until drawn. - Trim self-describing JSDoc in LevelBadgeFill/LevelBadge and the LobbyRosterLevels header. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 9 ++ packages/shared/src/WireSchemas.ts | 18 +-- src/client/LobbyRosterLevels.ts | 24 +--- src/client/components/LevelBadge.ts | 7 +- src/client/components/LevelBadgeFill.ts | 5 - src/client/hud/layers/PlayerPanel.ts | 42 +++++- src/server/Client.ts | 5 +- src/server/LevelBadge.ts | 20 ++- src/server/Worker.ts | 6 +- .../LobbyPlayerView.badgeFill.test.ts | 7 + .../layers/PlayerPanelLevelBadge.test.ts | 131 +++++++++++++++++- tests/server/RosterLevelBadge.test.ts | 108 ++++++++++++++- 12 files changed, 328 insertions(+), 54 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 68e868cdcb..a3265dcf24 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -357,6 +357,15 @@ export const UserMeResponseSchema = z.object({ // reads as absent: drift in this cosmetic field must never fail the whole // /users/@me parse, which would make the player look signed out. progress: ProgressSchema.optional().catch(undefined), + // "Hide my level": true when the player opted out of showing their level + // to others. They still earn XP and see their own level; the game server + // then leaves the badge off their lobby roster entry. Optional so an older + // API without the setting still parses — absent reads as shown, and the + // account settings hide the Privacy card. A malformed value reads as + // hidden: the server parses this response at join, so a bad value must + // never fail the parse and reject the join, and when the setting can't be + // read the private answer is the safe one. + levelHidden: z.boolean().optional().catch(true), clans: z .array( z.object({ diff --git a/packages/shared/src/WireSchemas.ts b/packages/shared/src/WireSchemas.ts index 6237f902be..02a138bdec 100644 --- a/packages/shared/src/WireSchemas.ts +++ b/packages/shared/src/WireSchemas.ts @@ -223,14 +223,16 @@ export const ClientInfoSchema = z.object({ // prestige and legend packed into one varint by packLevelBadge // (LevelBadgeWire.ts); read it with unpackLevelBadge. Stamped by the game // server from its own /users/@me lookup, never taken from the client. - // Display-only. Absent for guests, when the API has progression off or the - // lookup failed, and on anonymized entries (a badge beside an anonymous name - // would point out the veterans). Must stay the LAST field: its one presence - // bit is the eighth and last bit of this object's one-byte header, so an - // entry without it encodes byte-identically to one from before it existed, - // and a pre-badge frame still decodes. Another field here would need a - // second header byte. No range check here on purpose: one bad value must - // not fail the whole roster's parse, so unpackLevelBadge drops it instead. + // Display-only. Absent for guests, for players who hide their level (on + // every copy of the roster, their own included), when the API has + // progression off or the lookup failed, and on anonymized entries (a badge + // beside an anonymous name would point out the veterans). Must stay the + // LAST field: its one presence bit is the eighth and last bit of this + // object's one-byte header, so an entry without it encodes byte-identically + // to one from before it existed, and a pre-badge frame still decodes. + // Another field here would need a second header byte. No range check here + // on purpose: one bad value must not fail the whole roster's parse, so + // unpackLevelBadge drops it instead. levelBadge: zb.uint().optional(), }); diff --git a/src/client/LobbyRosterLevels.ts b/src/client/LobbyRosterLevels.ts index 6fb1acecdc..bc16bb7bc0 100644 --- a/src/client/LobbyRosterLevels.ts +++ b/src/client/LobbyRosterLevels.ts @@ -2,22 +2,10 @@ import { ClientID, GameID } from "@openfront/engine-api/Schemas"; import { LevelBadge, unpackLevelBadge } from "@openfront/shared/LevelBadgeWire"; import { ClientInfo } from "@openfront/shared/WireSchemas"; -// Level badges from the last lobby roster this tab was sent, so in-game UI -// can show them after the lobby is gone. The roster (lobby_info) only flows -// while the game is in its lobby, and the start message carries no levels, -// so this is the only copy the client keeps. Keyed by clientID, which is -// the same id PlayerView.clientID() returns in-game. -// -// Limits, all of which simply mean "no badge": a player who joined in the -// last broadcast interval before start, and anyone viewed from a tab that -// never saw the lobby (a late-joining spectator, or a page refresh mid-game, -// which rejoins straight into the game). -// -// Per-viewer like the roster itself: anonymized entries arrive without a -// badge, so nothing here can reveal one. -// -// Kept in wire form (packLevelBadge) — a roster arrives every second, a badge -// is read only when a panel opens — and unpacked on read. +// Level badges from the last lobby roster this tab was sent, for in-game UI. +// The start message carries no levels, so a tab that never saw the lobby (a +// refresh or late join) has none. Kept in wire form, as a roster arrives every +// second and a badge is read only when a panel opens. let rosterGameID: GameID | null = null; let badges = new Map(); @@ -35,8 +23,8 @@ export function rememberLobbyRoster( } // The player's badge in game `gameID`, or undefined when they have none (a -// guest, an anonymized name, progression off, a value out of range) or this -// tab never saw them in that game's lobby. +// guest, an anonymized name, progression off, their level hidden, a value out +// of range) or this tab never saw them in that game's lobby. export function lobbyLevelBadge( gameID: GameID, clientID: ClientID | null, diff --git a/src/client/components/LevelBadge.ts b/src/client/components/LevelBadge.ts index 027d31f7f7..ea64f8dfbe 100644 --- a/src/client/components/LevelBadge.ts +++ b/src/client/components/LevelBadge.ts @@ -178,7 +178,9 @@ export class LevelBadge extends LitElement implements StaggeredBadge { // With the `stagger` attribute (long lists): when many badges appear at // once, this one first holds its square empty and is drawn on a later - // frame (see LevelBadgeFill). Decided once, at its first render. + // frame (see LevelBadgeFill). Decided once, at its first render. The host + // is aria-hidden meanwhile, so a screen reader meets no run of unnamed + // elements. private waiting = false; createRenderRoot() { @@ -199,14 +201,15 @@ export class LevelBadge extends LitElement implements StaggeredBadge { protected willUpdate(): void { if (!this.hasUpdated && this.hasAttribute("stagger") && shouldStagger()) { this.waiting = true; + this.setAttribute("aria-hidden", "true"); queueBadge(this); } } - /** Draws a badge that is waiting its turn in a long list. */ draw(): void { if (!this.waiting) return; this.waiting = false; + this.removeAttribute("aria-hidden"); this.requestUpdate(); } diff --git a/src/client/components/LevelBadgeFill.ts b/src/client/components/LevelBadgeFill.ts index 4181147c72..915d479c19 100644 --- a/src/client/components/LevelBadgeFill.ts +++ b/src/client/components/LevelBadgeFill.ts @@ -41,7 +41,6 @@ let arrivals = 0; let countDone = false; let idleWaiters: (() => void)[] = []; -/** Counts a staggered badge put on the page, before its first render. */ export function badgeArrived(): void { arrivals++; } @@ -64,14 +63,12 @@ export function shouldStagger(): boolean { return arrivals > STAGGER_ABOVE; } -/** Queues a badge to be drawn on a later frame. */ export function queueBadge(badge: StaggeredBadge): void { waiting.add(badge); unsorted = true; schedule(); } -/** Takes a badge out of the queue (removed from the page before its turn). */ export function dequeueBadge(badge: StaggeredBadge): void { if (!waiting.delete(badge) || waiting.size > 0) return; if (frame !== null) { @@ -81,12 +78,10 @@ export function dequeueBadge(badge: StaggeredBadge): void { settle(); } -/** Badges still waiting to be drawn. */ export function pendingBadges(): number { return waiting.size; } -/** True while a frame is booked to draw waiting badges. */ export function badgeFrameScheduled(): boolean { return frame !== null; } diff --git a/src/client/hud/layers/PlayerPanel.ts b/src/client/hud/layers/PlayerPanel.ts index 6b5b6ea045..e7f8980602 100644 --- a/src/client/hud/layers/PlayerPanel.ts +++ b/src/client/hud/layers/PlayerPanel.ts @@ -9,13 +9,17 @@ import { } from "@openfront/engine-api/game/GameTypes"; import { Emoji, flattenedEmojiTable } from "@openfront/engine-api/Schemas"; import { renderNumber, renderTroops } from "@openfront/engine-lib/Format"; +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; import { assetUrl } from "@openfront/shared/AssetUrls"; import { EventBus } from "@openfront/shared/EventBus"; -import type { LevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { + type LevelBadge, + packLevelBadge, +} from "@openfront/shared/LevelBadgeWire"; import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import Countries from "resources/countries.json" with { type: "json" }; -import { fetchLobbyListed } from "../../Api"; +import { fetchLobbyListed, getUserMe } from "../../Api"; import "../../components/LevelBadge"; import { actionButton } from "../../components/ui/ActionButton"; import "../../components/ui/Divider"; @@ -57,6 +61,18 @@ const startTradingIcon = assetUrl("images/TradingIconWhite.svg"); const traitorIcon = assetUrl("images/TraitorIconLightRed.svg"); const breakAllianceIcon = assetUrl("images/TraitorIconWhite.svg"); +// The viewer's own badge from their /users/@me progress. Shown even when they +// hide their level from others, as their profile does. +export function ownLevelBadgeFrom( + me: UserMeResponse | false, +): LevelBadge | undefined { + const progress = me === false ? undefined : me.player.progress; + if (progress === undefined) return undefined; + const { level, prestige, legend } = progress; + const badge = { level, prestige, legend }; + return packLevelBadge(badge) === undefined ? undefined : badge; +} + @customElement("player-panel") export class PlayerPanel extends LitElement implements Controller { public g: GameView; @@ -86,6 +102,9 @@ export class PlayerPanel extends LitElement implements Controller { // Whether this game is a publicly listed lobby. Kept out of // GameStartInfo (never touches records), so it's fetched from the worker. @state() private gameListed = false; + // Fallback for the viewer's own badge when this tab never saw the lobby + // roster (a refresh or late join goes straight into the game). + private ownLevelBadge: LevelBadge | undefined; setRole(role: string | null): void { this.playerRole = role; @@ -138,6 +157,11 @@ export class PlayerPanel extends LitElement implements Controller { this.gameListed = listed; }); } + + // Memoised: normally answered from the profile loaded at sign-in. + void getUserMe().then((me) => { + this.ownLevelBadge = ownLevelBadgeFrom(me); + }); } async tick() { @@ -571,16 +595,20 @@ export class PlayerPanel extends LitElement implements Controller { `; } - // The level badge from the lobby roster, for human players only. Hidden for - // anyone but the viewer's own player while anonymous names are on: their - // name is anonymized then (PlayerView.displayName), and the badge belongs - // to the real one. + // The level badge from the lobby roster, for human players only; the + // viewer's own falls back to their /users/@me. Hidden for anyone but the + // viewer's own player while anonymous names are on: their name is + // anonymized then (PlayerView.displayName), and the badge belongs to the + // real one. private levelBadgeFor(other: PlayerView): LevelBadge | undefined { if (other.type() !== PlayerType.Human) return undefined; const clientID = other.clientID(); const isSelf = clientID !== null && clientID === this.g.myClientID(); if (!isSelf && this.userSettings.anonymousNames()) return undefined; - return lobbyLevelBadge(this.g.gameID(), clientID); + return ( + lobbyLevelBadge(this.g.gameID(), clientID) ?? + (isSelf ? this.ownLevelBadge : undefined) + ); } private renderIdentityRow(other: PlayerView, my: PlayerView) { diff --git a/src/server/Client.ts b/src/server/Client.ts index 55d296acc6..07e75f86a5 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -40,8 +40,9 @@ export class Client { public readonly trusted: boolean = false, // Client-reported and unverified; metric dimension only. public readonly platform: ClientPlatform | "unknown" = "unknown", - // From the server's /users/@me lookup at join (levelBadgeFromProgress); - // undefined for guests or when the API sent no progress. Display-only. + // From the server's /users/@me lookup at join (levelBadgeForPlayer); + // undefined for guests, players who hide their level, or when the API sent + // no progress. Display-only. public readonly levelBadge: LevelBadge | undefined = undefined, ) { this.wireLevelBadge = packLevelBadge(levelBadge); diff --git a/src/server/LevelBadge.ts b/src/server/LevelBadge.ts index b46840fdc3..495c60425d 100644 --- a/src/server/LevelBadge.ts +++ b/src/server/LevelBadge.ts @@ -1,11 +1,21 @@ import { Progress } from "@openfront/shared/ApiSchemas"; import { LevelBadge, packLevelBadge } from "@openfront/shared/LevelBadgeWire"; -// The roster's level badge for a joining player, from the `progress` of the -// game server's own /users/@me lookup — never from anything the client sent. -// Undefined (no badge) when the response has no progress: a guest, the API -// has progression off, or the field was malformed (UserMeResponseSchema -// already reads a malformed object as absent). +// The roster's level badge for a joining player, from the `player` of the game +// server's own /users/@me lookup — never from anything the client sent. No +// badge when the player chose to hide their level (`levelHidden`); an API +// without the setting omits the field, which reads as shown. +export function levelBadgeForPlayer(player: { + progress?: Progress; + levelHidden?: boolean; +}): LevelBadge | undefined { + if (player.levelHidden === true) return undefined; + return levelBadgeFromProgress(player.progress); +} + +// The badge from a /users/@me `progress`. Undefined (no badge) when there is +// no progress: a guest, the API has progression off, or the field was +// malformed (UserMeResponseSchema already reads a malformed object as absent). // // The API types these as plain numbers, but the roster packs them into one // small integer (packLevelBadge: level 1..100, prestige 0..10), so anything diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 0934735885..125013e397 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -37,7 +37,7 @@ import { registerGamePreviewRoute } from "./GamePreviewRoute"; import { GamePhase, type GameServer } from "./GameServer"; import { isSteamAuthenticated, planJoinVerify, verifyJoin } from "./JoinVerify"; import { getUserMe, userMeFailureClose, verifyClientToken } from "./jwt"; -import { levelBadgeFromProgress } from "./LevelBadge"; +import { levelBadgeForPlayer } from "./LevelBadge"; import { payForLobbyQueue, queueListedLobby } from "./LobbyQueuePayment"; import { logger } from "./Logger"; import { resolveVerifiedJoin } from "./Privilege"; @@ -748,8 +748,8 @@ export async function startWorker() { accountUsername = result.response.player; trusted = result.response.player.trustTier === "trusted"; // Display-only: the roster badge comes from this lookup alone, never - // from the join message. - levelBadge = levelBadgeFromProgress(result.response.player.progress); + // from the join message. None when the player hides their level. + levelBadge = levelBadgeForPlayer(result.response.player); if (allowedFlares !== undefined) { const allowed = diff --git a/tests/client/components/LobbyPlayerView.badgeFill.test.ts b/tests/client/components/LobbyPlayerView.badgeFill.test.ts index 266b73e419..b42fb4a596 100644 --- a/tests/client/components/LobbyPlayerView.badgeFill.test.ts +++ b/tests/client/components/LobbyPlayerView.badgeFill.test.ts @@ -38,6 +38,8 @@ const waitingSquares = (view: Element) => view.querySelectorAll("level-badge > [data-badge-waiting]"); const drawnBadges = (view: Element) => view.querySelectorAll("level-badge > svg[role=img]"); +const ariaHiddenBadges = (view: Element) => + view.querySelectorAll('level-badge[aria-hidden="true"]'); // Mounts the view and lets every badge finish its first render, but no // animation frame: what the first paint shows. @@ -100,11 +102,15 @@ describe("lobby badges arriving many at once", { timeout: 20_000 }, () => { expect(waitingSquares(view).length).toBe(150 * perPlayer); expect(drawnBadges(view).length).toBe(0); expect(badgeFrameScheduled()).toBe(true); + // A waiting badge has no name yet, so it is out of the + // accessibility tree until drawn. + expect(ariaHiddenBadges(view).length).toBe(150 * perPlayer); await badgesDrawn(); expect(waitingSquares(view).length).toBe(0); expect(drawnBadges(view).length).toBe(150 * perPlayer); + expect(ariaHiddenBadges(view).length).toBe(0); expect(pendingBadges()).toBe(0); expect(badgeFrameScheduled()).toBe(false); // Each one is the player's own badge. @@ -126,6 +132,7 @@ describe("lobby badges arriving many at once", { timeout: 20_000 }, () => { expect(drawnBadges(view).length).toBe(8); expect(waitingSquares(view).length).toBe(0); + expect(ariaHiddenBadges(view).length).toBe(0); expect(pendingBadges()).toBe(0); }); diff --git a/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts b/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts index 9153350575..f0173e842c 100644 --- a/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts +++ b/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts @@ -29,14 +29,26 @@ vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ vi.mock("../../../../src/client/components/LevelBadge", () => ({})); +const getUserMe = vi.hoisted(() => + vi.fn<() => Promise>(async () => false), +); +vi.mock("../../../../src/client/Api", async (importOriginal) => ({ + ...(await importOriginal()), + getUserMe, +})); + vi.mock("../../../../src/client/InGameModal", () => ({ showInGameConfirm: vi.fn(), showInGameAlert: vi.fn(), })); -import { PlayerType } from "@openfront/engine-api/game/GameTypes"; +import { GameType, PlayerType } from "@openfront/engine-api/game/GameTypes"; +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; -import { PlayerPanel } from "../../../../src/client/hud/layers/PlayerPanel"; +import { + ownLevelBadgeFrom, + PlayerPanel, +} from "../../../../src/client/hud/layers/PlayerPanel"; import { rememberLobbyRoster } from "../../../../src/client/LobbyRosterLevels"; import { UserSettings } from "../../../../src/client/UserSettings"; import { PlayerView } from "../../../../src/client/view"; @@ -155,3 +167,118 @@ describe("PlayerPanel - level badge", () => { expect(own).toContain(".level=12"); }); }); + +const progress = { + prestige: 2, + level: 37, + xpInLevel: 0, + xpForNext: 900, + lifetimeXp: 123456, + legend: false, + canPrestige: false, +}; +function userMe(player: Record): UserMeResponse { + return { + user: {}, + player: { publicId: "me-pub", ...player }, + } as unknown as UserMeResponse; +} + +describe("PlayerPanel - your own badge without the lobby roster", () => { + let panel: PlayerPanel; + + // A refresh or late join: this tab saw only another game's lobby. + async function initPanel(gameID = "game-2") { + panel = new PlayerPanel(); + (panel as any).g = { + gameID: () => gameID, + myClientID: () => "me", + config: () => ({ + gameConfig: () => ({ gameType: GameType.Public }), + }), + }; + (panel as any).eventBus = { on: vi.fn() }; + (panel as any).renderTraitorBadge = () => ""; + (panel as any).renderRelationPillIfNation = () => ""; + panel.init(); + await getUserMe.mock.results[0]?.value; + await Promise.resolve(); + } + + const row = (other: PlayerView) => + flatten((panel as any).renderIdentityRow(other, player("me"))); + + beforeEach(() => { + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + getUserMe.mockReset(); + rememberLobbyRoster("game-1", [ + { + clientID: "me", + username: "me", + clanTag: null, + levelBadge: packLevelBadge({ level: 12, prestige: 0, legend: false }), + }, + { + clientID: "vet", + username: "vet", + clanTag: null, + levelBadge: packLevelBadge({ level: 42, prestige: 3, legend: true }), + }, + ]); + }); + + test("shows your own badge from /users/@me, and nobody else's", async () => { + getUserMe.mockResolvedValue(userMe({ progress })); + await initPanel(); + + const own = row(player("me")); + expect(own).toContain(" { + getUserMe.mockResolvedValue(userMe({ progress })); + await initPanel("game-1"); + expect(row(player("me"))).toContain(".level=12"); + }); + + test("still shows you your own badge when you hide your level", async () => { + getUserMe.mockResolvedValue(userMe({ progress, levelHidden: true })); + await initPanel(); + expect(row(player("me"))).toContain(".level=37"); + }); + + test("shows nothing signed out or without progress", async () => { + getUserMe.mockResolvedValue(false); + await initPanel(); + expect(row(player("me"))).not.toContain(" { + test("is the badge from /users/@me progress", () => { + expect(ownLevelBadgeFrom(userMe({ progress }))).toEqual({ + level: 37, + prestige: 2, + legend: false, + }); + }); + + test("drops values the badge cannot show, like the server does", () => { + for (const bad of [{ level: 0 }, { level: 101 }, { prestige: 11 }]) { + expect( + ownLevelBadgeFrom(userMe({ progress: { ...progress, ...bad } })), + ).toBeUndefined(); + } + }); +}); diff --git a/tests/server/RosterLevelBadge.test.ts b/tests/server/RosterLevelBadge.test.ts index a1aa28da7f..b0adbd6b41 100644 --- a/tests/server/RosterLevelBadge.test.ts +++ b/tests/server/RosterLevelBadge.test.ts @@ -12,11 +12,14 @@ import { encodeServerMessage, } from "@openfront/shared/ZbinWire"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { levelBadgeFromProgress } from "../../src/server/LevelBadge"; +import { + levelBadgeForPlayer, + levelBadgeFromProgress, +} from "../../src/server/LevelBadge"; import { makeClient, makeGame } from "../util/GameServerHarness"; // The game server stamps each signed-in player's level onto the lobby roster -// from its own /users/@me lookup (Worker.ts join -> levelBadgeFromProgress -> +// from its own /users/@me lookup (Worker.ts join -> levelBadgeForPlayer -> // Client.levelBadge -> NameVisibility.lobbyClients). Display-only. const progress = { @@ -103,6 +106,87 @@ describe("levelBadgeFromProgress: stamping from /users/@me", () => { }); }); +describe("levelBadgeForPlayer: hide my level", () => { + it("parses /users/@me with and without levelHidden", () => { + expect(userMe({ progress }).player.levelHidden).toBeUndefined(); + expect(userMe({ progress, levelHidden: true }).player.levelHidden).toBe( + true, + ); + expect(userMe({ progress, levelHidden: false }).player.levelHidden).toBe( + false, + ); + }); + + it("reads a malformed levelHidden as hidden instead of failing the parse", () => { + for (const bad of [null, "yes", "true", 1, 0, {}]) { + const me = userMe({ progress, levelHidden: bad }); + expect(me.player.levelHidden).toBe(true); + expect(me.player.progress).toBeDefined(); + // Fails closed: no badge for a setting that can't be read. + expect(levelBadgeForPlayer(me.player)).toBeUndefined(); + } + }); + + it("stamps nothing when the player hides their level", () => { + const me = userMe({ progress, levelHidden: true }); + expect(levelBadgeForPlayer(me.player)).toBeUndefined(); + }); + + it("stamps the badge when the level is shown", () => { + const me = userMe({ progress, levelHidden: false }); + expect(levelBadgeForPlayer(me.player)).toEqual({ + level: 37, + prestige: 2, + legend: false, + }); + }); + + it("stamps the badge when an older API omits the setting", () => { + expect(levelBadgeForPlayer(userMe({ progress }).player)).toEqual({ + level: 37, + prestige: 2, + legend: false, + }); + }); + + it("still stamps nothing without progress, hidden or not", () => { + expect(levelBadgeForPlayer(userMe().player)).toBeUndefined(); + expect( + levelBadgeForPlayer(userMe({ levelHidden: false }).player), + ).toBeUndefined(); + }); + + it("a hidden player's entry carries no badge on the wire, for anyone", () => { + const game = makeGame({ config: { gameType: GameType.Private } }); + game.joinClient( + makeClient({ + clientID: "hide0001", + username: "Hider", + publicId: "hide-pub", + levelBadge: levelBadgeForPlayer( + userMe({ progress, levelHidden: true }).player, + ), + }), + ); + game.joinClient( + makeClient({ clientID: "peer0001", username: "Peer", publicId: "p-pub" }), + ); + for (const viewer of ["hide0001", "peer0001"]) { + const msg: ServerMessage = { + type: "lobby_info", + lobby: game.gameInfo(viewer), + myClientID: viewer, + }; + const back = decodeServerMessage( + encodeServerMessage(msg, undefined), + undefined, + ); + if (back.type !== "lobby_info") throw new Error("wrong type"); + expect(byId(back.lobby, "hide0001")).not.toHaveProperty("levelBadge"); + } + }); +}); + const VET: LevelBadge = { level: 100, prestige: 10, legend: true }; const MID: LevelBadge = { level: 37, prestige: 2, legend: false }; const byId = (info: GameInfo, id: string) => @@ -215,6 +299,26 @@ describe("lobby roster carries the server-stamped badge", () => { const info = lobby({ anonymizeNames: true }).gameInfo(); for (const c of info.clients!) expect(c.levelBadge).toBeUndefined(); }); + + it("names on: a hidden player's entry has no badge, even in the public HTTP view", () => { + const game = lobby(); + game.joinClient( + makeClient({ + clientID: "hide0001", + username: "Hider", + publicId: "hide-pub", + levelBadge: levelBadgeForPlayer( + userMe({ progress, levelHidden: true }).player, + ), + }), + ); + for (const viewer of [undefined, "hide0001", "vet00001"]) { + const info = game.gameInfo(viewer); + expect(byId(info, "hide0001").username).toBe("Hider"); + expect(byId(info, "hide0001").levelBadge).toBeUndefined(); + expect(badgeOf(info, "vet00001")).toEqual(VET); + } + }); }); describe("Client packs its badge once, at construction", () => { From 812cc15b5534120bc7f0f558f7c0aa0d55a20a9f Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 8 Oct 2026 14:33:23 +0100 Subject: [PATCH 17/17] Address review on the player levels UI - Singleplayer games that come back "unverified" (they never carry the end-of-game vote) now say "Singleplayer games don't earn XP." instead of "couldn't be verified". WinModal and GameStatsModal pass the game type to the panel and card; the stats modal waits for the game record before showing that one reason, so the wrong line never flashes. - XP in the subtotal that no known source accounts for (e.g. a row stored under the old `firstWinOfDay` key) is shown as its own "Other" line instead of being apportioned across the known cards. - The progression config memo expires after the server's 60s max-age, so a deploy that changes it reaches open tabs; failures are still not cached. - The reveal stops on the levels the server reports in `levelsReached`, and a full bar stays just short of the next level, so a level-up from a full bar is celebrated and its milestone card isn't shown from the first frame. - level-badge, game-xp-panel and past-game-xp-card re-render on a language change. - Documented why MAX_LEVEL/MAX_PRESTIGE stay client constants (badge art) and that MILESTONE_LEVELS must match the server's list. - Trimmed the narration above startReveal. - Tests for fetchMyGameXp / fetchPublicPlayerProgress status mapping and fetchProgressionConfig caching, the legacy breakdown row, the full-bar level-up and the singleplayer copy. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 2 + src/client/GameStatsModal.ts | 14 ++ src/client/LangSelector.ts | 3 + src/client/Progression.ts | 42 +++- src/client/ProgressionApi.ts | 30 ++- src/client/components/GameXpPanel.ts | 38 ++-- src/client/components/PastGameXpCard.ts | 6 +- src/client/hud/layers/WinModal.ts | 1 + tests/client/LevelBadge.test.ts | 57 ++++- tests/client/PastGameXpCard.test.ts | 54 ++++- tests/client/ProgressionApi.test.ts | 202 ++++++++++++++++++ tests/client/ProgressionModals.test.ts | 42 +++- .../client/graphics/layers/WinModalXp.test.ts | 113 +++++++++- 13 files changed, 563 insertions(+), 41 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 67dfcf3144..4ee9471bbf 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1794,6 +1794,7 @@ "ineligible_generic": "This game didn't earn XP.", "ineligible_no_action": "No XP this time: you didn't take any actions.", "ineligible_not_spawned": "You didn't spawn, so this game didn't earn XP.", + "ineligible_singleplayer": "Singleplayer games don't earn XP.", "ineligible_too_few_humans": "Not enough human players in this game to earn XP.", "ineligible_too_short": "This game was too short to earn XP.", "ineligible_unverified": "This game's results couldn't be verified, so it didn't earn XP.", @@ -1807,6 +1808,7 @@ "lifetime_xp": "{xp} lifetime XP", "line_feats": "Feats", "line_first_game": "First game of the day", + "line_other": "Other", "line_placement": "Placement", "line_played": "Game played", "line_time": "Time played", diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts index ac066f973a..35e50729d1 100644 --- a/src/client/GameStatsModal.ts +++ b/src/client/GameStatsModal.ts @@ -1,3 +1,4 @@ +import type { GameType } from "@openfront/engine-api/game/GameTypes"; import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import "./components/baseComponents/stats/GameInfoView"; @@ -23,6 +24,8 @@ export class GameStatsModal extends BaseModal { @state() private gameId: string | null = null; // The XP the signed-in player earned in this game, if any. @state() private xpView: PastGameXpView = { kind: "hidden" }; + // The open game's type, once GameInfoView has loaded its record. + @state() private gameType: GameType | null = null; // Cancels the current XP lookup (and its polling) on close or a new game. private xpAbort: AbortController | null = null; private openedFrom: "account" | "clan" | "profile" | null = null; @@ -64,6 +67,7 @@ export class GameStatsModal extends BaseModal { .gameId=${this.gameId} .afterSummary=${html``} @game-info-loaded=${this.onGameInfoLoaded} > @@ -76,6 +80,7 @@ export class GameStatsModal extends BaseModal { typeof args?.gameID === "string" && args.gameID.length > 0 ? args.gameID : null; + this.gameType = null; this.resetXp(); if (this.gameId !== null) { this.xpAbort = new AbortController(); @@ -85,6 +90,9 @@ export class GameStatsModal extends BaseModal { private onGameInfoLoaded = (e: CustomEvent): void => { this.gameEnd = { gameId: e.detail.gameId, end: e.detail.info?.end ?? null }; + if (e.detail.gameId === this.gameId) { + this.gameType = e.detail.info?.config.gameType ?? null; + } const waiters = this.gameEndWaiters; this.gameEndWaiters = []; for (const wake of waiters) wake(); @@ -127,6 +135,12 @@ export class GameStatsModal extends BaseModal { const result = await fetchMyGameXp(gameId, signal); if (signal.aborted) return; if (result.status === "ok") { + // This reason reads differently for a singleplayer game, and the + // game's type comes with its record. + if (!result.data.eligible && result.data.reason === "unverified") { + await this.gameEndedAt(gameId, signal); + if (signal.aborted) return; + } this.xpView = { kind: "result", data: result.data }; return; } diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index cf77800499..1bc4d74a83 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -278,6 +278,9 @@ export class LangSelector extends LitElement { "custom-currency-card", "fluent-slider", "nav-account-menu", + "level-badge", + "game-xp-panel", + "past-game-xp-card", "news-modal", "account-modal", "game-stats-modal", diff --git a/src/client/Progression.ts b/src/client/Progression.ts index f16911ab18..2288b5a4a9 100644 --- a/src/client/Progression.ts +++ b/src/client/Progression.ts @@ -1,8 +1,12 @@ +import { GameType } from "@openfront/engine-api/game/GameTypes"; import type { XpBreakdown } from "@openfront/shared/ApiSchemas"; // Pure helpers behind the level / XP UI. No DOM, no fetches — so the rules the // components follow are unit-testable on their own. +// The badge art is cut for a 100-level track with 10 prestige ranks (see +// levelBand, prestigeTier), so the caps stay client constants: raising either +// on the server needs new art, not only a new number. export const MAX_LEVEL = 100; export const MAX_PRESTIGE = 10; @@ -41,7 +45,8 @@ export function prestigeTier(prestige: number): PrestigeTier { // Levels called out when reached: the end-of-game XP panel gives one reached // this game its own "new milestone" card (100 is the Legend card at the last -// prestige). +// prestige). Must match the server's milestone list, which dates them on the +// profile. export const MILESTONE_LEVELS: readonly number[] = [10, 25, 50, 75, 100]; export function isMilestoneLevel(level: number): boolean { @@ -115,7 +120,7 @@ export function multiplierPercent(permille: number): number { return Math.round(permille - 1000) / 10; } -export type XpLineKey = +type XpSourceKey = | "played" | "time" | "placement" @@ -123,7 +128,11 @@ export type XpLineKey = | "firstGame" | "feats"; -export const XP_LINE_KEYS: readonly XpLineKey[] = [ +// "other" is XP in the subtotal that no known source accounts for: a source +// this client doesn't know, or one stored under an older name. +export type XpLineKey = XpSourceKey | "other"; + +export const XP_LINE_KEYS: readonly XpSourceKey[] = [ "played", "time", "placement", @@ -140,15 +149,23 @@ export const XP_LINE_LABEL_KEYS: Record = { win: "progression.line_win", firstGame: "progression.line_first_game", feats: "progression.line_feats", + other: "progression.line_other", }; -/** The breakdown lines worth showing: the non-zero ones, in a fixed order. */ +/** + * The breakdown lines worth showing: the non-zero ones, in a fixed order, then + * whatever of the subtotal they leave unaccounted for, so the lines add up to + * the subtotal and no line is credited with XP another source earned. + */ export function visibleXpLines( breakdown: XpBreakdown, ): { key: XpLineKey; amount: number }[] { - return XP_LINE_KEYS.map((key) => ({ key, amount: breakdown[key] })).filter( - (line) => line.amount !== 0, - ); + const lines: { key: XpLineKey; amount: number }[] = XP_LINE_KEYS.map( + (key) => ({ key, amount: breakdown[key] }), + ).filter((line) => line.amount !== 0); + const other = breakdown.subtotal - lines.reduce((a, l) => a + l.amount, 0); + if (other > 0) lines.push({ key: "other", amount: other }); + return lines; } /** The multipliers worth showing: anything other than 1x. */ @@ -232,7 +249,16 @@ const INELIGIBLE_REASON_KEYS: Record = { [BEFORE_PROGRESSION]: "progression.xp_before_levels", }; -export function ineligibleReasonKey(reason: string): string { +// A singleplayer game never carries the end-of-game vote, so the server +// always marks it unverified: callers that know the game's type pass it, and +// it gets its own line instead. +export function ineligibleReasonKey( + reason: string, + gameType?: GameType | null, +): string { + if (reason === "unverified" && gameType === GameType.Singleplayer) { + return "progression.ineligible_singleplayer"; + } return Object.prototype.hasOwnProperty.call(INELIGIBLE_REASON_KEYS, reason) ? INELIGIBLE_REASON_KEYS[reason] : "progression.ineligible_generic"; diff --git a/src/client/ProgressionApi.ts b/src/client/ProgressionApi.ts index 37151ca803..bd817ff1df 100644 --- a/src/client/ProgressionApi.ts +++ b/src/client/ProgressionApi.ts @@ -151,13 +151,25 @@ export async function fetchPublicPlayerProgress( } } -let progressionConfig: Promise | null = null; +// The server's max-age for the config: a deploy that changes it has to reach +// open tabs within this. +const PROGRESSION_CONFIG_TTL_MS = 60_000; + +let progressionConfig: { + request: Promise; + at: number; +} | null = null; // GET /public/progression/config — the level curve, and whether progression is -// on at all (404 when it is off). Memoised for the page's lifetime; a failure -// is not, so a later caller can try again. +// on at all (404 when it is off). Memoised for as long as the server says it +// stays fresh; a failure is not, so a later caller can try again. export function fetchProgressionConfig(): Promise { - if (progressionConfig !== null) return progressionConfig; + if ( + progressionConfig !== null && + Date.now() - progressionConfig.at < PROGRESSION_CONFIG_TTL_MS + ) { + return progressionConfig.request; + } const request = (async (): Promise => { try { const res = await fetch(`${getApiBase()}/public/progression/config`, { @@ -179,11 +191,13 @@ export function fetchProgressionConfig(): Promise { return false; } })(); - progressionConfig = request; + const entry = { request, at: Date.now() }; + progressionConfig = entry; void request.then((config) => { - if (config === false && progressionConfig === request) { - progressionConfig = null; - } + if (progressionConfig !== entry) return; + // Fresh from the response, like the server's max-age. + if (config === false) progressionConfig = null; + else entry.at = Date.now(); }); return request; } diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index 3468ce7e72..589bf78db1 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -1,3 +1,4 @@ +import type { GameType } from "@openfront/engine-api/game/GameTypes"; import type { GameXpEligible, GameXpResponse, @@ -110,10 +111,11 @@ interface RevealFrame { } // Bar position of a level/progress pair, in level units. Level 100 has no -// next level and reads as a full bar. +// next level and reads as a full bar. A full level stays just short of the +// next, so the position never reads as a level the player hasn't reached. function positionOf(level: number, xpInLevel: number, xpForNext: number) { if (level >= MAX_LEVEL) return MAX_LEVEL; - return level + levelFraction(xpInLevel, xpForNext); + return level + Math.min(1 - 1e-6, levelFraction(xpInLevel, xpForNext)); } // The source cards' final values: the award split across them, so a @@ -133,6 +135,9 @@ export class GameXpPanel extends LitElement { // reveal only plays on screen: a result that arrives while it is off screen // shows in its final state, and going off screen mid-reveal ends it there. @property({ attribute: false }) onScreen = true; + // The finished game's type, when known: picks the copy for a game that + // didn't earn XP (see ineligibleReasonKey). + @property({ attribute: false }) gameType: GameType | null = null; // The reveal in progress, or null once it has finished (or when there is // none: reduced motion, a skip). Null renders the final @@ -177,18 +182,10 @@ export class GameXpPanel extends LitElement { this.timers.push(setTimeout(fn, ms)); } - // The reveal. For each XP source a caption rolls in over the bar ("TIME - // PLAYED +340 XP"), the bar climbs by that much, the counter under it - // counts up, and the source's card lands in the grid below. When the bar - // reaches the end of a level, everything stops for the level-up moment: - // the bar flashes, the caption becomes "LEVEL 24!", the badge at the end - // pops with a burst, and the level-up card appears. Then the bar empties - // and the climb carries on. Each multiplier then slams in and is applied to - // the cards on screen: a wipe runs across them, first to last, turning each - // value over to its multiplied share (340 → 408) as it passes, with the - // counter and bar going up with it; the bonus line then lands under the - // counter. Becoming a Legend is its own, longer moment. Tapping skips to - // the end. + // Tapping the panel skips to the end. Each multiplier is applied to the + // source cards already on screen, turning each over to its share of the + // multiplied award, rather than to the subtotal, so the cards always add + // up to the counter. private startReveal(data: GameXpEligible): void { this.clearTimers(); if (prefersReducedMotion()) { @@ -260,6 +257,13 @@ export class GameXpPanel extends LitElement { // `totalMs` is how long the climbing takes, pauses aside. Returns when it // ends. let position = start; + // The levels to stop on, in order, as the server reports them: flooring + // the bar's interpolated position skips the first one when the player + // starts the game on a full bar. + const pendingLevels = levelsReachedInGame(data) + .map((l) => l.level) + .filter((l) => l > data.before.level && l <= MAX_LEVEL) + .sort((a, b) => a - b); const climb = ( t: number, to: number, @@ -288,8 +292,8 @@ export class GameXpPanel extends LitElement { } position = to; const boundaries: number[] = []; - for (let b = Math.floor(from) + 1; b <= Math.floor(to); b++) { - if (b <= MAX_LEVEL) boundaries.push(b); + while (pendingLevels.length > 0 && pendingLevels[0] <= to) { + boundaries.push(pendingLevels.shift()!); } const distance = to - from; const legMs = (a: number, b: number) => @@ -1396,7 +1400,7 @@ export class GameXpPanel extends LitElement { case "result": if (!s.data.eligible) { return this.renderNote( - ineligibleReasonKey(s.data.reason), + ineligibleReasonKey(s.data.reason, this.gameType), "ineligible", ); } diff --git a/src/client/components/PastGameXpCard.ts b/src/client/components/PastGameXpCard.ts index 6b37dd0f55..2bc8240f15 100644 --- a/src/client/components/PastGameXpCard.ts +++ b/src/client/components/PastGameXpCard.ts @@ -1,3 +1,4 @@ +import type { GameType } from "@openfront/engine-api/game/GameTypes"; import type { GameXpEligible, GameXpResponse, @@ -43,6 +44,9 @@ const BADGE_SIZE = 30; @customElement("past-game-xp-card") export class PastGameXpCard extends LitElement { @property({ attribute: false }) view: PastGameXpView = { kind: "hidden" }; + // The game's type, once its record has loaded: picks the copy for a game + // that didn't earn XP (see ineligibleReasonKey). + @property({ attribute: false }) gameType: GameType | null = null; createRenderRoot() { return this; @@ -322,7 +326,7 @@ export class PastGameXpCard extends LitElement { } return this.renderNote( this.circledIcon("info"), - translateText(ineligibleReasonKey(s.data.reason)), + translateText(ineligibleReasonKey(s.data.reason, this.gameType)), "ineligible", "text-white/70", ); diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 97263f9426..6a7e7514ec 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -89,6 +89,7 @@ export class WinModal extends LitElement implements Controller { ${this.innerHtml()} diff --git a/tests/client/LevelBadge.test.ts b/tests/client/LevelBadge.test.ts index 0eb0b6b9d1..ff0bb65f2d 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 type { XpBreakdown } from "@openfront/shared/ApiSchemas"; +import { GameType } from "@openfront/engine-api/game/GameTypes"; +import { + GameXpResponseSchema, + type XpBreakdown, +} from "@openfront/shared/ApiSchemas"; import { LevelBadge } from "../../src/client/components/LevelBadge"; import { apportionXp, @@ -203,6 +207,39 @@ describe("progression helpers", () => { ]); }); + it("shows XP no known source accounts for as its own line", () => { + // A row scored when the first-game bonus was stored as `firstWinOfDay`. + const parsed = GameXpResponseSchema.parse({ + gameId: "g", + eligible: true, + breakdown: { + played: 50, + time: 100, + firstWinOfDay: 200, + subtotal: 350, + gamePermille: 1000, + subscriberPermille: 1000, + total: 350, + }, + before: { prestige: 0, level: 1, xpInLevel: 0, xpForNext: 100 }, + after: { + prestige: 0, + level: 3, + xpInLevel: 0, + xpForNext: 100, + lifetimeXp: 350, + legend: false, + canPrestige: false, + }, + }); + if (!parsed.eligible) throw new Error("expected eligible"); + expect(visibleXpLines(parsed.breakdown)).toEqual([ + { key: "played", amount: 50 }, + { key: "time", amount: 100 }, + { key: "other", amount: 200 }, + ]); + expect(en.progression.line_other).toBe("Other"); + }); it("lists only the multipliers that are not 1x", () => { expect(visibleMultipliers(breakdown)).toEqual([ { key: "subscriber", permille: 1250 }, @@ -301,6 +338,24 @@ describe("progression helpers", () => { ); }); + it("tells a singleplayer game it doesn't earn XP, not that it couldn't be verified", () => { + expect(ineligibleReasonKey("unverified", GameType.Singleplayer)).toBe( + "progression.ineligible_singleplayer", + ); + expect(en.progression.ineligible_singleplayer).toBe( + "Singleplayer games don't earn XP.", + ); + // Only that reason: the others say what happened in any game. + expect(ineligibleReasonKey("too_short", GameType.Singleplayer)).toBe( + "progression.ineligible_too_short", + ); + expect(ineligibleReasonKey("unverified", GameType.Public)).toBe( + "progression.ineligible_unverified", + ); + expect(ineligibleReasonKey("unverified", null)).toBe( + "progression.ineligible_unverified", + ); + }); it("explains a game played before levels existed", () => { expect(ineligibleReasonKey("before_progression")).toBe( "progression.xp_before_levels", diff --git a/tests/client/PastGameXpCard.test.ts b/tests/client/PastGameXpCard.test.ts index b66a7c0f86..809b9ccca3 100644 --- a/tests/client/PastGameXpCard.test.ts +++ b/tests/client/PastGameXpCard.test.ts @@ -5,7 +5,11 @@ vi.mock("../../src/client/Utils", () => ({ params ? `${key}:${JSON.stringify(params)}` : key, })); -import type { GameXpEligible } from "@openfront/shared/ApiSchemas"; +import { GameType } from "@openfront/engine-api/game/GameTypes"; +import { + type GameXpEligible, + GameXpResponseSchema, +} from "@openfront/shared/ApiSchemas"; import { PastGameXpCard, type PastGameXpView, @@ -70,9 +74,13 @@ const noLevelUp: GameXpEligible = { let card: PastGameXpCard | null = null; -async function mount(view: PastGameXpView): Promise { +async function mount( + view: PastGameXpView, + gameType: GameType | null = null, +): Promise { card = document.createElement("past-game-xp-card") as PastGameXpCard; card.view = view; + card.gameType = gameType; document.body.appendChild(card); await card.updateComplete; return card; @@ -253,6 +261,48 @@ describe("", () => { ); }); + it("tells a singleplayer game it doesn't earn XP, not that it couldn't be verified", async () => { + const unverified = { + gameId: "g1", + eligible: false as const, + reason: "unverified", + }; + await mount({ kind: "result", data: unverified }, GameType.Singleplayer); + expect(q("[data-past-xp-note]")!.textContent).toBe( + "progression.ineligible_singleplayer", + ); + card!.remove(); + await mount({ kind: "result", data: unverified }, GameType.Private); + expect(q("[data-past-xp-note]")!.textContent).toBe( + "progression.ineligible_unverified", + ); + }); + + it("shows XP no known source accounts for as its own line, so the lines add up", async () => { + // Scored when the first-game bonus was stored as `firstWinOfDay`. + const legacy = GameXpResponseSchema.parse({ + ...noLevelUp, + breakdown: { + played: 50, + time: 100, + firstWinOfDay: 200, + subtotal: 350, + gamePermille: 1000, + subscriberPermille: 1000, + total: 350, + }, + }); + await mount({ kind: "result", data: legacy }); + const lines = [...card!.querySelectorAll("[data-past-xp-line]")].map( + (l) => [l.getAttribute("data-past-xp-line"), l.textContent!.trim()], + ); + expect(lines).toEqual([ + ["played", expect.stringContaining('progression.xp_plus:{"xp":"50"}')], + ["time", expect.stringContaining('progression.xp_plus:{"xp":"100"}')], + ["other", expect.stringContaining('progression.xp_plus:{"xp":"200"}')], + ]); + expect(lines[2][1]).toContain("progression.line_other"); + }); it("notes a game the player left early", async () => { await mount({ kind: "result", diff --git a/tests/client/ProgressionApi.test.ts b/tests/client/ProgressionApi.test.ts index f531996ea5..cba6377229 100644 --- a/tests/client/ProgressionApi.test.ts +++ b/tests/client/ProgressionApi.test.ts @@ -10,6 +10,9 @@ vi.mock("../../src/client/ApiBase", () => ({ import type { GameXpResponse } from "@openfront/shared/ApiSchemas"; import { + fetchMyGameXp, + fetchProgressionConfig, + fetchPublicPlayerProgress, type GameXpFetchResult, pollGameXp, } from "../../src/client/ProgressionApi"; @@ -153,3 +156,202 @@ describe("pollGameXp", () => { expect(fetcher.mock.calls.length).toBeLessThanOrEqual(11); }); }); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const eligibleBody = { + gameId: "g1", + eligible: true, + breakdown: { + played: 50, + subtotal: 50, + gamePermille: 1000, + subscriberPermille: 1000, + total: 50, + }, + before: { prestige: 0, level: 1, xpInLevel: 0, xpForNext: 100 }, + after: { + prestige: 0, + level: 1, + xpInLevel: 50, + xpForNext: 100, + lifetimeXp: 50, + legend: false, + canPrestige: false, + }, +}; + +const configBody = { + version: 1, + maxLevel: 100, + maxPrestige: 10, + levels: [{ level: 1, xpToNext: 100, cumulativeXp: 0 }], +}; + +describe("fetchMyGameXp", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function answer(response: () => Response) { + vi.stubGlobal( + "fetch", + vi.fn(async () => response()), + ); + } + + it("reads a processed game", async () => { + answer(() => jsonResponse(eligibleBody)); + const result = await fetchMyGameXp("g1"); + expect(result.status).toBe("ok"); + }); + + it("keeps asking about a game not processed yet (404)", async () => { + answer(() => new Response("", { status: 404 })); + await expect(fetchMyGameXp("g1")).resolves.toEqual({ status: "pending" }); + }); + + it("stops asking when the session is refused (401, 403)", async () => { + answer(() => new Response("", { status: 401 })); + await expect(fetchMyGameXp("g1")).resolves.toEqual({ + status: "unavailable", + }); + answer(() => new Response("", { status: 403 })); + await expect(fetchMyGameXp("g1")).resolves.toEqual({ + status: "unavailable", + }); + }); + + it("keeps asking through a server error (500)", async () => { + answer(() => new Response("", { status: 500 })); + await expect(fetchMyGameXp("g1")).resolves.toEqual({ status: "pending" }); + }); + + it("stops asking when the body can't be read, rather than poll it", async () => { + answer(() => jsonResponse({ gameId: "g1", eligible: true })); + await expect(fetchMyGameXp("g1")).resolves.toEqual({ + status: "unavailable", + }); + }); + + it("keeps asking through a network failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("network down"); + }), + ); + await expect(fetchMyGameXp("g1")).resolves.toEqual({ status: "pending" }); + }); +}); + +describe("fetchPublicPlayerProgress", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("reads a player's level, and says false for anything else", async () => { + const progress = { + prestige: 1, + level: 12, + lifetimeXp: 4200, + legend: false, + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse(progress)), + ); + await expect(fetchPublicPlayerProgress("p1")).resolves.toEqual(progress); + for (const response of [ + () => new Response("", { status: 404 }), + () => new Response("", { status: 500 }), + () => jsonResponse({ level: "twelve" }), + ]) { + vi.stubGlobal( + "fetch", + vi.fn(async () => response()), + ); + await expect(fetchPublicPlayerProgress("p1")).resolves.toBe(false); + } + }); +}); + +describe("fetchProgressionConfig", () => { + // The memo is module state: a fresh copy of the module per test. + let fetchConfig: typeof fetchProgressionConfig; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.resetModules(); + ({ fetchProgressionConfig: fetchConfig } = + await import("../../src/client/ProgressionApi")); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function answers(responses: (() => Response)[]) { + const fetchMock = vi.fn(async () => + (responses.length > 1 ? responses.shift()! : responses[0])(), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("asks once while the config is fresh, then again once it is stale", async () => { + const fetchMock = answers([() => jsonResponse(configBody)]); + await expect(fetchConfig()).resolves.toEqual(configBody); + await vi.advanceTimersByTimeAsync(30_000); + await expect(fetchConfig()).resolves.toEqual(configBody); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Past the server's 60s max-age: a deploy that changed it shows up. + await vi.advanceTimersByTimeAsync(31_000); + await fetchConfig(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("shares one request between callers while it is in flight", async () => { + const fetchMock = answers([() => jsonResponse(configBody)]); + const [a, b] = await Promise.all([fetchConfig(), fetchConfig()]); + expect(a).toEqual(configBody); + expect(b).toEqual(configBody); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("asks again after a failure, rather than remember it", async () => { + const fetchMock = answers([ + () => new Response("", { status: 503 }), + () => jsonResponse(configBody), + ]); + await expect(fetchConfig()).resolves.toBe(false); + await expect(fetchConfig()).resolves.toEqual(configBody); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("says false when progression is off (404) or the body can't be read", async () => { + answers([() => new Response("", { status: 404 })]); + await expect(fetchConfig()).resolves.toBe(false); + answers([() => jsonResponse({ version: "one" })]); + await expect(fetchConfig()).resolves.toBe(false); + }); +}); diff --git a/tests/client/ProgressionModals.test.ts b/tests/client/ProgressionModals.test.ts index 5e9c7e183e..491c827598 100644 --- a/tests/client/ProgressionModals.test.ts +++ b/tests/client/ProgressionModals.test.ts @@ -87,6 +87,7 @@ vi.mock( }, ); +import { GameType } from "@openfront/engine-api/game/GameTypes"; import { GameStatsModal } from "../../src/client/GameStatsModal"; import { modalRouter } from "../../src/client/ModalRouter"; import { initNavigation } from "../../src/client/Navigation"; @@ -331,10 +332,17 @@ describe("game stats XP", () => { } // GameInfoView has loaded the game: it ended `agoMs` ago. - function gameLoaded(gameId: string, agoMs: number): void { + function gameLoaded( + gameId: string, + agoMs: number, + gameType = GameType.Public, + ): void { modal.querySelector("game-info-view")!.dispatchEvent( new CustomEvent("game-info-loaded", { - detail: { gameId, info: { end: Date.now() - agoMs } }, + detail: { + gameId, + info: { end: Date.now() - agoMs, config: { gameType } }, + }, bubbles: true, }), ); @@ -379,6 +387,36 @@ describe("game stats XP", () => { ); }); + it("tells a singleplayer game it doesn't earn XP, once the game has loaded", async () => { + fetchMyGameXp.mockResolvedValue({ + status: "ok", + data: { gameId: "g8", eligible: false, reason: "unverified" }, + }); + await open("g8"); + await vi.waitFor(() => expect(fetchMyGameXp).toHaveBeenCalled()); + await idle(); + // Never the multiplayer line first: it waits for the game's type. + expect(await cardState()).toBeNull(); + gameLoaded("g8", 60_000, GameType.Singleplayer); + await vi.waitFor(async () => expect(await cardState()).toBe("ineligible")); + expect(modal.querySelector("[data-past-xp-note]")!.textContent).toBe( + "progression.ineligible_singleplayer", + ); + }); + + it("says a multiplayer game's results couldn't be verified", async () => { + fetchMyGameXp.mockResolvedValue({ + status: "ok", + data: { gameId: "g9", eligible: false, reason: "unverified" }, + }); + await open("g9"); + gameLoaded("g9", 60_000, GameType.Public); + await vi.waitFor(async () => expect(await cardState()).toBe("ineligible")); + expect(modal.querySelector("[data-past-xp-note]")!.textContent).toBe( + "progression.ineligible_unverified", + ); + }); + it("says when a game was played before levels existed", async () => { fetchMyGameXp.mockResolvedValue({ status: "ok", diff --git a/tests/client/graphics/layers/WinModalXp.test.ts b/tests/client/graphics/layers/WinModalXp.test.ts index 117763663f..ba48a3af6e 100644 --- a/tests/client/graphics/layers/WinModalXp.test.ts +++ b/tests/client/graphics/layers/WinModalXp.test.ts @@ -53,6 +53,7 @@ vi.mock("../../../../src/client/CrazyGamesSDK", () => ({ }, })); +import { GameType } from "@openfront/engine-api/game/GameTypes"; import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; import { EventBus } from "@openfront/shared/EventBus"; import type { GameXpPanel } from "../../../../src/client/components/GameXpPanel"; @@ -135,7 +136,12 @@ function stubXpEndpoint(answers: (() => Response)[]) { const notFound = () => new Response("", { status: 404 }); -function makeGame(opts: { ended: boolean; alive?: boolean; replay?: boolean }) { +function makeGame(opts: { + ended: boolean; + alive?: boolean; + replay?: boolean; + gameType?: GameType; +}) { const winUpdate = { winner: ["team", "Red"], allPlayersStats: {} }; let delivered = false; return { @@ -154,7 +160,10 @@ function makeGame(opts: { ended: boolean; alive?: boolean; replay?: boolean }) { }, playerByClientID: () => undefined, config: () => ({ - gameConfig: () => ({ rankedType: undefined }), + gameConfig: () => ({ + rankedType: undefined, + gameType: opts.gameType ?? GameType.Public, + }), isReplay: () => opts.replay ?? false, }), } as unknown as GameView; @@ -296,6 +305,27 @@ describe("WinModal XP section", () => { expect(panel()!.textContent!.trim()).toBe("progression.ineligible_generic"); }); + it("tells a singleplayer game it doesn't earn XP, not that it couldn't be verified", async () => { + stubXpEndpoint([ + () => json({ gameId: GAME_ID, eligible: false, reason: "unverified" }), + ]); + await mount(makeGame({ ended: true, gameType: GameType.Singleplayer })); + await finishReveal(); + expect(panel()!.textContent!.trim()).toBe( + "progression.ineligible_singleplayer", + ); + }); + + it("still says a multiplayer game's results couldn't be verified", async () => { + stubXpEndpoint([ + () => json({ gameId: GAME_ID, eligible: false, reason: "unverified" }), + ]); + await mount(makeGame({ ended: true, gameType: GameType.Public })); + await finishReveal(); + expect(panel()!.textContent!.trim()).toBe( + "progression.ineligible_unverified", + ); + }); it("explains a left-early forfeit", async () => { stubXpEndpoint([ () => @@ -752,6 +782,85 @@ describe("WinModal XP section", () => { expect(moments).toEqual(["5", "6", "7"]); }); + it("celebrates a level-up from a level the player started full", async () => { + // A curve change left the player a full bar on level 9. + stubXpEndpoint([ + () => + json( + eligible({ + before: { prestige: 0, level: 9, xpInLevel: 330, xpForNext: 330 }, + after: { + ...eligible().after, + level: 10, + xpInLevel: 182, + xpForNext: 370, + }, + levelsReached: [{ prestige: 0, level: 10 }], + }), + ), + ]); + await mount(makeGame({ ended: true })); + const badgeLevel = (sel: string) => + ( + panel()!.querySelector(`${sel} level-badge`) as HTMLElement & { + level: number; + } + ).level; + const milestoneSpace = () => + panel()! + .querySelector("[data-xp-levelup]")! + .closest("[data-xp-collapsible]")! + .getAttribute("data-xp-collapsible"); + // It starts on level 9, the milestone still to come. + expect(badgeLevel("[data-xp-current-badge]")).toBe(9); + expect(badgeLevel("[data-xp-next-badge]")).toBe(10); + expect(milestoneSpace()).toBe("closed"); + const moments: string[] = []; + for (let i = 0; i < 400; i++) { + await settle(50); + const moment = panel() + ?.querySelector("[data-xp-caption-levelup]") + ?.getAttribute("data-xp-caption-levelup"); + if (moment && moment !== moments[moments.length - 1]) { + moments.push(moment); + } + if (panel()?.getAttribute("data-xp-revealing") !== "true") break; + } + expect(moments).toEqual(["10"]); + expect(milestoneSpace()).toBe("open"); + expect(badgeLevel("[data-xp-current-badge]")).toBe(10); + }); + + it("shows XP from a source it doesn't know as its own card, not spread over the others", async () => { + // Scored when the first-game bonus was stored as `firstWinOfDay`. + stubXpEndpoint([ + () => + json( + eligible({ + breakdown: { + played: 50, + time: 100, + firstWinOfDay: 200, + subtotal: 350, + gamePermille: 1000, + subscriberPermille: 1000, + total: 350, + }, + }), + ), + ]); + await mount(makeGame({ ended: true })); + await finishReveal(); + const cards = [...panel()!.querySelectorAll("[data-xp-card]")].map((c) => [ + c.querySelector("[data-xp-line]")!.getAttribute("data-xp-line"), + c.querySelector("[data-xp-card-value]")!.textContent!.trim(), + ]); + expect(cards).toEqual([ + ["played", 'progression.xp_total:{"xp":"50"}'], + ["time", 'progression.xp_total:{"xp":"100"}'], + ["other", 'progression.xp_total:{"xp":"200"}'], + ]); + }); it("skips to the end when the panel is tapped", async () => { stubXpEndpoint([() => json(eligible())]); await mount(makeGame({ ended: true }));