From 53b26e0c4e956842401898db2b1f613bb6324c89 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 1 Oct 2026 00:16:58 +0100 Subject: [PATCH 01/30] 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 1ea2387233a..3394da3cbad 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 d2dc361c226..723e9dfee27 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 f37818694d2..690d6e97909 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 00000000000..2871ae2889f --- /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 00000000000..14475e905e3 --- /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 00000000000..d5244dcab69 --- /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 00000000000..1432b7d2ee3 --- /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 ba4c30edeae..e9d247e5507 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 6ee224ade7f..2a3b196806e 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 00000000000..05a54f8da99 --- /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 00000000000..49848d8b56b --- /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 00000000000..88af119e5f8 --- /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 00000000000..a300cf4af88 --- /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 00000000000..e81c231f8c1 --- /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 d558bcea000..42678eb670f 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 00000000000..8451ff276d7 --- /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/30] 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 05a54f8da99..d613bcb6767 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/30] 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 625ad9949ac..041f61c676c 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 723e9dfee27..08b110e73ef 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 690d6e97909..e94379775c5 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 00000000000..b03b75f310e --- /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 14475e905e3..b058f0acf87 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 d5244dcab69..f3c48085416 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 1432b7d2ee3..d04cf15aabc 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 3f5fb72b8a3..3ca0df078ab 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 2a3b196806e..5eee568b9bc 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 d613bcb6767..a7124c31b76 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 00000000000..e25aca8e2e9 --- /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 88af119e5f8..14437f5e6dd 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 a300cf4af88..eca496cc44c 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 8451ff276d7..990a73feb0b 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/30] 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 15089f11952..63e7d4384b1 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 2ed41541d0c..feb9611a4cb 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 e94379775c5..f395fff1a71 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 2871ae2889f..3d11fdcc6f8 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 b058f0acf87..99f9afc7932 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 f3c48085416..f3b4bd58816 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 7e28ba96a4b..5d087bc6ef3 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 3ca0df078ab..c23b2d49708 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 b0bff0404cf..d592223604b 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()}
+
+ `; + } + + // "Others see": the player's name as other players get it — with their + // level badge, or without it and marked hidden. Decorative (the switch + // carries the state), so hidden from assistive tech. Needs the player's + // level, so it is left out when progression is off. + private renderLevelPreview(shown: boolean): TemplateResult | typeof nothing { + const progress = this.player?.progress; + if (progress === undefined) return nothing; + return html` + + `; + } + // No verified email on the account yet. Offer both ways to attach one: // a magic link to a plain email (the backend associates a not-yet-registered // email with the current session — the "new-association" path), or linking a @@ -290,6 +374,35 @@ export class AccountSettingsPanel extends LitElement { this.requestUpdate(); } + private async setLevelShown(shown: boolean): Promise { + const player = this.player; + if (!player || player.levelHidden === undefined) return; + if (this.levelVisibilityBusy) return; + const previous = player.levelHidden; + const hidden = !shown; + if (previous === hidden) return; + + // Optimistic, like the consent toggle: `player` is the cached /users/@me + // profile, so this also keeps every other reader of it consistent. The + // switch is disabled until the server answers; a failure puts it back. + this.levelVisibilityBusy = true; + player.levelHidden = hidden; + this.requestUpdate(); + + const result = await setLevelVisibility(hidden); + player.levelHidden = result.ok ? result.hidden : previous; + this.levelVisibilityBusy = false; + this.requestUpdate(); + + // 401: logOut() has already run and the signed-out state takes over — + // nothing to tell the player here. + if (!result.ok && result.code === "failed") { + await showInGameAlert( + translateText("account_modal.level_visibility_failed"), + ); + } + } + private handleEmailInput = (e: Event): void => { this.email = (e.target as HTMLInputElement).value; }; diff --git a/tests/client/LevelVisibilityApi.test.ts b/tests/client/LevelVisibilityApi.test.ts new file mode 100644 index 00000000000..8fd75e83942 --- /dev/null +++ b/tests/client/LevelVisibilityApi.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Auth", () => ({ + getAuthHeader: async () => "Bearer t", + logOut: vi.fn(), +})); +vi.mock("../../src/client/ClientEnv", async (importOriginal) => ({ + ...(await importOriginal()), +})); + +import { setLevelVisibility } from "../../src/client/Api"; +import { logOut } from "../../src/client/Auth"; +import { ClientEnv } from "../../src/client/ClientEnv"; + +function response(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("setLevelVisibility", () => { + const fetchSpy = vi.fn(); + beforeEach(() => { + fetchSpy.mockReset(); + vi.mocked(logOut).mockClear(); + vi.stubGlobal("fetch", fetchSpy); + // getApiBase() reads window.BOOTSTRAP_CONFIG via ClientEnv (same shape as + // UpdateUsername.test.ts). + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience: "localhost", + instanceId: "test", + gitCommit: "test", + }; + ClientEnv.reset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete (window as any).BOOTSTRAP_CONFIG; + ClientEnv.reset(); + }); + + it("PUTs the desired state with the auth header and returns the echo", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: true })); + + expect(await setLevelVisibility(true)).toEqual({ ok: true, hidden: true }); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toMatch(/\/users\/@me\/level_visibility$/); + expect(init.method).toBe("PUT"); + expect(init.headers.Authorization).toBe("Bearer t"); + expect(init.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body)).toEqual({ hidden: true }); + }); + + it("sends hidden=false to show the level again", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: false })); + + expect(await setLevelVisibility(false)).toEqual({ + ok: true, + hidden: false, + }); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ + hidden: false, + }); + }); + + it("maps 400 to failed", async () => { + fetchSpy.mockResolvedValue(response(400, { error: "Bad Request" })); + + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + expect(logOut).not.toHaveBeenCalled(); + }); + + it("maps 401 to logged_out and runs the signed-out handling", async () => { + fetchSpy.mockResolvedValue(response(401, { error: "Unauthorized" })); + + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "logged_out", + }); + expect(logOut).toHaveBeenCalledTimes(1); + }); + + it("maps a rate limit, an unreadable body and a network error to failed", async () => { + fetchSpy.mockResolvedValueOnce(response(429, {})); + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockResolvedValueOnce(response(200, { hidden: "yes" })); + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockRejectedValueOnce(new TypeError("network")); + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + }); +}); diff --git a/tests/client/components/AccountSettingsPanel.test.ts b/tests/client/components/AccountSettingsPanel.test.ts index 9310257d942..93a10f85797 100644 --- a/tests/client/components/AccountSettingsPanel.test.ts +++ b/tests/client/components/AccountSettingsPanel.test.ts @@ -15,6 +15,11 @@ vi.mock("../../../src/client/Api", () => ({ setMarketingConsent: vi.fn(async () => true), deleteAccount: vi.fn(async () => ({ ok: true })), getIdentityTokenAudiences: vi.fn(async () => []), + setLevelVisibility: vi.fn(async (hidden: boolean) => ({ ok: true, hidden })), +})); + +vi.mock("../../../src/client/InGameModal", () => ({ + showInGameAlert: vi.fn(async () => {}), })); vi.mock("../../../src/client/Auth", () => ({ @@ -167,3 +172,209 @@ describe("AccountSettingsPanel — marketing-consent card", () => { expect(panel.querySelector("input[type=email]")).toBeTruthy(); }); }); + +describe("AccountSettingsPanel — privacy card (hide my level)", () => { + let panel: AccountSettingsPanel; + const progress = { + prestige: 3, + level: 96, + xpInLevel: 10, + xpForNext: 900, + lifetimeXp: 1234567, + legend: false, + canPrestige: false, + }; + + beforeEach(async () => { + if (!customElements.get("account-settings-panel")) { + customElements.define("account-settings-panel", AccountSettingsPanel); + } + panel = document.createElement( + "account-settings-panel", + ) as AccountSettingsPanel; + document.body.appendChild(panel); + await panel.updateComplete; + }); + + afterEach(() => { + document.body.removeChild(panel); + vi.clearAllMocks(); + }); + + async function show(player: UserMePlayer): Promise { + panel.player = player; + panel.user = { email: "player@example.com" }; + await panel.updateComplete; + } + + // Lets the mocked request settle, then the re-render. + async function settle(): Promise { + await new Promise((r) => setTimeout(r, 0)); + await panel.updateComplete; + } + + const levelSwitch = () => + panel.querySelector( + 'button[role="switch"][aria-label="account_modal.level_visibility_title"]', + ) as HTMLButtonElement | null; + const preview = () => panel.querySelector(".level-visibility-preview"); + + it("renders no card when /users/@me has no levelHidden (older API)", async () => { + await show(makePlayer({ progress, username: "Kestrel" })); + + expect(panel.textContent).not.toContain("account_modal.privacy_title"); + expect(levelSwitch()).toBeNull(); + }); + + it("sits between the email-updates card and the delete card", async () => { + await show( + makePlayer({ + progress, + levelHidden: false, + marketingConsent: { consented: "approved", hasEmail: true }, + }), + ); + + const text = panel.textContent ?? ""; + const marketing = text.indexOf("account_modal.marketing_title"); + const privacy = text.indexOf("account_modal.privacy_title"); + const del = text.indexOf("account_modal.delete_account_title"); + expect(marketing).toBeGreaterThanOrEqual(0); + expect(privacy).toBeGreaterThan(marketing); + expect(del).toBeGreaterThan(privacy); + expect(text).toContain("account_modal.level_visibility_desc"); + }); + + it("while shown: switch on, and the badge in front of the name in the preview", async () => { + await show( + makePlayer({ progress, username: "Kestrel", levelHidden: false }), + ); + + const toggle = levelSwitch()!; + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(toggle.disabled).toBe(false); + + const strip = preview()!; + expect(strip.getAttribute("aria-hidden")).toBe("true"); + expect(strip.textContent).toContain( + "account_modal.level_visibility_others_see", + ); + expect(strip.textContent).toContain("Kestrel"); + expect(strip.textContent).not.toContain( + "account_modal.level_visibility_hidden", + ); + const badge = strip.querySelector("level-badge") as HTMLElement & { + level: number; + prestige: number; + }; + expect(badge.level).toBe(96); + expect(badge.prestige).toBe(3); + expect(badge.getAttribute("size")).toBe("24"); + }); + + it("while hidden: switch off, the name alone and 'Level hidden'", async () => { + await show( + makePlayer({ progress, username: "Kestrel", levelHidden: true }), + ); + + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + const strip = preview()!; + expect(strip.querySelector("level-badge")).toBeNull(); + expect(strip.textContent).toContain("Kestrel"); + expect(strip.textContent).toContain( + "account_modal.level_visibility_hidden", + ); + }); + + it("shows the switch without the preview when progression is off", async () => { + await show(makePlayer({ username: "Kestrel", levelHidden: false })); + + expect(levelSwitch()).toBeTruthy(); + expect(preview()).toBeNull(); + expect(panel.querySelector("level-badge")).toBeNull(); + }); + + it("turning it off: optimistic, disabled in flight, PUTs hidden=true", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + let answer!: (r: { ok: true; hidden: boolean }) => void; + vi.mocked(setLevelVisibility).mockImplementationOnce( + () => new Promise((r) => (answer = r)), + ); + const player = makePlayer({ progress, levelHidden: false }); + await show(player); + + levelSwitch()!.click(); + await panel.updateComplete; + + expect(setLevelVisibility).toHaveBeenCalledWith(true); + // Optimistic: already off, on the cached profile object too. + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.levelHidden).toBe(true); + expect(preview()!.querySelector("level-badge")).toBeNull(); + // Disabled while in flight; another click sends nothing. + expect(levelSwitch()!.disabled).toBe(true); + levelSwitch()!.click(); + expect(setLevelVisibility).toHaveBeenCalledTimes(1); + + answer({ ok: true, hidden: true }); + await settle(); + expect(levelSwitch()!.disabled).toBe(false); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.levelHidden).toBe(true); + }); + + it("turning it back on PUTs hidden=false", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + const player = makePlayer({ progress, levelHidden: true }); + await show(player); + + levelSwitch()!.click(); + await settle(); + + expect(setLevelVisibility).toHaveBeenCalledWith(false); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(player.levelHidden).toBe(false); + expect(preview()!.querySelector("level-badge")).toBeTruthy(); + }); + + it("reverts and shows the error alert when the request fails", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setLevelVisibility).mockResolvedValueOnce({ + ok: false, + code: "failed", + }); + const player = makePlayer({ progress, levelHidden: false }); + await show(player); + + levelSwitch()!.click(); + await settle(); + + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(levelSwitch()!.disabled).toBe(false); + expect(player.levelHidden).toBe(false); + expect(showInGameAlert).toHaveBeenCalledWith( + "account_modal.level_visibility_failed", + ); + }); + + it("reverts without an alert when signed out (401)", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setLevelVisibility).mockResolvedValueOnce({ + ok: false, + code: "logged_out", + }); + const player = makePlayer({ progress, levelHidden: true }); + await show(player); + + levelSwitch()!.click(); + await settle(); + + expect(player.levelHidden).toBe(true); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + // logOut() already ran inside setLevelVisibility; the signed-out state + // takes over from there. + expect(showInGameAlert).not.toHaveBeenCalled(); + }); +}); From bfbcbda815c83bc708a0fc3ce94807a85ea92ebe Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 12:31:51 +0100 Subject: [PATCH 12/30] perf: benchmark the lobby roster's level badges - tests/perf/LobbyInfoWirePerf.ts (also in `npm run perf`): lobby_info frame size and encode/decode time for 10/50/150 clients with and without levelBadge, plus the join-time stamping. A badge costs 3 bytes per entry; at 150 clients a frame is 3633 -> 4083 bytes and encodes in ~31 -> ~38 us, so one broadcast (one encode per recipient) ~4.7 -> ~5.7 ms per second. - tests/perf/client/LobbyPlayerViewRenderPerf.ts: with 150 clients under jsdom, median/p95 of first paint and of the per-second "same roster, new objects" update. LOBBY_PERF_SRC points it at another checkout's src/ to compare branches. Neither is a vitest file, so neither runs in the normal suite. Co-Authored-By: Claude Opus 5.5 --- tests/perf/LobbyInfoWirePerf.ts | 178 ++++++++++++++ .../perf/client/LobbyPlayerViewRenderPerf.ts | 219 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 tests/perf/LobbyInfoWirePerf.ts create mode 100644 tests/perf/client/LobbyPlayerViewRenderPerf.ts diff --git a/tests/perf/LobbyInfoWirePerf.ts b/tests/perf/LobbyInfoWirePerf.ts new file mode 100644 index 00000000000..17b220fd789 --- /dev/null +++ b/tests/perf/LobbyInfoWirePerf.ts @@ -0,0 +1,178 @@ +import Benchmark from "benchmark"; +import { Progress } from "../../src/core/ApiSchemas"; +import { + ClientInfo, + ServerLobbyInfoMessage, + ServerMessage, +} from "../../src/core/Schemas"; +import { + decodeServerMessage, + encodeServerMessage, +} from "../../src/core/ZbinWire"; +import * as LevelBadgeModule from "../../src/server/LevelBadge"; +import { testGameConfig } from "../util/Wire"; + +/** + * Cost of the lobby roster's level badges on the lobby_info broadcast. + * + * While a game is in its lobby the server sends every connected client a + * lobby_info frame once a second (GameServer.broadcastLobbyInfo), encoding it + * once PER RECIPIENT — so one broadcast of an N-player lobby is N encodes of + * an N-entry roster. This measures, for 10 / 50 / 150 clients: + * + * - the encoded frame size with no badges and with a badge on every entry + * (the worst case: everyone signed in, progression on, nobody hidden); + * - the time to encode one frame, and so one whole broadcast (x N); + * - the time a client takes to decode one frame. + * + * Plus a microbenchmark of the join-time badge stamping (levelBadgeFromProgress, + * and levelBadgeForPlayer with the "hide my level" check when present). + * + * The lobby is not yet started, so the server encodes with no zbin context + * (zbinCtx is undefined until start): clientIDs go out as plain strings. + * + * Usage: npx tsx tests/perf/LobbyInfoWirePerf.ts + */ + +// Deterministic data: a tiny LCG, so every run (and branch) encodes the same +// rosters. +let seed = 0x5eed; +function rand(): number { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; +} +const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +function word(len: number): string { + let s = ""; + for (let i = 0; i < len; i++) s += ALNUM[Math.floor(rand() * ALNUM.length)]; + return s; +} + +function roster(n: number, withBadges: boolean): ClientInfo[] { + seed = 0x5eed + n; + const ids = Array.from({ length: n }, () => word(8)); + return ids.map((clientID, i) => { + const c: ClientInfo = { + clientID, + username: word(6 + Math.floor(rand() * 9)), + // Roughly a third in a clan, a quarter verified, a few friends here and + // there — the shape NameVisibility.lobbyClients sends with names on. + clanTag: rand() < 0.33 ? word(2 + Math.floor(rand() * 4)) : null, + }; + if (rand() < 0.25) c.verified = true; + if (rand() < 0.1) c.friends = [ids[(i + 1) % n]]; + // Drawn for every entry either way, so both rosters consume the same + // random numbers and differ ONLY in the badges. + const badge = + rand() < 0.01 + ? { level: 100, prestige: 10, legend: true } + : { + level: 1 + Math.floor(rand() * 100), + prestige: Math.floor(rand() * 11), + legend: false, + }; + if (withBadges) c.levelBadge = badge; + return c; + }); +} + +function lobbyInfo(clients: ClientInfo[]): ServerMessage { + return { + type: "lobby_info", + lobby: { + gameID: "AbCdEfGh", + clients, + lobbyCreatorClientID: clients[0]?.clientID, + serverTime: 1_790_000_000_000, + gameConfig: testGameConfig({ maxPlayers: 150 }), + }, + myClientID: clients[0]?.clientID ?? "AbCdEfGh", + groupToken: "0123456789abcdef0123456789abcdef", + } satisfies ServerLobbyInfoMessage; +} + +function fmtUs(seconds: number): string { + return seconds < 1e-6 + ? `${(seconds * 1e9).toFixed(1)} ns` + : `${(seconds * 1e6).toFixed(2)} µs`; +} + +function bench(name: string, fn: () => void): Benchmark { + const b = new Benchmark(name, fn, { minSamples: 40 }); + b.run({ async: false }); + return b; +} + +console.log("=== lobby_info frame size (bytes) ==="); +console.log("clients | no badges | all badges | delta | delta/entry"); +for (const n of [10, 50, 150]) { + const plain = encodeServerMessage(lobbyInfo(roster(n, false)), undefined); + const badged = encodeServerMessage(lobbyInfo(roster(n, true)), undefined); + const delta = badged.byteLength - plain.byteLength; + console.log( + `${String(n).padStart(7)} | ${String(plain.byteLength).padStart(9)} | ${String( + badged.byteLength, + ).padStart(10)} | ${String(delta).padStart(5)} | ${(delta / n).toFixed(2)}`, + ); +} + +console.log("\n=== encode / decode time (benchmark.js, mean ± rme) ==="); +console.log( + "clients | badges | encode 1 frame | broadcast (x N) | decode 1 frame", +); +for (const n of [10, 50, 150]) { + for (const withBadges of [false, true]) { + const msg = lobbyInfo(roster(n, withBadges)); + const bytes = encodeServerMessage(msg, undefined); + const enc = bench(`encode ${n} ${withBadges}`, () => { + encodeServerMessage(msg, undefined); + }); + const dec = bench(`decode ${n} ${withBadges}`, () => { + decodeServerMessage(bytes, undefined); + }); + console.log( + `${String(n).padStart(7)} | ${(withBadges ? "yes" : "no").padStart(6)} | ${fmtUs( + enc.stats.mean, + ).padStart(10)} ±${enc.stats.rme.toFixed(1)}% | ${( + (enc.stats.mean * n * 1e3).toFixed(3) + " ms" + ).padStart( + 15, + )} | ${fmtUs(dec.stats.mean).padStart(10)} ±${dec.stats.rme.toFixed(1)}%`, + ); + } +} + +console.log("\n=== join-time badge stamping (per join) ==="); +const progress: Progress = { + prestige: 3, + level: 57, + xpInLevel: 120, + xpForNext: 900, + lifetimeXp: 1_234_567, + legend: false, + canPrestige: false, +}; +let sink: unknown; +const fromProgress = bench("levelBadgeFromProgress", () => { + sink = LevelBadgeModule.levelBadgeFromProgress(progress); +}); +console.log( + `levelBadgeFromProgress(progress) ${fmtUs(fromProgress.stats.mean)} ±${fromProgress.stats.rme.toFixed(1)}%`, +); +// levelBadgeForPlayer only exists once "hide my level" is in (level-optout). +const forPlayer = (LevelBadgeModule as Record) + .levelBadgeForPlayer as + | ((p: { progress?: Progress; levelHidden?: boolean }) => unknown) + | undefined; +if (forPlayer !== undefined) { + for (const levelHidden of [undefined, false, true]) { + const player = { progress, levelHidden }; + const b = bench(`levelBadgeForPlayer ${levelHidden}`, () => { + sink = forPlayer(player); + }); + console.log( + `levelBadgeForPlayer({ levelHidden: ${String(levelHidden).padEnd(9)} }) ${fmtUs(b.stats.mean)} ±${b.stats.rme.toFixed(1)}%`, + ); + } +} +void sink; diff --git a/tests/perf/client/LobbyPlayerViewRenderPerf.ts b/tests/perf/client/LobbyPlayerViewRenderPerf.ts new file mode 100644 index 00000000000..82de08815dc --- /dev/null +++ b/tests/perf/client/LobbyPlayerViewRenderPerf.ts @@ -0,0 +1,219 @@ +/** + * render cost with ~150 clients, with and without level + * badges. + * + * The lobby list re-renders on every lobby_info frame (once a second): each + * frame decodes into a brand-new ClientInfo[] of brand-new objects, so this + * times both the first paint and that steady-state "same roster, new objects" + * update, which is what the player pays every second while waiting. + * + * Scenarios (150 clients): + * - FFA pills, no badges / every player badged; + * - team mode (Players column + team cards), no badges / every player + * badged / half badged (the other half get the empty alignment slot); + * - and a 1-in-150 change (one player joins) on top of a badged roster. + * + * Runs under jsdom, so absolute numbers are jsdom's (no layout or paint) and + * only the relative cost is meaningful. Reports median / p95 over many runs + * after a warm-up. + * + * Usage: npx tsx tests/perf/client/LobbyPlayerViewRenderPerf.ts + * (RUNS=n to change the sample count, LOBBY_PERF_SRC=/src + * to time another branch's component with the same harness) + */ +import { readFileSync } from "fs"; +// @ts-expect-error -- jsdom ships no types and @types/jsdom is not a dependency +import { JSDOM } from "jsdom"; +import { performance } from "perf_hooks"; +import { pathToFileURL } from "url"; + +const dom = new JSDOM("", { + url: "http://localhost/", + pretendToBeVisual: true, +}); +// Expose jsdom's window as the global scope, like vitest's jsdom environment: +// every window property Node doesn't already have, plus the few it has its +// own versions of. +const w = dom.window as unknown as Record; +const OVERRIDE = new Set(["navigator", "localStorage", "sessionStorage"]); +for (const key of Object.getOwnPropertyNames(w)) { + if (key in globalThis && !OVERRIDE.has(key)) continue; + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: w[key], + }); +} +Object.defineProperty(globalThis, "window", { value: globalThis }); + +const RUNS = Number(process.env.RUNS ?? 100); +const WARMUP = 20; +// Point at another checkout's src/ to compare branches, e.g. +// LOBBY_PERF_SRC=C:/dev/OpenFrontIO/.worktrees/roster-levels/src +const SRC = process.env.LOBBY_PERF_SRC + ? pathToFileURL(process.env.LOBBY_PERF_SRC.replace(/\/?$/, "/")).href + : new URL("../../../src/", import.meta.url).href; + +interface Client { + clientID: string; + username: string; + clanTag: string | null; + verified?: boolean; + levelBadge?: { level: number; prestige: number; legend: boolean }; +} + +function roster(n: number, badged: (i: number) => boolean): Client[] { + return Array.from({ length: n }, (_, i) => { + const c: Client = { + clientID: `c${String(i).padStart(7, "0")}`, + username: `Player${i}`, + clanTag: i % 3 === 0 ? "CLAN" : null, + }; + if (i % 4 === 0) c.verified = true; + if (badged(i)) { + c.levelBadge = { + level: 1 + ((i * 37) % 100), + prestige: i % 11, + legend: i === 7, + }; + } + return c; + }); +} + +// A lobby_info frame decodes to fresh objects every time. +const fresh = (clients: Client[]): Client[] => + clients.map((c) => ({ + ...c, + levelBadge: c.levelBadge ? { ...c.levelBadge } : undefined, + })); + +function stats(samples: number[]): string { + const s = [...samples].sort((a, b) => a - b); + const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))]; + return `median ${at(0.5).toFixed(3)} ms p95 ${at(0.95).toFixed(3)} ms`; +} + +// translateText() looks the up once and caches it; without one +// in the page it re-runs document.querySelector on EVERY call, which scans the +// whole lobby list and swamps the timings. The real page always has one, so +// stand one in with the English strings. +function installLangSelector() { + const flat: Record = {}; + const walk = (obj: Record, prefix: string) => { + for (const [k, v] of Object.entries(obj)) { + if (typeof v === "string") flat[prefix + k] = v; + else walk(v as Record, `${prefix}${k}.`); + } + }; + walk( + JSON.parse( + readFileSync( + new URL("../../../resources/lang/en.json", import.meta.url), + "utf8", + ), + ), + "", + ); + const sel = document.createElement("lang-selector") as HTMLElement & { + currentLang: string; + translations: Record; + defaultTranslations: Record; + }; + sel.currentLang = "en"; + sel.translations = flat; + sel.defaultTranslations = flat; + document.body.append(sel); +} + +async function main() { + installLangSelector(); + const { GameMode } = await import(SRC + "core/game/Game.ts"); + await import(SRC + "client/components/LobbyPlayerView.ts"); + + type View = HTMLElement & { + gameMode: unknown; + clients: Client[]; + currentClientID: string; + teamCount: unknown; + updateComplete: Promise; + }; + + async function mount(mode: unknown, clients: Client[]): Promise { + const view = document.createElement("lobby-player-view") as View; + view.gameMode = mode; + view.teamCount = 2; + view.currentClientID = clients[0].clientID; + view.clients = clients; + document.body.append(view); + await view.updateComplete; + return view; + } + + async function firstPaint(mode: unknown, clients: Client[]) { + const samples: number[] = []; + for (let i = 0; i < WARMUP + RUNS; i++) { + const t0 = performance.now(); + const view = await mount(mode, fresh(clients)); + const t1 = performance.now(); + view.remove(); + if (i >= WARMUP) samples.push(t1 - t0); + } + return stats(samples); + } + + async function update(mode: unknown, a: Client[], b: Client[] = a) { + const view = await mount(mode, fresh(a)); + const samples: number[] = []; + for (let i = 0; i < WARMUP + RUNS; i++) { + const next = fresh(i % 2 === 0 ? b : a); + const t0 = performance.now(); + view.clients = next; + await view.updateComplete; + const t1 = performance.now(); + if (i >= WARMUP) samples.push(t1 - t0); + } + view.remove(); + return stats(samples); + } + + const N = 150; + const none = roster(N, () => false); + const all = roster(N, () => true); + const half = roster(N, (i) => i % 2 === 0); + const allPlusOne = [...all, ...roster(N + 1, () => true).slice(N)]; + + const rows: [string, () => Promise][] = [ + ["FFA first paint, no badges ", () => firstPaint(GameMode.FFA, none)], + ["FFA first paint, all badged ", () => firstPaint(GameMode.FFA, all)], + ["FFA update, no badges ", () => update(GameMode.FFA, none)], + ["FFA update, all badged ", () => update(GameMode.FFA, all)], + [ + "FFA update, +1 join badged", + () => update(GameMode.FFA, all, allPlusOne), + ], + ["Team first paint, no badges ", () => firstPaint(GameMode.Team, none)], + ["Team first paint, all badged ", () => firstPaint(GameMode.Team, all)], + ["Team first paint, half (slots) ", () => firstPaint(GameMode.Team, half)], + ["Team update, no badges ", () => update(GameMode.Team, none)], + ["Team update, all badged ", () => update(GameMode.Team, all)], + ["Team update, half (slots) ", () => update(GameMode.Team, half)], + ]; + console.log( + `=== , ${N} clients, jsdom, ${RUNS} runs after ${WARMUP} warm-up ===`, + ); + // ONLY= runs just the matching rows (e.g. ONLY="first paint"). + const only = process.env.ONLY; + for (const [name, run] of rows) { + if (only !== undefined && !name.includes(only)) continue; + console.log(`${name} ${await run()}`); + } +} + +main().then( + () => process.exit(0), + (e) => { + console.error(e); + process.exit(1); + }, +); From 91e13d6610916ef0a66cd0fdf66e1a055749aadc Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 12:46:07 +0100 Subject: [PATCH 13/30] fix(core): read a malformed levelHidden as absent The game server parses /users/@me at join, so a bad levelHidden value must not fail the parse and reject the join. Like progress, it now reads as absent, which means the level is shown. Co-Authored-By: Claude Opus 5.5 --- src/core/ApiSchemas.ts | 6 ++++-- tests/server/RosterLevelBadge.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 231e0537e0b..dc29068f15f 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -358,8 +358,10 @@ export const UserMeResponseSchema = z.object({ // 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. - levelHidden: z.boolean().optional(), + // account settings hide the Privacy card. A malformed value reads as + // absent too, like `progress`: the server parses this response at join, + // so a bad value must never fail the parse and reject the join. + levelHidden: z.boolean().optional().catch(undefined), clans: z .array( z.object({ diff --git a/tests/server/RosterLevelBadge.test.ts b/tests/server/RosterLevelBadge.test.ts index d69916181b2..3480a09e7e4 100644 --- a/tests/server/RosterLevelBadge.test.ts +++ b/tests/server/RosterLevelBadge.test.ts @@ -102,6 +102,16 @@ describe("levelBadgeForPlayer: hide my level", () => { ); }); + it("reads a malformed levelHidden as absent instead of failing the parse", () => { + for (const bad of [null, "yes", 1, {}]) { + const me = userMe({ progress, levelHidden: bad }); + expect(me.player.levelHidden).toBeUndefined(); + expect(me.player.progress).toBeDefined(); + // Absent reads as shown, so the badge is still stamped. + expect(levelBadgeForPlayer(me.player)).toBeDefined(); + } + }); + it("stamps nothing when the player hides their level", () => { const me = userMe({ progress, levelHidden: true }); expect(levelBadgeForPlayer(me.player)).toBeUndefined(); From 08e0bfb6eacd19cc4280710972090af07f158979 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 14:28:46 +0100 Subject: [PATCH 14/30] feat(client): "Show my profile in search engines" in the Privacy card A second Privacy row, under a divider below "Show my level to other players": "Show my profile in search engines", on (shown) by default. Turned off, the public profile page (openfront.io/player/) is served noindex; anyone with the link can still open it and link previews are unchanged. The page side is the site Worker's; this is only the setting. The switch is the level one's: the same role="switch" markup (now one shared row renderer), optimistic on the cached /users/@me player object, disabled while in flight, reverted with an alert on failure, and silent on a 401 since logOut() has already run. - ApiSchemas: player.searchHidden on /users/@me, optional and read as absent when malformed (like levelHidden); SearchVisibilityResponseSchema. - Api: setSearchVisibility(hidden) -> PUT /users/@me/search_visibility, { ok, hidden } | logged_out | failed; invalidates the cached /users/@me on success. - The row shows only when /users/@me carries searchHidden, and the card when it carries either flag, so an older API shows nothing new. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 3 + src/client/Api.ts | 49 +++++ src/client/components/AccountSettingsPanel.ts | 140 ++++++++++---- src/core/ApiSchemas.ts | 11 ++ tests/client/SearchVisibilityApi.test.ts | 112 ++++++++++++ .../components/AccountSettingsPanel.test.ts | 172 ++++++++++++++++++ tests/core/ApiSchemas.test.ts | 33 ++++ 7 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 tests/client/SearchVisibilityApi.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 22d08493717..d1a5140e611 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -95,6 +95,9 @@ "reward_level_up": "Level-up reward", "reward_prestige": "Prestige reward", "reward_signup_bonus": "Subscription signup bonus", + "search_visibility_desc": "Your public profile page can appear in Google and other search results. Turn this off to keep it out of search. Anyone with the link can still open it.", + "search_visibility_failed": "Couldn't update your search setting. Please try again.", + "search_visibility_title": "Show my profile in search engines", "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.", "sub_granted_indefinite": "Free access granted to your account. It isn't billed, so there's nothing to manage or cancel.", diff --git a/src/client/Api.ts b/src/client/Api.ts index 912b1cb25c7..0ce4f82e5fd 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -38,6 +38,7 @@ import { PutUsernameResponseSchema, RankedLeaderboardResponse, RankedLeaderboardResponseSchema, + SearchVisibilityResponseSchema, SteamFinalizeResponseSchema, SteamOrderResolution, StreamsFeedSchema, @@ -461,6 +462,54 @@ export async function setLevelVisibility( } } +export type SetSearchVisibilityResult = SetLevelVisibilityResult; + +// PUT /users/@me/search_visibility { hidden } — "keep my profile out of search +// engines" (the public profile page is served noindex). Idempotent: the body is +// the desired state. Invalidates the cached /users/@me on success so the next +// read reflects it. +export async function setSearchVisibility( + hidden: boolean, +): Promise { + try { + const response = await fetch( + `${getApiBase()}/users/@me/search_visibility`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ hidden }), + }, + ); + if (response.status === 401) { + await logOut(); + return { ok: false, code: "logged_out" }; + } + if (!response.ok) { + console.error( + "setSearchVisibility: request failed", + response.status, + response.statusText, + ); + return { ok: false, code: "failed" }; + } + const parsed = SearchVisibilityResponseSchema.safeParse( + await response.json(), + ); + if (!parsed.success) { + console.error("setSearchVisibility: Zod validation failed", parsed.error); + return { ok: false, code: "failed" }; + } + invalidateUserMe(); + return { ok: true, hidden: parsed.data.hidden }; + } catch (e) { + console.error("setSearchVisibility: request failed", e); + return { ok: false, code: "failed" }; + } +} + // The sites a player can generate an identity token for. Fails closed: any // error lands on an empty list, which hides the account-settings card. export async function getIdentityTokenAudiences(): Promise { diff --git a/src/client/components/AccountSettingsPanel.ts b/src/client/components/AccountSettingsPanel.ts index fd193e305c8..1e5c8bbb3ba 100644 --- a/src/client/components/AccountSettingsPanel.ts +++ b/src/client/components/AccountSettingsPanel.ts @@ -2,7 +2,12 @@ import { html, LitElement, nothing, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { UserMeResponse } from "../../core/ApiSchemas"; import { isSteamPrimaryUser } from "../AccountIdentity"; -import { deleteAccount, setLevelVisibility, setMarketingConsent } from "../Api"; +import { + deleteAccount, + setLevelVisibility, + setMarketingConsent, + setSearchVisibility, +} from "../Api"; import { clearLocalSession, linkGoogle, sendMagicLink } from "../Auth"; import { crazyGamesSDK } from "../CrazyGamesSDK"; import { isDesktopShell } from "../DesktopShell"; @@ -20,8 +25,9 @@ type UserMeUser = UserMeResponse["user"]; /** * Account settings: marketing-consent control (with the bind-an-email flow when - * the account has no verified email), privacy ("hide my level"), third-party - * identity tokens and self-service account deletion. + * the account has no verified email), privacy ("hide my level", "keep my + * profile out of search engines"), third-party identity tokens and + * self-service account deletion. * * Extracted from AccountModal so the standalone account-settings modal opened * from the nav profile menu and the account modal's settings tab render the @@ -35,6 +41,7 @@ export class AccountSettingsPanel extends LitElement { @state() private email: string = ""; @state() private consentBusy: boolean = false; @state() private levelVisibilityBusy: boolean = false; + @state() private searchVisibilityBusy: boolean = false; @state() private deleteDialogOpen: boolean = false; @state() private deleteBusy: boolean = false; @@ -130,13 +137,16 @@ export class AccountSettingsPanel extends LitElement { `; } - // Privacy: "Show my level to other players". Only when /users/@me carries the - // setting at all — an older API without it gets no card. + // Privacy: "Show my level to other players" and "Show my profile in search + // engines". Each row only when /users/@me carries its setting at all, and + // the card only when it carries either — an older API without them gets no + // card. private renderPrivacyCard(): TemplateResult | typeof nothing { const levelHidden = this.player?.levelHidden; - if (levelHidden === undefined) return nothing; - const shown = !levelHidden; - const title = translateText("account_modal.level_visibility_title"); + const searchHidden = this.player?.searchHidden; + if (levelHidden === undefined && searchHidden === undefined) { + return nothing; + } return html`
${translateText("account_modal.privacy_title")}
-
-
-
${title}
-
- ${translateText("account_modal.level_visibility_desc")} -
-
- + ${levelHidden === undefined + ? nothing + : html`${this.renderPrivacySwitchRow( + translateText("account_modal.level_visibility_title"), + translateText("account_modal.level_visibility_desc"), + !levelHidden, + this.levelVisibilityBusy, + (shown) => this.setLevelShown(shown), + )} + ${this.renderLevelPreview(!levelHidden)}`} + ${searchHidden === undefined + ? nothing + : html`
+ ${this.renderPrivacySwitchRow( + translateText("account_modal.search_visibility_title"), + translateText("account_modal.search_visibility_desc"), + !searchHidden, + this.searchVisibilityBusy, + (shown) => this.setSearchShown(shown), + )} +
`} +
+ `; + } + + // One Privacy row: title, description and the same role="switch" toggle as + // the email-updates card. On = shown. + private renderPrivacySwitchRow( + title: string, + description: string, + shown: boolean, + busy: boolean, + setShown: (shown: boolean) => void, + ): TemplateResult { + return html` +
+
+
${title}
+
${description}
- ${this.renderLevelPreview(shown)} - +
`; } @@ -403,6 +446,35 @@ export class AccountSettingsPanel extends LitElement { } } + private async setSearchShown(shown: boolean): Promise { + const player = this.player; + if (!player || player.searchHidden === undefined) return; + if (this.searchVisibilityBusy) return; + const previous = player.searchHidden; + const hidden = !shown; + if (previous === hidden) return; + + // Optimistic, like the level toggle: `player` is the cached /users/@me + // profile. The switch is disabled until the server answers; a failure + // puts it back. + this.searchVisibilityBusy = true; + player.searchHidden = hidden; + this.requestUpdate(); + + const result = await setSearchVisibility(hidden); + player.searchHidden = result.ok ? result.hidden : previous; + this.searchVisibilityBusy = false; + this.requestUpdate(); + + // 401: logOut() has already run and the signed-out state takes over — + // nothing to tell the player here. + if (!result.ok && result.code === "failed") { + await showInGameAlert( + translateText("account_modal.search_visibility_failed"), + ); + } + } + private handleEmailInput = (e: Event): void => { this.email = (e.target as HTMLInputElement).value; }; diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index dc29068f15f..6cbfc469fd4 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -362,6 +362,12 @@ export const UserMeResponseSchema = z.object({ // absent too, like `progress`: the server parses this response at join, // so a bad value must never fail the parse and reject the join. levelHidden: z.boolean().optional().catch(undefined), + // "Show my profile in search engines" turned off: the public profile page + // (openfront.io/player/) is served noindex. It still opens for + // anyone with the link, and link previews are unchanged. Optional so an + // older API without the setting still parses — absent hides the row in + // account settings. A malformed value reads as absent, like levelHidden. + searchHidden: z.boolean().optional().catch(undefined), clans: z .array( z.object({ @@ -466,6 +472,11 @@ export type UserMeResponse = z.infer; export const LevelVisibilityResponseSchema = z.object({ hidden: z.boolean(), }); + +// PUT /users/@me/search_visibility { hidden } — the stored setting, echoed. +export const SearchVisibilityResponseSchema = z.object({ + hidden: z.boolean(), +}); export type UserSubscription = NonNullable< NonNullable >; diff --git a/tests/client/SearchVisibilityApi.test.ts b/tests/client/SearchVisibilityApi.test.ts new file mode 100644 index 00000000000..c34fe6a75bb --- /dev/null +++ b/tests/client/SearchVisibilityApi.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Auth", () => ({ + getAuthHeader: async () => "Bearer t", + logOut: vi.fn(), +})); +vi.mock("../../src/client/ClientEnv", async (importOriginal) => ({ + ...(await importOriginal()), +})); + +import { setSearchVisibility } from "../../src/client/Api"; +import { logOut } from "../../src/client/Auth"; +import { ClientEnv } from "../../src/client/ClientEnv"; + +function response(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("setSearchVisibility", () => { + const fetchSpy = vi.fn(); + beforeEach(() => { + fetchSpy.mockReset(); + vi.mocked(logOut).mockClear(); + vi.stubGlobal("fetch", fetchSpy); + // getApiBase() reads window.BOOTSTRAP_CONFIG via ClientEnv (same shape as + // UpdateUsername.test.ts). + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience: "localhost", + instanceId: "test", + gitCommit: "test", + }; + ClientEnv.reset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete (window as any).BOOTSTRAP_CONFIG; + ClientEnv.reset(); + }); + + it("PUTs the desired state with the auth header and returns the echo", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: true })); + + expect(await setSearchVisibility(true)).toEqual({ ok: true, hidden: true }); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toMatch(/\/users\/@me\/search_visibility$/); + expect(init.method).toBe("PUT"); + expect(init.headers.Authorization).toBe("Bearer t"); + expect(init.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body)).toEqual({ hidden: true }); + }); + + it("sends hidden=false to show the profile in search again", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: false })); + + expect(await setSearchVisibility(false)).toEqual({ + ok: true, + hidden: false, + }); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ + hidden: false, + }); + }); + + it("maps 400 to failed", async () => { + fetchSpy.mockResolvedValue(response(400, { error: "Bad Request" })); + + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + expect(logOut).not.toHaveBeenCalled(); + }); + + it("maps 401 to logged_out and runs the signed-out handling", async () => { + fetchSpy.mockResolvedValue(response(401, { error: "Unauthorized" })); + + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "logged_out", + }); + expect(logOut).toHaveBeenCalledTimes(1); + }); + + it("maps a rate limit, an unreadable body and a network error to failed", async () => { + fetchSpy.mockResolvedValueOnce(response(429, {})); + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockResolvedValueOnce(response(200, { hidden: "yes" })); + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockRejectedValueOnce(new TypeError("network")); + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + }); +}); diff --git a/tests/client/components/AccountSettingsPanel.test.ts b/tests/client/components/AccountSettingsPanel.test.ts index 93a10f85797..cb0ee4d778b 100644 --- a/tests/client/components/AccountSettingsPanel.test.ts +++ b/tests/client/components/AccountSettingsPanel.test.ts @@ -16,6 +16,7 @@ vi.mock("../../../src/client/Api", () => ({ deleteAccount: vi.fn(async () => ({ ok: true })), getIdentityTokenAudiences: vi.fn(async () => []), setLevelVisibility: vi.fn(async (hidden: boolean) => ({ ok: true, hidden })), + setSearchVisibility: vi.fn(async (hidden: boolean) => ({ ok: true, hidden })), })); vi.mock("../../../src/client/InGameModal", () => ({ @@ -378,3 +379,174 @@ describe("AccountSettingsPanel — privacy card (hide my level)", () => { expect(showInGameAlert).not.toHaveBeenCalled(); }); }); + +describe("AccountSettingsPanel — privacy card (search engines)", () => { + let panel: AccountSettingsPanel; + + beforeEach(async () => { + if (!customElements.get("account-settings-panel")) { + customElements.define("account-settings-panel", AccountSettingsPanel); + } + panel = document.createElement( + "account-settings-panel", + ) as AccountSettingsPanel; + document.body.appendChild(panel); + await panel.updateComplete; + }); + + afterEach(() => { + document.body.removeChild(panel); + vi.clearAllMocks(); + }); + + async function show(player: UserMePlayer): Promise { + panel.player = player; + panel.user = { email: "player@example.com" }; + await panel.updateComplete; + } + + // Lets the mocked request settle, then the re-render. + async function settle(): Promise { + await new Promise((r) => setTimeout(r, 0)); + await panel.updateComplete; + } + + const searchSwitch = () => + panel.querySelector( + 'button[role="switch"][aria-label="account_modal.search_visibility_title"]', + ) as HTMLButtonElement | null; + const levelSwitch = () => + panel.querySelector( + 'button[role="switch"][aria-label="account_modal.level_visibility_title"]', + ) as HTMLButtonElement | null; + + it("renders no search row when /users/@me has no searchHidden (older API)", async () => { + await show(makePlayer({ levelHidden: false })); + + expect(levelSwitch()).toBeTruthy(); + expect(searchSwitch()).toBeNull(); + expect(panel.textContent).not.toContain( + "account_modal.search_visibility_title", + ); + }); + + it("sits below the level row, under a divider", async () => { + await show(makePlayer({ levelHidden: false, searchHidden: false })); + + const text = panel.textContent ?? ""; + const level = text.indexOf("account_modal.level_visibility_title"); + const search = text.indexOf("account_modal.search_visibility_title"); + expect(level).toBeGreaterThanOrEqual(0); + expect(search).toBeGreaterThan(level); + expect(text).toContain("account_modal.search_visibility_desc"); + const row = searchSwitch()!.closest("div.border-t"); + expect(row).toBeTruthy(); + expect(row!.contains(levelSwitch())).toBe(false); + }); + + it("shows the card with only the search row when levelHidden is absent", async () => { + await show(makePlayer({ searchHidden: false })); + + expect(panel.textContent).toContain("account_modal.privacy_title"); + expect(searchSwitch()).toBeTruthy(); + expect(levelSwitch()).toBeNull(); + // Nothing above it to divide from. + expect(searchSwitch()!.closest("div.border-t")).toBeNull(); + }); + + it("is on when shown in search (the default) and off when hidden", async () => { + await show(makePlayer({ searchHidden: false })); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(searchSwitch()!.disabled).toBe(false); + + await show(makePlayer({ searchHidden: true })); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + }); + + it("turning it off: optimistic, disabled in flight, PUTs hidden=true", async () => { + const { setSearchVisibility, setLevelVisibility } = + await import("../../../src/client/Api"); + let answer!: (r: { ok: true; hidden: boolean }) => void; + vi.mocked(setSearchVisibility).mockImplementationOnce( + () => new Promise((r) => (answer = r)), + ); + const player = makePlayer({ levelHidden: false, searchHidden: false }); + await show(player); + + searchSwitch()!.click(); + await panel.updateComplete; + + expect(setSearchVisibility).toHaveBeenCalledWith(true); + expect(setLevelVisibility).not.toHaveBeenCalled(); + // Optimistic: already off, on the cached profile object too. + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.searchHidden).toBe(true); + // Disabled while in flight; another click sends nothing. The level + // switch is independent and stays usable. + expect(searchSwitch()!.disabled).toBe(true); + expect(levelSwitch()!.disabled).toBe(false); + searchSwitch()!.click(); + expect(setSearchVisibility).toHaveBeenCalledTimes(1); + + answer({ ok: true, hidden: true }); + await settle(); + expect(searchSwitch()!.disabled).toBe(false); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.searchHidden).toBe(true); + expect(player.levelHidden).toBe(false); + }); + + it("turning it back on PUTs hidden=false", async () => { + const { setSearchVisibility } = await import("../../../src/client/Api"); + const player = makePlayer({ searchHidden: true }); + await show(player); + + searchSwitch()!.click(); + await settle(); + + expect(setSearchVisibility).toHaveBeenCalledWith(false); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(player.searchHidden).toBe(false); + }); + + it("reverts and shows the error alert when the request fails", async () => { + const { setSearchVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setSearchVisibility).mockResolvedValueOnce({ + ok: false, + code: "failed", + }); + const player = makePlayer({ searchHidden: false }); + await show(player); + + searchSwitch()!.click(); + await settle(); + + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(searchSwitch()!.disabled).toBe(false); + expect(player.searchHidden).toBe(false); + expect(showInGameAlert).toHaveBeenCalledWith( + "account_modal.search_visibility_failed", + ); + }); + + it("reverts without an alert when signed out (401)", async () => { + const { setSearchVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setSearchVisibility).mockResolvedValueOnce({ + ok: false, + code: "logged_out", + }); + const player = makePlayer({ searchHidden: true }); + await show(player); + + searchSwitch()!.click(); + await settle(); + + expect(player.searchHidden).toBe(true); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + // logOut() already ran inside setSearchVisibility; the signed-out state + // takes over from there. + expect(showInGameAlert).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/ApiSchemas.test.ts b/tests/core/ApiSchemas.test.ts index d861a026097..922d2bd04cc 100644 --- a/tests/core/ApiSchemas.test.ts +++ b/tests/core/ApiSchemas.test.ts @@ -121,3 +121,36 @@ describe("UserMeResponseSchema subscription.provider", () => { ); }); }); + +// "Show my profile in search engines" turned off. Optional so an older API +// still parses (account settings then show no search row); a malformed value +// reads as absent rather than failing the /users/@me parse, like levelHidden. +describe("UserMeResponseSchema searchHidden", () => { + const parseWith = (player: Record) => + UserMeResponseSchema.parse({ user: {}, player }); + + it("reads true and false", () => { + expect( + parseWith({ ...samplePlayer(), searchHidden: true }).player.searchHidden, + ).toBe(true); + expect( + parseWith({ ...samplePlayer(), searchHidden: false }).player.searchHidden, + ).toBe(false); + }); + + it("is undefined when the API does not send it", () => { + expect(parseWith(samplePlayer()).player.searchHidden).toBeUndefined(); + }); + + it("reads a malformed value as absent instead of failing the parse", () => { + for (const bad of [null, "yes", 1, {}]) { + const parsed = parseWith({ + ...samplePlayer(), + levelHidden: true, + searchHidden: bad, + }); + expect(parsed.player.searchHidden).toBeUndefined(); + expect(parsed.player.levelHidden).toBe(true); + } + }); +}); From 46dd844473d3a1c90ac3e22e2b2b8a61e70dc52d Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 15:06:24 +0100 Subject: [PATCH 15/30] fix(client): a player who hides their level sees it on their own profile The profile modal reads every level, your own included, from GET /public/player/:publicId/progress, which 404s for a player who hides their level - for everyone, them included. So hiding your level also hid it from you on your own profile, contradicting the setting's promise that you still see your own level. Keep the public endpoint first (it is fresher than the page-load /users/@me), but when it has no progress and the profile is the viewer's own (same publicId as /users/@me), fall back to the viewer's own /users/@me progress. getUserMe() is memoised, so this normally makes no request. Other players' profiles never fall back, so a hidden level stays hidden from everyone else; a signed-out viewer has nothing to fall back on. Co-Authored-By: Claude Opus 5.5 --- src/client/PlayerProfileModal.ts | 17 +++++++--- tests/client/ProgressionModals.test.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index f395fff1a71..9c5816443f8 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 } 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"; @@ -263,13 +263,20 @@ export class PlayerProfileModal extends BaseModal { `; } - // 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. + // Everyone's level, your own included, comes from the public endpoint first: + // the /users/@me copy is cached from page load and would miss the games + // played since. But that endpoint 404s for a player who hides their level — + // for everyone, them included — and they still see their own level. So on + // your OWN profile only, no public progress falls back to your /users/@me + // copy (memoised: normally no request). Nobody else's profile ever does, so a + // hidden level stays hidden from others. Missing progress hides the summary. private async loadProgress(publicId: string): Promise { try { const progress = await fetchPublicPlayerProgress(publicId); - return progress === false ? null : progress; + if (progress !== false) return progress; + const me = await getUserMe(); + if (me === false || me.player.publicId !== publicId) return null; + return me.player.progress ?? null; } catch { return null; } diff --git a/tests/client/ProgressionModals.test.ts b/tests/client/ProgressionModals.test.ts index 327fefa4e86..8e0722bbac8 100644 --- a/tests/client/ProgressionModals.test.ts +++ b/tests/client/ProgressionModals.test.ts @@ -236,6 +236,52 @@ describe("player profile level", () => { expect(fetchPublicPlayerProgress).toHaveBeenCalledWith("me"); }); + // The public endpoint 404s for a player who hides their level, them + // included — but they still see their own level on their own profile. + it("shows your own hidden level from /users/@me when the public endpoint has none", async () => { + getUserMe.mockResolvedValue({ + ...me, + player: { ...me.player, levelHidden: true }, + }); + fetchPublicPlayerProgress.mockResolvedValue(false); + await open("me"); + await vi.waitFor(async () => { + await settled(modal); + const level = modal.querySelector("[data-profile-level]"); + expect(level).not.toBeNull(); + expect(level!.textContent).toContain('progression.level:{"level":64}'); + expect(level!.textContent).toContain( + 'progression.prestige:{"prestige":2}', + ); + }); + expect(fetchPublicPlayerProgress).toHaveBeenCalledWith("me"); + }); + + it("never shows a hidden player's level on their profile to anyone else", async () => { + getUserMe.mockClear(); + getUserMe.mockResolvedValue({ + ...me, + player: { ...me.player, levelHidden: true }, + }); + fetchPublicPlayerProgress.mockResolvedValue(false); + await open("hidden-player"); + await vi.waitFor(() => expect(getUserMe).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + await settled(modal); + expect(modal.querySelector("[data-profile-level]")).toBeNull(); + }); + + it("has nothing to fall back on for a signed-out viewer", async () => { + getUserMe.mockClear(); + getUserMe.mockResolvedValue(false); + fetchPublicPlayerProgress.mockResolvedValue(false); + await open("me"); + await vi.waitFor(() => expect(getUserMe).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + await settled(modal); + expect(modal.querySelector("[data-profile-level]")).toBeNull(); + }); + it("hides the level when there is no progress", async () => { fetchPublicPlayerProgress.mockResolvedValue(false); await open("no-progress"); From 5f83874e221cea04779fdb421924feba672b0441 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 19:35:26 +0100 Subject: [PATCH 16/30] Stack the XP reveal's counter and level progress on a narrow bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone the bar under the XP reveal is under 200px wide, so the centred "+1,150 XP" counter ran straight into the truncated "444 / 2,41…" at its right end. Below a 16rem bar (a container query on the bar's column) the two now stack, centred, at a fixed height so nothing moves during the reveal. Wider bars keep today's layout. Co-Authored-By: Claude Opus 5.5 --- src/client/components/GameXpPanel.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index acd590922ff..24e24d08abd 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -750,10 +750,17 @@ export class GameXpPanel extends LitElement { >`, )}
-
+
${this.renderSegmentedBar(fill, levelUp !== null, progressText)} -
- + +
+ ${progressText} From 6a758f1897bea8f43e5462e64c4139c743768672 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 19:31:10 +0100 Subject: [PATCH 17/30] 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 761116903fb..c70a4fab827 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 08b110e73ef..ac066f973a1 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 f395fff1a71..0881cfa258f 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 5322826bb93..331ecae9159 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 24e24d08abd..6259b8e23d8 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 00000000000..ffcdb142eea --- /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 c23b2d49708..360922c9fec 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 42dc86c8cca..57f38453e5a 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 500344a62af..3847a4ca817 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 00000000000..0f225826a96 --- /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 327fefa4e86..5e9c7e183e7 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 18/30] 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 cd33e8fd433..dec0816f158 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 8f1f3d2c330..c93283254b0 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 00000000000..a88bcb5594f --- /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 a4ee2550b67..89bce95a8fb 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 ca93f71556a..a0f4aa307f6 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 f2c1cee7343..9990abe7a69 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 1fa217d73a4..2dd3e50d1df 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 2c44d5c3e61..e34cefe7bd5 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 25a010b5f94..b6a3e957017 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 250f60978b8..28a00b98c8a 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 bc534e11523..88494986464 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 00000000000..4879355b4fc --- /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 63c94f7459e..1b5ae7c2428 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 19/30] 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 d04cf15aabc..027d31f7f72 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 00000000000..4181147c72e --- /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 c93283254b0..46d9ae02e7f 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 20/30] 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 fd49e692e3d..117763663f0 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 5ae7770b89aa881bad1ade2a0eaa1d4add386d78 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 8 Oct 2026 14:12:48 +0100 Subject: [PATCH 21/30] Hide my level: a malformed setting reads as hidden; say it takes a while - levelHidden that isn't a boolean now parses as hidden (still never fails the join), so the badge is never stamped when the setting can't be read. - The setting's description says it can take a little while to update everywhere. - Drop a placeholder comment about later work. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 7 ++++--- resources/lang/en.json | 2 +- src/client/components/AccountSettingsPanel.ts | 2 -- tests/server/RosterLevelBadge.test.ts | 10 +++++----- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index f59ec05a784..cb81506422b 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -362,9 +362,10 @@ export const UserMeResponseSchema = z.object({ // 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 - // absent too, like `progress`: the server parses this response at join, - // so a bad value must never fail the parse and reject the join. - levelHidden: z.boolean().optional().catch(undefined), + // 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/resources/lang/en.json b/resources/lang/en.json index 1f5aca9d6f4..3af43dd5a23 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -48,7 +48,7 @@ "identity_token_rate_limited": "Too many requests. Please wait a minute and try again.", "identity_token_site": "Site", "identity_token_title": "Link to a third-party site", - "level_visibility_desc": "Your level badge appears next to your name in lobbies, in game, on leaderboards, in clans and on your profile. Turn this off to hide it. You still earn XP and see your own level.", + "level_visibility_desc": "Your level badge appears next to your name in lobbies, in game, on leaderboards, in clans and on your profile. Turn this off to hide it; it can take a little while to update everywhere. You still earn XP and see your own level.", "level_visibility_failed": "Couldn't update your level setting. Please try again.", "level_visibility_hidden": "Level hidden", "level_visibility_others_see": "Others see", diff --git a/src/client/components/AccountSettingsPanel.ts b/src/client/components/AccountSettingsPanel.ts index c97b4971806..cb76d5d0da0 100644 --- a/src/client/components/AccountSettingsPanel.ts +++ b/src/client/components/AccountSettingsPanel.ts @@ -169,8 +169,6 @@ export class AccountSettingsPanel extends LitElement {
${this.renderLevelPreview(shown)} -
`; } diff --git a/tests/server/RosterLevelBadge.test.ts b/tests/server/RosterLevelBadge.test.ts index 08eef7f409b..a4967eca9ab 100644 --- a/tests/server/RosterLevelBadge.test.ts +++ b/tests/server/RosterLevelBadge.test.ts @@ -117,13 +117,13 @@ describe("levelBadgeForPlayer: hide my level", () => { ); }); - it("reads a malformed levelHidden as absent instead of failing the parse", () => { - for (const bad of [null, "yes", 1, {}]) { + 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).toBeUndefined(); + expect(me.player.levelHidden).toBe(true); expect(me.player.progress).toBeDefined(); - // Absent reads as shown, so the badge is still stamped. - expect(levelBadgeForPlayer(me.player)).toBeDefined(); + // Fails closed: no badge for a setting that can't be read. + expect(levelBadgeForPlayer(me.player)).toBeUndefined(); } }); From a22ad43c2e9918a58ab9b530df28c68fe4d8f8ac Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 8 Oct 2026 14:20:54 +0100 Subject: [PATCH 22/30] Privacy toggles: one write path for level and search visibility Review follow-up on #5779. - Api.ts: setLevelVisibility and setSearchVisibility were two copies of the same PUT; both now call one putVisibility(setting, hidden), so they differ only in the endpoint. One VisibilityResponseSchema replaces the two identical response schemas. - AccountSettingsPanel: setLevelShown/setSearchShown become one setPrivacyShown(field), with each field's request and error text in a single table and a per-field busy flag. - Tests: each setter hits only its own endpoint with only its own body; flipping the level switch leaves the search setting and switch alone. - Search copy says search engines can take a while to drop a page they have already indexed. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 10 +- resources/lang/en.json | 2 +- src/client/Api.ts | 84 +++++------------ src/client/components/AccountSettingsPanel.ts | 93 +++++++++---------- tests/client/SearchVisibilityApi.test.ts | 17 +++- .../components/AccountSettingsPanel.test.ts | 25 +++++ 6 files changed, 114 insertions(+), 117 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 74d6f5e0870..c7dd8439753 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -472,13 +472,9 @@ export const UserMeResponseSchema = z.object({ }); export type UserMeResponse = z.infer; -// PUT /users/@me/level_visibility { hidden } — the stored setting, echoed. -export const LevelVisibilityResponseSchema = z.object({ - hidden: z.boolean(), -}); - -// PUT /users/@me/search_visibility { hidden } — the stored setting, echoed. -export const SearchVisibilityResponseSchema = z.object({ +// PUT /users/@me/level_visibility and /users/@me/search_visibility { hidden } +// — the stored setting, echoed. +export const VisibilityResponseSchema = z.object({ hidden: z.boolean(), }); export type UserSubscription = NonNullable< diff --git a/resources/lang/en.json b/resources/lang/en.json index a4f539cb9f2..d086ebbbcda 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -95,7 +95,7 @@ "reward_level_up": "Level-up reward", "reward_prestige": "Prestige reward", "reward_signup_bonus": "Subscription signup bonus", - "search_visibility_desc": "Your public profile page can appear in Google and other search results. Turn this off to keep it out of search. Anyone with the link can still open it.", + "search_visibility_desc": "Your public profile page can appear in Google and other search results. Turn this off to keep it out of search; search engines can take a while to drop a page they have already indexed. Anyone with the link can still open it.", "search_visibility_failed": "Couldn't update your search setting. Please try again.", "search_visibility_title": "Show my profile in search engines", "sign_in_desc": "Sign in to save your stats and progress", diff --git a/src/client/Api.ts b/src/client/Api.ts index e9c1427cdd8..76e31f9e443 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -9,7 +9,6 @@ import { IdentityTokenAudiencesResponseSchema, IdentityTokenResponse, IdentityTokenResponseSchema, - LevelVisibilityResponseSchema, NewsItemSchema, PaymentsCheckoutResponse, PaymentsCheckoutResponseSchema, @@ -35,7 +34,6 @@ import { PutUsernameResponseSchema, RankedLeaderboardResponse, RankedLeaderboardResponseSchema, - SearchVisibilityResponseSchema, SteamFinalizeResponseSchema, SteamOrderResolution, StreamsFeedSchema, @@ -45,6 +43,7 @@ import { TribeStatsResponseSchema, UserMeResponse, UserMeResponseSchema, + VisibilityResponseSchema, } from "@openfront/shared/ApiSchemas"; import { AnalyticsRecord, @@ -411,7 +410,7 @@ export async function setMarketingConsent( } } -export type SetLevelVisibilityResult = +export type SetVisibilityResult = // 200: the stored setting, as the server echoes it. | { ok: true; hidden: boolean } // 401: the session is gone; logOut() has already run. @@ -420,14 +419,16 @@ export type SetLevelVisibilityResult = // beyond "try again". | { ok: false; code: "failed" }; -// PUT /users/@me/level_visibility { hidden } — "hide my level". Idempotent: -// the body is the desired state. Invalidates the cached /users/@me on success -// so the next read reflects it. -export async function setLevelVisibility( +// PUT /users/@me/ { hidden }: the one write path behind every privacy +// toggle, so each setter differs only in its endpoint. Idempotent: the body is +// the desired state. Invalidates the cached /users/@me on success so the next +// read reflects it. +async function putVisibility( + setting: "level_visibility" | "search_visibility", hidden: boolean, -): Promise { +): Promise { try { - const response = await fetch(`${getApiBase()}/users/@me/level_visibility`, { + const response = await fetch(`${getApiBase()}/users/@me/${setting}`, { method: "PUT", headers: { "Content-Type": "application/json", @@ -441,73 +442,38 @@ export async function setLevelVisibility( } if (!response.ok) { console.error( - "setLevelVisibility: request failed", + `PUT ${setting}: request failed`, response.status, response.statusText, ); return { ok: false, code: "failed" }; } - const parsed = LevelVisibilityResponseSchema.safeParse( - await response.json(), - ); + const parsed = VisibilityResponseSchema.safeParse(await response.json()); if (!parsed.success) { - console.error("setLevelVisibility: Zod validation failed", parsed.error); + console.error(`PUT ${setting}: Zod validation failed`, parsed.error); return { ok: false, code: "failed" }; } invalidateUserMe(); return { ok: true, hidden: parsed.data.hidden }; } catch (e) { - console.error("setLevelVisibility: request failed", e); + console.error(`PUT ${setting}: request failed`, e); return { ok: false, code: "failed" }; } } -export type SetSearchVisibilityResult = SetLevelVisibilityResult; +// "Hide my level". +export function setLevelVisibility( + hidden: boolean, +): Promise { + return putVisibility("level_visibility", hidden); +} -// PUT /users/@me/search_visibility { hidden } — "keep my profile out of search -// engines" (the public profile page is served noindex). Idempotent: the body is -// the desired state. Invalidates the cached /users/@me on success so the next -// read reflects it. -export async function setSearchVisibility( +// "Keep my profile out of search engines" (the public profile page is served +// noindex). +export function setSearchVisibility( hidden: boolean, -): Promise { - try { - const response = await fetch( - `${getApiBase()}/users/@me/search_visibility`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: await getAuthHeader(), - }, - body: JSON.stringify({ hidden }), - }, - ); - if (response.status === 401) { - await logOut(); - return { ok: false, code: "logged_out" }; - } - if (!response.ok) { - console.error( - "setSearchVisibility: request failed", - response.status, - response.statusText, - ); - return { ok: false, code: "failed" }; - } - const parsed = SearchVisibilityResponseSchema.safeParse( - await response.json(), - ); - if (!parsed.success) { - console.error("setSearchVisibility: Zod validation failed", parsed.error); - return { ok: false, code: "failed" }; - } - invalidateUserMe(); - return { ok: true, hidden: parsed.data.hidden }; - } catch (e) { - console.error("setSearchVisibility: request failed", e); - return { ok: false, code: "failed" }; - } +): Promise { + return putVisibility("search_visibility", hidden); } // The sites a player can generate an identity token for. Fails closed: any diff --git a/src/client/components/AccountSettingsPanel.ts b/src/client/components/AccountSettingsPanel.ts index 150b97679f3..f31ed55da46 100644 --- a/src/client/components/AccountSettingsPanel.ts +++ b/src/client/components/AccountSettingsPanel.ts @@ -7,6 +7,7 @@ import { setLevelVisibility, setMarketingConsent, setSearchVisibility, + type SetVisibilityResult, } from "../Api"; import { clearLocalSession, linkGoogle, sendMagicLink } from "../Auth"; import { crazyGamesSDK } from "../CrazyGamesSDK"; @@ -23,6 +24,23 @@ import { googleLinkButton } from "./ui/GoogleLinkButton"; type UserMePlayer = UserMeResponse["player"]; type UserMeUser = UserMeResponse["user"]; +// The /users/@me privacy flags the Privacy card toggles, each with the request +// that stores it and the alert shown when that request fails. +type PrivacyField = "levelHidden" | "searchHidden"; +const PRIVACY_SETTINGS: Record< + PrivacyField, + { save: (hidden: boolean) => Promise; failedKey: string } +> = { + levelHidden: { + save: (hidden) => setLevelVisibility(hidden), + failedKey: "account_modal.level_visibility_failed", + }, + searchHidden: { + save: (hidden) => setSearchVisibility(hidden), + failedKey: "account_modal.search_visibility_failed", + }, +}; + /** * Account settings: marketing-consent control (with the bind-an-email flow when * the account has no verified email), privacy ("hide my level", "keep my @@ -40,8 +58,11 @@ export class AccountSettingsPanel extends LitElement { @state() private email: string = ""; @state() private consentBusy: boolean = false; - @state() private levelVisibilityBusy: boolean = false; - @state() private searchVisibilityBusy: boolean = false; + // Per setting, so one switch's request in flight never locks the other. + @state() private privacyBusy: Record = { + levelHidden: false, + searchHidden: false, + }; @state() private deleteDialogOpen: boolean = false; @state() private deleteBusy: boolean = false; @@ -160,8 +181,8 @@ export class AccountSettingsPanel extends LitElement { translateText("account_modal.level_visibility_title"), translateText("account_modal.level_visibility_desc"), !levelHidden, - this.levelVisibilityBusy, - (shown) => this.setLevelShown(shown), + this.privacyBusy.levelHidden, + (shown) => this.setPrivacyShown("levelHidden", shown), )} ${this.renderLevelPreview(!levelHidden)}`} ${searchHidden === undefined @@ -175,8 +196,8 @@ export class AccountSettingsPanel extends LitElement { translateText("account_modal.search_visibility_title"), translateText("account_modal.search_visibility_desc"), !searchHidden, - this.searchVisibilityBusy, - (shown) => this.setSearchShown(shown), + this.privacyBusy.searchHidden, + (shown) => this.setPrivacyShown("searchHidden", shown), )} `} @@ -417,61 +438,35 @@ export class AccountSettingsPanel extends LitElement { this.requestUpdate(); } - private async setLevelShown(shown: boolean): Promise { + // One handler for every Privacy switch: the field, its busy flag, its + // request and its error text all come from PRIVACY_SETTINGS[field], so a + // toggle can only ever read and write its own setting. + private async setPrivacyShown( + field: PrivacyField, + shown: boolean, + ): Promise { const player = this.player; - if (!player || player.levelHidden === undefined) return; - if (this.levelVisibilityBusy) return; - const previous = player.levelHidden; + if (!player || player[field] === undefined) return; + if (this.privacyBusy[field]) return; + const previous = player[field]; const hidden = !shown; if (previous === hidden) return; // Optimistic, like the consent toggle: `player` is the cached /users/@me // profile, so this also keeps every other reader of it consistent. The // switch is disabled until the server answers; a failure puts it back. - this.levelVisibilityBusy = true; - player.levelHidden = hidden; - this.requestUpdate(); + const setting = PRIVACY_SETTINGS[field]; + this.privacyBusy = { ...this.privacyBusy, [field]: true }; + player[field] = hidden; - const result = await setLevelVisibility(hidden); - player.levelHidden = result.ok ? result.hidden : previous; - this.levelVisibilityBusy = false; - this.requestUpdate(); + const result = await setting.save(hidden); + player[field] = result.ok ? result.hidden : previous; + this.privacyBusy = { ...this.privacyBusy, [field]: false }; // 401: logOut() has already run and the signed-out state takes over — // nothing to tell the player here. if (!result.ok && result.code === "failed") { - await showInGameAlert( - translateText("account_modal.level_visibility_failed"), - ); - } - } - - private async setSearchShown(shown: boolean): Promise { - const player = this.player; - if (!player || player.searchHidden === undefined) return; - if (this.searchVisibilityBusy) return; - const previous = player.searchHidden; - const hidden = !shown; - if (previous === hidden) return; - - // Optimistic, like the level toggle: `player` is the cached /users/@me - // profile. The switch is disabled until the server answers; a failure - // puts it back. - this.searchVisibilityBusy = true; - player.searchHidden = hidden; - this.requestUpdate(); - - const result = await setSearchVisibility(hidden); - player.searchHidden = result.ok ? result.hidden : previous; - this.searchVisibilityBusy = false; - this.requestUpdate(); - - // 401: logOut() has already run and the signed-out state takes over — - // nothing to tell the player here. - if (!result.ok && result.code === "failed") { - await showInGameAlert( - translateText("account_modal.search_visibility_failed"), - ); + await showInGameAlert(translateText(setting.failedKey)); } } diff --git a/tests/client/SearchVisibilityApi.test.ts b/tests/client/SearchVisibilityApi.test.ts index c34fe6a75bb..f696137e1b2 100644 --- a/tests/client/SearchVisibilityApi.test.ts +++ b/tests/client/SearchVisibilityApi.test.ts @@ -8,7 +8,7 @@ vi.mock("../../src/client/ClientEnv", async (importOriginal) => ({ ...(await importOriginal()), })); -import { setSearchVisibility } from "../../src/client/Api"; +import { setLevelVisibility, setSearchVisibility } from "../../src/client/Api"; import { logOut } from "../../src/client/Auth"; import { ClientEnv } from "../../src/client/ClientEnv"; @@ -109,4 +109,19 @@ describe("setSearchVisibility", () => { code: "failed", }); }); + + it("shares a write path with setLevelVisibility but never its endpoint", async () => { + fetchSpy.mockImplementation(async () => response(200, { hidden: true })); + + await setSearchVisibility(true); + await setLevelVisibility(false); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [[searchUrl, searchInit], [levelUrl, levelInit]] = + fetchSpy.mock.calls; + expect(String(searchUrl)).toMatch(/\/users\/@me\/search_visibility$/); + expect(JSON.parse(searchInit.body)).toEqual({ hidden: true }); + expect(String(levelUrl)).toMatch(/\/users\/@me\/level_visibility$/); + expect(JSON.parse(levelInit.body)).toEqual({ hidden: false }); + }); }); diff --git a/tests/client/components/AccountSettingsPanel.test.ts b/tests/client/components/AccountSettingsPanel.test.ts index fa4791fd05c..5ac6d33087a 100644 --- a/tests/client/components/AccountSettingsPanel.test.ts +++ b/tests/client/components/AccountSettingsPanel.test.ts @@ -496,6 +496,31 @@ describe("AccountSettingsPanel — privacy card (search engines)", () => { expect(player.levelHidden).toBe(false); }); + it("flipping the level switch writes only the level setting", async () => { + const { setSearchVisibility, setLevelVisibility } = + await import("../../../src/client/Api"); + let answer!: (r: { ok: true; hidden: boolean }) => void; + vi.mocked(setLevelVisibility).mockImplementationOnce( + () => new Promise((r) => (answer = r)), + ); + const player = makePlayer({ levelHidden: false, searchHidden: false }); + await show(player); + + levelSwitch()!.click(); + await panel.updateComplete; + + expect(setLevelVisibility).toHaveBeenCalledWith(true); + expect(setSearchVisibility).not.toHaveBeenCalled(); + expect(levelSwitch()!.disabled).toBe(true); + expect(searchSwitch()!.disabled).toBe(false); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + + answer({ ok: true, hidden: true }); + await settle(); + expect(player.levelHidden).toBe(true); + expect(player.searchHidden).toBe(false); + }); + it("turning it back on PUTs hidden=false", async () => { const { setSearchVisibility } = await import("../../../src/client/Api"); const player = makePlayer({ searchHidden: true }); From 1f01c8568c5ad7286a184ea4e0507a7ffbcca2a2 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 8 Oct 2026 14:26:25 +0100 Subject: [PATCH 23/30] 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 68e868cdcb2..a3265dcf242 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 6237f902be4..02a138bdecc 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 6fb1acecdc8..bc16bb7bc05 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 027d31f7f72..ea64f8dfbe2 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 4181147c72e..915d479c19b 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 6b5b6ea0457..e7f89806025 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 55d296acc6d..07e75f86a52 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 b46840fdc31..495c60425dd 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 0934735885c..125013e3977 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 266b73e4197..b42fb4a5965 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 91533505757..f0173e842cd 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 a1aa28da7f7..b0adbd6b41d 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 24/30] 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 67dfcf31446..4ee9471bbf8 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 ac066f973a1..35e50729d13 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 cf77800499d..1bc4d74a832 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 f16911ab182..2288b5a4a9c 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 37151ca8030..bd817ff1dfb 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 3468ce7e729..589bf78db11 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 6b37dd0f555..2bc8240f15d 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 97263f9426c..6a7e7514ec7 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 0eb0b6b9d1d..ff0bb65f2dd 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 b66a7c0f860..809b9ccca3e 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 f531996ea5b..cba63772290 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 5e9c7e183e7..491c8275983 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 117763663f0..ba48a3af6e2 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 })); From 6cffd4fc2e444bb7b79ca87e19466ba67b2287f2 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 17:52:39 +0100 Subject: [PATCH 25/30] Keep the own-level fallback in step with the setting and the session - Account settings: after a saved level-visibility change, re-read the viewer's own-badge fallback so the lobby and in-game panel follow it. - Lobby roster: refresh that fallback on every roster update too, not only on joining (getUserMe is memoised, so normally no request). - OwnLevelBadge: a refresh still waiting on /users/@me when the session is cleared no longer writes the old account's badge back. Co-Authored-By: Claude Opus 5.5 --- src/client/OwnLevelBadge.ts | 9 ++++- src/client/components/AccountSettingsPanel.ts | 5 +++ src/client/components/LobbyPlayerView.ts | 9 ++++- tests/client/OwnLevelBadge.test.ts | 11 +++++ .../components/AccountSettingsPanel.test.ts | 40 +++++++++++++++++++ .../LobbyPlayerView.levelBadge.test.ts | 38 ++++++++++++++---- 6 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/client/OwnLevelBadge.ts b/src/client/OwnLevelBadge.ts index 9917dbb3080..d99c5a2e022 100644 --- a/src/client/OwnLevelBadge.ts +++ b/src/client/OwnLevelBadge.ts @@ -24,6 +24,9 @@ export function ownHiddenLevelBadgeFrom( } let cached: LevelBadge | undefined; +// Bumped when the session is cleared, so a refresh still waiting on the old +// account's /users/@me can't write its badge back after the clear. +let session = 0; // The last value refreshOwnHiddenLevelBadge() found, for synchronous readers // (the in-game player panel). @@ -36,10 +39,14 @@ export function ownHiddenLevelBadge(): LevelBadge | undefined { export async function refreshOwnHiddenLevelBadge(): Promise< LevelBadge | undefined > { - cached = ownHiddenLevelBadgeFrom(await getUserMe()); + const forSession = session; + const badge = ownHiddenLevelBadgeFrom(await getUserMe()); + // A stale answer (the session was cleared meanwhile) leaves the cache be. + if (forSession === session) cached = badge; return cached; } document.addEventListener("session-cleared", () => { + session++; cached = undefined; }); diff --git a/src/client/components/AccountSettingsPanel.ts b/src/client/components/AccountSettingsPanel.ts index cb76d5d0da0..dcea0f2087c 100644 --- a/src/client/components/AccountSettingsPanel.ts +++ b/src/client/components/AccountSettingsPanel.ts @@ -7,6 +7,7 @@ import { clearLocalSession, linkGoogle, sendMagicLink } from "../Auth"; import { crazyGamesSDK } from "../CrazyGamesSDK"; import { isDesktopShell } from "../DesktopShell"; import { showInGameAlert } from "../InGameModal"; +import { refreshOwnHiddenLevelBadge } from "../OwnLevelBadge"; import { steamSDK } from "../SteamSDK"; import { translateText } from "../Utils"; import "./baseComponents/Button"; @@ -391,6 +392,10 @@ export class AccountSettingsPanel extends LitElement { player.levelHidden = result.ok ? result.hidden : previous; this.levelVisibilityBusy = false; this.requestUpdate(); + // The viewer's own-badge fallback (lobby roster, in-game panel) reads the + // same cached profile: re-read it now so the change shows without + // waiting for the next lobby. + if (result.ok) void refreshOwnHiddenLevelBadge(); // 401: logOut() has already run and the signed-out state takes over — // nothing to tell the player here. diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index c1681de16f3..32dcc87add6 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -115,9 +115,14 @@ export class LobbyTeamView extends LitElement { : undefined; this._viewerFriends = new Set(self?.friends ?? []); } - // Once per lobby joined, not per lobby_info: from the memoised + // On joining and on every roster update, so a visibility change made in + // the account settings shows by the next lobby_info. From the memoised // /users/@me, so normally no request at all. - if (changedProperties.has("currentClientID") && this.currentClientID) { + if ( + (changedProperties.has("currentClientID") || + changedProperties.has("clients")) && + this.currentClientID + ) { const forClient = this.currentClientID; void refreshOwnHiddenLevelBadge().then((badge) => { if (this.currentClientID !== forClient) return; diff --git a/tests/client/OwnLevelBadge.test.ts b/tests/client/OwnLevelBadge.test.ts index 9f327690d03..e5c8c90d42d 100644 --- a/tests/client/OwnLevelBadge.test.ts +++ b/tests/client/OwnLevelBadge.test.ts @@ -71,4 +71,15 @@ describe("refreshOwnHiddenLevelBadge", () => { document.dispatchEvent(new Event("session-cleared")); expect(ownHiddenLevelBadge()).toBeUndefined(); }); + + it("drops a refresh still pending when the session is cleared", async () => { + let answer!: (me: UserMeResponse | false) => void; + getUserMe.mockReturnValue(new Promise((r) => (answer = r))); + const pending = refreshOwnHiddenLevelBadge(); + document.dispatchEvent(new Event("session-cleared")); + // The old account's profile arrives after the clear. + answer(me({ progress, levelHidden: true })); + expect(await pending).toBeUndefined(); + expect(ownHiddenLevelBadge()).toBeUndefined(); + }); }); diff --git a/tests/client/components/AccountSettingsPanel.test.ts b/tests/client/components/AccountSettingsPanel.test.ts index f9a61c40199..cd3d6d2a4ba 100644 --- a/tests/client/components/AccountSettingsPanel.test.ts +++ b/tests/client/components/AccountSettingsPanel.test.ts @@ -1,6 +1,7 @@ import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AccountSettingsPanel } from "../../../src/client/components/AccountSettingsPanel"; +import { ownHiddenLevelBadge } from "../../../src/client/OwnLevelBadge"; type UserMePlayer = UserMeResponse["player"]; type UserMeUser = UserMeResponse["user"]; @@ -16,6 +17,7 @@ vi.mock("../../../src/client/Api", () => ({ deleteAccount: vi.fn(async () => ({ ok: true })), getIdentityTokenAudiences: vi.fn(async () => []), setLevelVisibility: vi.fn(async (hidden: boolean) => ({ ok: true, hidden })), + getUserMe: vi.fn(async () => false), })); vi.mock("../../../src/client/InGameModal", () => ({ @@ -377,4 +379,42 @@ describe("AccountSettingsPanel — privacy card (hide my level)", () => { // takes over from there. expect(showInGameAlert).not.toHaveBeenCalled(); }); + + it("refreshes the viewer's own-badge fallback once the change is saved", async () => { + const { getUserMe } = await import("../../../src/client/Api"); + const player = makePlayer({ progress, levelHidden: false }); + // getUserMe() memoises the same profile object the panel shows. + vi.mocked(getUserMe).mockResolvedValue({ + user: {}, + player, + } as unknown as UserMeResponse); + await show(player); + expect(ownHiddenLevelBadge()).toBeUndefined(); + + levelSwitch()!.click(); + await settle(); + expect(ownHiddenLevelBadge()).toEqual({ + level: 96, + prestige: 3, + legend: false, + }); + + levelSwitch()!.click(); + await settle(); + expect(ownHiddenLevelBadge()).toBeUndefined(); + }); + + it("leaves the fallback alone when the change fails", async () => { + const { getUserMe, setLevelVisibility } = + await import("../../../src/client/Api"); + vi.mocked(setLevelVisibility).mockResolvedValueOnce({ + ok: false, + code: "failed", + }); + await show(makePlayer({ progress, levelHidden: false })); + + levelSwitch()!.click(); + await settle(); + expect(getUserMe).not.toHaveBeenCalled(); + }); }); diff --git a/tests/client/components/LobbyPlayerView.levelBadge.test.ts b/tests/client/components/LobbyPlayerView.levelBadge.test.ts index d54bf4a0c53..480811f77e6 100644 --- a/tests/client/components/LobbyPlayerView.levelBadge.test.ts +++ b/tests/client/components/LobbyPlayerView.levelBadge.test.ts @@ -18,6 +18,8 @@ const getUserMe = vi.hoisted(() => ); vi.mock("../../../src/client/Api", () => ({ getUserMe })); +type LevelBadgeEl = HTMLElement & { level: number }; + const BADGE: LevelBadge = { level: 42, prestige: 2, legend: false }; // As the roster carries it. const BADGE_WIRE = packLevelBadge(BADGE)!; @@ -340,18 +342,38 @@ describe("lobby level badges", () => { expect(pills(view)[0]).toEqual(["badge:24", "me"]); }); - it("reads /users/@me once per lobby, not on every roster update", async () => { - signedIn(true); + it("follows a visibility change on the next roster update", async () => { + // One cached profile, as getUserMe() memoises it: the account settings + // toggle flips levelHidden on that same object. + const me = { + user: {}, + player: { publicId: "me-pub", progress, levelHidden: false }, + }; + getUserMe.mockResolvedValue(me as unknown as UserMeResponse); const view = await mount({ currentClientID: "me", - clients: [client("me")], + clients: [client("me", { levelBadge: BADGE_WIRE })], }); await settle(view); - for (let i = 0; i < 3; i++) { - view.clients = [client("me"), client(`p${i}`)]; - await settle(view); - } - expect(getUserMe).toHaveBeenCalledTimes(1); + expect((view.querySelector("level-badge") as LevelBadgeEl).level).toBe( + 42, + ); + + // Hidden: the server now sends no badge for them; their own shows. + me.player.levelHidden = true; + view.clients = [client("me"), client("guest")]; + await settle(view); + expect(pills(view)).toEqual([["badge:24", "me"], ["guest"]]); + expect((view.querySelector("level-badge") as LevelBadgeEl).level).toBe( + 88, + ); + + // Shown again, before the server's roster carries it back: no + // fallback badge lingers. + me.player.levelHidden = false; + view.clients = [client("me"), client("guest2")]; + await settle(view); + expect(view.querySelector("level-badge")).toBeNull(); }); }); }); From 1eaacc8b284c631dd53254ad80f298eedef311b4 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 17:52:40 +0100 Subject: [PATCH 26/30] Pin levelBadge as ClientInfo's last field Its presence bit is the last bit of the one-byte header, so a field added after it would break pre-badge frames. Co-Authored-By: Claude Opus 5.5 --- tests/core/ClientInfoLevelBadgeWire.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/core/ClientInfoLevelBadgeWire.test.ts b/tests/core/ClientInfoLevelBadgeWire.test.ts index 860ce5784bc..d1c48e26530 100644 --- a/tests/core/ClientInfoLevelBadgeWire.test.ts +++ b/tests/core/ClientInfoLevelBadgeWire.test.ts @@ -59,6 +59,13 @@ const FULL: ClientInfo = { }; describe("ClientInfo.levelBadge on the wire", () => { + it("stays the last field of ClientInfo", () => { + // Its presence bit is the last of the one-byte header; a field after it + // would move it and break pre-badge frames. + const keys = Object.keys(ClientInfoSchema.shape); + expect(keys[keys.length - 1]).toBe("levelBadge"); + }); + it.each([ { level: 1, prestige: 0, legend: false }, { level: 37, prestige: 2, legend: false }, From 53ff155014d8cb69114ff515e441735108314983 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 17:52:51 +0100 Subject: [PATCH 27/30] Move the lobby badge perf harnesses to their own PR They measure #5763's roster badges (now on main), not the level opt-out. Co-Authored-By: Claude Opus 5.5 --- tests/perf/LobbyInfoWirePerf.ts | 180 -------------- .../perf/client/LobbyPlayerViewRenderPerf.ts | 219 ------------------ 2 files changed, 399 deletions(-) delete mode 100644 tests/perf/LobbyInfoWirePerf.ts delete mode 100644 tests/perf/client/LobbyPlayerViewRenderPerf.ts diff --git a/tests/perf/LobbyInfoWirePerf.ts b/tests/perf/LobbyInfoWirePerf.ts deleted file mode 100644 index 96f0502962d..00000000000 --- a/tests/perf/LobbyInfoWirePerf.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Progress } from "@openfront/shared/ApiSchemas"; -import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; -import { - ClientInfo, - ServerLobbyInfoMessage, - ServerMessage, -} from "@openfront/shared/WireSchemas"; -import { - decodeServerMessage, - encodeServerMessage, -} from "@openfront/shared/ZbinWire"; -import Benchmark from "benchmark"; -import * as LevelBadgeModule from "../../src/server/LevelBadge"; -import { testGameConfig } from "../util/Wire"; - -/** - * Cost of the lobby roster's level badges on the lobby_info broadcast. - * - * While a game is in its lobby the server sends every connected client a - * lobby_info frame once a second (GameServer.broadcastLobbyInfo), encoding it - * once PER RECIPIENT — so one broadcast of an N-player lobby is N encodes of - * an N-entry roster. This measures, for 10 / 50 / 125 / 150 clients: - * - * - the encoded frame size with no badges and with a badge on every entry - * (the worst case: everyone signed in, progression on, nobody hidden); - * - the time to encode one frame, and so one whole broadcast (x N); - * - the time a client takes to decode one frame. - * - * Plus a microbenchmark of the join-time badge stamping (levelBadgeFromProgress, - * and levelBadgeForPlayer with the "hide my level" check when present). - * - * The lobby is not yet started, so the server encodes with no zbin context - * (zbinCtx is undefined until start): clientIDs go out as plain strings. - * - * Usage: npx tsx tests/perf/LobbyInfoWirePerf.ts - */ - -// Deterministic data: a tiny LCG, so every run (and branch) encodes the same -// rosters. -let seed = 0x5eed; -function rand(): number { - seed = (seed * 1664525 + 1013904223) >>> 0; - return seed / 0x100000000; -} -const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; -function word(len: number): string { - let s = ""; - for (let i = 0; i < len; i++) s += ALNUM[Math.floor(rand() * ALNUM.length)]; - return s; -} - -function roster(n: number, withBadges: boolean): ClientInfo[] { - seed = 0x5eed + n; - const ids = Array.from({ length: n }, () => word(8)); - return ids.map((clientID, i) => { - const c: ClientInfo = { - clientID, - username: word(6 + Math.floor(rand() * 9)), - // Roughly a third in a clan, a quarter verified, a few friends here and - // there — the shape NameVisibility.lobbyClients sends with names on. - clanTag: rand() < 0.33 ? word(2 + Math.floor(rand() * 4)) : null, - }; - if (rand() < 0.25) c.verified = true; - if (rand() < 0.1) c.friends = [ids[(i + 1) % n]]; - // Drawn for every entry either way, so both rosters consume the same - // random numbers and differ ONLY in the badges. - const badge = - rand() < 0.01 - ? { level: 100, prestige: 10, legend: true } - : { - level: 1 + Math.floor(rand() * 100), - prestige: Math.floor(rand() * 11), - legend: false, - }; - // The roster carries a badge packed into one integer. - if (withBadges) c.levelBadge = packLevelBadge(badge); - return c; - }); -} - -function lobbyInfo(clients: ClientInfo[]): ServerMessage { - return { - type: "lobby_info", - lobby: { - gameID: "AbCdEfGh", - clients, - lobbyCreatorClientID: clients[0]?.clientID, - serverTime: 1_790_000_000_000, - gameConfig: testGameConfig({ maxPlayers: 150 }), - }, - myClientID: clients[0]?.clientID ?? "AbCdEfGh", - groupToken: "0123456789abcdef0123456789abcdef", - } satisfies ServerLobbyInfoMessage; -} - -function fmtUs(seconds: number): string { - return seconds < 1e-6 - ? `${(seconds * 1e9).toFixed(1)} ns` - : `${(seconds * 1e6).toFixed(2)} µs`; -} - -function bench(name: string, fn: () => void): Benchmark { - const b = new Benchmark(name, fn, { minSamples: 40 }); - b.run({ async: false }); - return b; -} - -console.log("=== lobby_info frame size (bytes) ==="); -console.log("clients | no badges | all badges | delta | delta/entry"); -for (const n of [10, 50, 125, 150]) { - const plain = encodeServerMessage(lobbyInfo(roster(n, false)), undefined); - const badged = encodeServerMessage(lobbyInfo(roster(n, true)), undefined); - const delta = badged.byteLength - plain.byteLength; - console.log( - `${String(n).padStart(7)} | ${String(plain.byteLength).padStart(9)} | ${String( - badged.byteLength, - ).padStart(10)} | ${String(delta).padStart(5)} | ${(delta / n).toFixed(2)}`, - ); -} - -console.log("\n=== encode / decode time (benchmark.js, mean ± rme) ==="); -console.log( - "clients | badges | encode 1 frame | broadcast (x N) | decode 1 frame", -); -for (const n of [10, 50, 125, 150]) { - for (const withBadges of [false, true]) { - const msg = lobbyInfo(roster(n, withBadges)); - const bytes = encodeServerMessage(msg, undefined); - const enc = bench(`encode ${n} ${withBadges}`, () => { - encodeServerMessage(msg, undefined); - }); - const dec = bench(`decode ${n} ${withBadges}`, () => { - decodeServerMessage(bytes, undefined); - }); - console.log( - `${String(n).padStart(7)} | ${(withBadges ? "yes" : "no").padStart(6)} | ${fmtUs( - enc.stats.mean, - ).padStart(10)} ±${enc.stats.rme.toFixed(1)}% | ${( - (enc.stats.mean * n * 1e3).toFixed(3) + " ms" - ).padStart( - 15, - )} | ${fmtUs(dec.stats.mean).padStart(10)} ±${dec.stats.rme.toFixed(1)}%`, - ); - } -} - -console.log("\n=== join-time badge stamping (per join) ==="); -const progress: Progress = { - prestige: 3, - level: 57, - xpInLevel: 120, - xpForNext: 900, - lifetimeXp: 1_234_567, - legend: false, - canPrestige: false, -}; -let sink: unknown; -const fromProgress = bench("levelBadgeFromProgress", () => { - sink = LevelBadgeModule.levelBadgeFromProgress(progress); -}); -console.log( - `levelBadgeFromProgress(progress) ${fmtUs(fromProgress.stats.mean)} ±${fromProgress.stats.rme.toFixed(1)}%`, -); -// levelBadgeForPlayer only exists once "hide my level" is in (level-optout). -const forPlayer = (LevelBadgeModule as Record) - .levelBadgeForPlayer as - | ((p: { progress?: Progress; levelHidden?: boolean }) => unknown) - | undefined; -if (forPlayer !== undefined) { - for (const levelHidden of [undefined, false, true]) { - const player = { progress, levelHidden }; - const b = bench(`levelBadgeForPlayer ${levelHidden}`, () => { - sink = forPlayer(player); - }); - console.log( - `levelBadgeForPlayer({ levelHidden: ${String(levelHidden).padEnd(9)} }) ${fmtUs(b.stats.mean)} ±${b.stats.rme.toFixed(1)}%`, - ); - } -} -void sink; diff --git a/tests/perf/client/LobbyPlayerViewRenderPerf.ts b/tests/perf/client/LobbyPlayerViewRenderPerf.ts deleted file mode 100644 index f0f334e3625..00000000000 --- a/tests/perf/client/LobbyPlayerViewRenderPerf.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * render cost with ~150 clients, with and without level - * badges. - * - * The lobby list re-renders on every lobby_info frame (once a second): each - * frame decodes into a brand-new ClientInfo[] of brand-new objects, so this - * times both the first paint and that steady-state "same roster, new objects" - * update, which is what the player pays every second while waiting. - * - * Scenarios (150 clients): - * - FFA pills, no badges / every player badged; - * - team mode (Players column + team cards), no badges / every player - * badged / half badged (the other half get the empty alignment slot); - * - and a 1-in-150 change (one player joins) on top of a badged roster. - * - * Runs under jsdom, so absolute numbers are jsdom's (no layout or paint) and - * only the relative cost is meaningful. Reports median / p95 over many runs - * after a warm-up. - * - * Usage: npx tsx tests/perf/client/LobbyPlayerViewRenderPerf.ts - * (RUNS=n to change the sample count, LOBBY_PERF_SRC=/src - * to time another branch's component with the same harness) - */ -import { readFileSync } from "fs"; -// @ts-expect-error -- jsdom ships no types and @types/jsdom is not a dependency -import { JSDOM } from "jsdom"; -import { performance } from "perf_hooks"; -import { pathToFileURL } from "url"; - -const dom = new JSDOM("", { - url: "http://localhost/", - pretendToBeVisual: true, -}); -// Expose jsdom's window as the global scope, like vitest's jsdom environment: -// every window property Node doesn't already have, plus the few it has its -// own versions of. -const w = dom.window as unknown as Record; -const OVERRIDE = new Set(["navigator", "localStorage", "sessionStorage"]); -for (const key of Object.getOwnPropertyNames(w)) { - if (key in globalThis && !OVERRIDE.has(key)) continue; - Object.defineProperty(globalThis, key, { - configurable: true, - writable: true, - value: w[key], - }); -} -Object.defineProperty(globalThis, "window", { value: globalThis }); - -const RUNS = Number(process.env.RUNS ?? 100); -const WARMUP = 20; -// Point at another checkout's src/ to compare branches, e.g. -// LOBBY_PERF_SRC=C:/dev/OpenFrontIO/.worktrees/roster-levels/src -const SRC = process.env.LOBBY_PERF_SRC - ? pathToFileURL(process.env.LOBBY_PERF_SRC.replace(/\/?$/, "/")).href - : new URL("../../../src/", import.meta.url).href; - -interface Client { - clientID: string; - username: string; - clanTag: string | null; - verified?: boolean; - levelBadge?: { level: number; prestige: number; legend: boolean }; -} - -function roster(n: number, badged: (i: number) => boolean): Client[] { - return Array.from({ length: n }, (_, i) => { - const c: Client = { - clientID: `c${String(i).padStart(7, "0")}`, - username: `Player${i}`, - clanTag: i % 3 === 0 ? "CLAN" : null, - }; - if (i % 4 === 0) c.verified = true; - if (badged(i)) { - c.levelBadge = { - level: 1 + ((i * 37) % 100), - prestige: i % 11, - legend: i === 7, - }; - } - return c; - }); -} - -// A lobby_info frame decodes to fresh objects every time. -const fresh = (clients: Client[]): Client[] => - clients.map((c) => ({ - ...c, - levelBadge: c.levelBadge ? { ...c.levelBadge } : undefined, - })); - -function stats(samples: number[]): string { - const s = [...samples].sort((a, b) => a - b); - const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))]; - return `median ${at(0.5).toFixed(3)} ms p95 ${at(0.95).toFixed(3)} ms`; -} - -// translateText() looks the up once and caches it; without one -// in the page it re-runs document.querySelector on EVERY call, which scans the -// whole lobby list and swamps the timings. The real page always has one, so -// stand one in with the English strings. -function installLangSelector() { - const flat: Record = {}; - const walk = (obj: Record, prefix: string) => { - for (const [k, v] of Object.entries(obj)) { - if (typeof v === "string") flat[prefix + k] = v; - else walk(v as Record, `${prefix}${k}.`); - } - }; - walk( - JSON.parse( - readFileSync( - new URL("../../../resources/lang/en.json", import.meta.url), - "utf8", - ), - ), - "", - ); - const sel = document.createElement("lang-selector") as HTMLElement & { - currentLang: string; - translations: Record; - defaultTranslations: Record; - }; - sel.currentLang = "en"; - sel.translations = flat; - sel.defaultTranslations = flat; - document.body.append(sel); -} - -async function main() { - installLangSelector(); - const { GameMode } = await import("@openfront/engine-api/game/GameTypes"); - await import(SRC + "client/components/LobbyPlayerView.ts"); - - type View = HTMLElement & { - gameMode: unknown; - clients: Client[]; - currentClientID: string; - teamCount: unknown; - updateComplete: Promise; - }; - - async function mount(mode: unknown, clients: Client[]): Promise { - const view = document.createElement("lobby-player-view") as View; - view.gameMode = mode; - view.teamCount = 2; - view.currentClientID = clients[0].clientID; - view.clients = clients; - document.body.append(view); - await view.updateComplete; - return view; - } - - async function firstPaint(mode: unknown, clients: Client[]) { - const samples: number[] = []; - for (let i = 0; i < WARMUP + RUNS; i++) { - const t0 = performance.now(); - const view = await mount(mode, fresh(clients)); - const t1 = performance.now(); - view.remove(); - if (i >= WARMUP) samples.push(t1 - t0); - } - return stats(samples); - } - - async function update(mode: unknown, a: Client[], b: Client[] = a) { - const view = await mount(mode, fresh(a)); - const samples: number[] = []; - for (let i = 0; i < WARMUP + RUNS; i++) { - const next = fresh(i % 2 === 0 ? b : a); - const t0 = performance.now(); - view.clients = next; - await view.updateComplete; - const t1 = performance.now(); - if (i >= WARMUP) samples.push(t1 - t0); - } - view.remove(); - return stats(samples); - } - - const N = 150; - const none = roster(N, () => false); - const all = roster(N, () => true); - const half = roster(N, (i) => i % 2 === 0); - const allPlusOne = [...all, ...roster(N + 1, () => true).slice(N)]; - - const rows: [string, () => Promise][] = [ - ["FFA first paint, no badges ", () => firstPaint(GameMode.FFA, none)], - ["FFA first paint, all badged ", () => firstPaint(GameMode.FFA, all)], - ["FFA update, no badges ", () => update(GameMode.FFA, none)], - ["FFA update, all badged ", () => update(GameMode.FFA, all)], - [ - "FFA update, +1 join badged", - () => update(GameMode.FFA, all, allPlusOne), - ], - ["Team first paint, no badges ", () => firstPaint(GameMode.Team, none)], - ["Team first paint, all badged ", () => firstPaint(GameMode.Team, all)], - ["Team first paint, half (slots) ", () => firstPaint(GameMode.Team, half)], - ["Team update, no badges ", () => update(GameMode.Team, none)], - ["Team update, all badged ", () => update(GameMode.Team, all)], - ["Team update, half (slots) ", () => update(GameMode.Team, half)], - ]; - console.log( - `=== , ${N} clients, jsdom, ${RUNS} runs after ${WARMUP} warm-up ===`, - ); - // ONLY= runs just the matching rows (e.g. ONLY="first paint"). - const only = process.env.ONLY; - for (const [name, run] of rows) { - if (only !== undefined && !name.includes(only)) continue; - console.log(`${name} ${await run()}`); - } -} - -main().then( - () => process.exit(0), - (e) => { - console.error(e); - process.exit(1); - }, -); From 61c86ac38ed66f1fa5458c5cf8fdfd4db10cb57e Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 18:19:01 +0100 Subject: [PATCH 28/30] Own level badge: only the latest refresh writes the cache A refresh still waiting on an older /users/@me (from before a visibility change) could answer last and overwrite the newer badge. One counter, bumped by every refresh and on session-cleared, now decides which answer stands. Co-Authored-By: Claude Opus 5.5 --- src/client/OwnLevelBadge.ts | 14 +++++++------- tests/client/OwnLevelBadge.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/client/OwnLevelBadge.ts b/src/client/OwnLevelBadge.ts index d99c5a2e022..4b2097d484d 100644 --- a/src/client/OwnLevelBadge.ts +++ b/src/client/OwnLevelBadge.ts @@ -24,9 +24,10 @@ export function ownHiddenLevelBadgeFrom( } let cached: LevelBadge | undefined; -// Bumped when the session is cleared, so a refresh still waiting on the old -// account's /users/@me can't write its badge back after the clear. -let session = 0; +// Bumped by every refresh and when the session is cleared: only the latest +// refresh writes the cache. One still waiting on an older /users/@me (from +// before a visibility change, or the old account's after a clear) leaves it. +let generation = 0; // The last value refreshOwnHiddenLevelBadge() found, for synchronous readers // (the in-game player panel). @@ -39,14 +40,13 @@ export function ownHiddenLevelBadge(): LevelBadge | undefined { export async function refreshOwnHiddenLevelBadge(): Promise< LevelBadge | undefined > { - const forSession = session; + const forGeneration = ++generation; const badge = ownHiddenLevelBadgeFrom(await getUserMe()); - // A stale answer (the session was cleared meanwhile) leaves the cache be. - if (forSession === session) cached = badge; + if (forGeneration === generation) cached = badge; return cached; } document.addEventListener("session-cleared", () => { - session++; + generation++; cached = undefined; }); diff --git a/tests/client/OwnLevelBadge.test.ts b/tests/client/OwnLevelBadge.test.ts index e5c8c90d42d..ebc82b123bf 100644 --- a/tests/client/OwnLevelBadge.test.ts +++ b/tests/client/OwnLevelBadge.test.ts @@ -82,4 +82,17 @@ describe("refreshOwnHiddenLevelBadge", () => { expect(await pending).toBeUndefined(); expect(ownHiddenLevelBadge()).toBeUndefined(); }); + + it("keeps the latest refresh when an older one answers last", async () => { + // Hidden, then shown again: the refresh for the change answers first, the + // one from before it last, with the old setting. + let older!: (me: UserMeResponse | false) => void; + getUserMe.mockReturnValueOnce(new Promise((r) => (older = r))); + getUserMe.mockResolvedValueOnce(me({ progress, levelHidden: false })); + const first = refreshOwnHiddenLevelBadge(); + await refreshOwnHiddenLevelBadge(); + older(me({ progress, levelHidden: true })); + await first; + expect(ownHiddenLevelBadge()).toBeUndefined(); + }); }); From 9e663f58b06d8ae42e5ec20167d29f10575c1ba5 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Mon, 12 Oct 2026 13:08:41 +0100 Subject: [PATCH 29/30] Privacy settings: drop old-API fallbacks The API always sends levelHidden and searchHidden now, so the Privacy card always shows both rows rather than hiding the card or a row when /users/@me lacks the setting. Should one still be absent it shows as on, as the game server reads it, and toggling it off works. The schema keeps .optional().catch(true): a malformed value still reads as hidden. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 16 +++-- src/client/components/AccountSettingsPanel.ts | 61 ++++++++----------- .../components/AccountSettingsPanel.test.ts | 44 ++++++------- 3 files changed, 56 insertions(+), 65 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index a148d961f86..1c52566edc4 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -436,18 +436,16 @@ export const UserMeResponseSchema = z.object({ 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. + // then leaves the badge off their lobby roster entry. The API always + // sends it. 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), // "Show my profile in search engines" turned off: the public profile page // (openfront.io/player/) is served noindex. It still opens for - // anyone with the link, and link previews are unchanged. Optional so an - // older API without the setting still parses — absent hides the row in - // account settings. A malformed value reads as hidden, like levelHidden. + // anyone with the link, and link previews are unchanged. The API always + // sends it. A malformed value reads as hidden, like levelHidden. searchHidden: z.boolean().optional().catch(true), clans: z .array( diff --git a/src/client/components/AccountSettingsPanel.ts b/src/client/components/AccountSettingsPanel.ts index 206f30783ee..eaefb73cb1f 100644 --- a/src/client/components/AccountSettingsPanel.ts +++ b/src/client/components/AccountSettingsPanel.ts @@ -160,15 +160,14 @@ export class AccountSettingsPanel extends LitElement { } // Privacy: "Show my level to other players" and "Show my profile in search - // engines". Each row only when /users/@me carries its setting at all, and - // the card only when it carries either — an older API without them gets no - // card. + // engines". /users/@me always carries both settings (a malformed one reads + // as hidden); one that is somehow absent shows as on, as the game server + // reads it. private renderPrivacyCard(): TemplateResult | typeof nothing { - const levelHidden = this.player?.levelHidden; - const searchHidden = this.player?.searchHidden; - if (levelHidden === undefined && searchHidden === undefined) { - return nothing; - } + const player = this.player; + if (!player) return nothing; + const levelShown = player.levelHidden !== true; + const searchShown = player.searchHidden !== true; return html`
${translateText("account_modal.privacy_title")}
- ${levelHidden === undefined - ? nothing - : html`${this.renderPrivacySwitchRow( - translateText("account_modal.level_visibility_title"), - translateText("account_modal.level_visibility_desc"), - !levelHidden, - this.privacyBusy.levelHidden, - (shown) => this.setPrivacyShown("levelHidden", shown), - )} - ${this.renderLevelPreview(!levelHidden)}`} - ${searchHidden === undefined - ? nothing - : html`
- ${this.renderPrivacySwitchRow( - translateText("account_modal.search_visibility_title"), - translateText("account_modal.search_visibility_desc"), - !searchHidden, - this.privacyBusy.searchHidden, - (shown) => this.setPrivacyShown("searchHidden", shown), - )} -
`} + ${this.renderPrivacySwitchRow( + translateText("account_modal.level_visibility_title"), + translateText("account_modal.level_visibility_desc"), + levelShown, + this.privacyBusy.levelHidden, + (shown) => this.setPrivacyShown("levelHidden", shown), + )} + ${this.renderLevelPreview(levelShown)} +
+ ${this.renderPrivacySwitchRow( + translateText("account_modal.search_visibility_title"), + translateText("account_modal.search_visibility_desc"), + searchShown, + this.privacyBusy.searchHidden, + (shown) => this.setPrivacyShown("searchHidden", shown), + )} +
`; } @@ -447,9 +438,9 @@ export class AccountSettingsPanel extends LitElement { shown: boolean, ): Promise { const player = this.player; - if (!player || player[field] === undefined) return; + if (!player) return; if (this.privacyBusy[field]) return; - const previous = player[field]; + const previous = player[field] === true; const hidden = !shown; if (previous === hidden) return; diff --git a/tests/client/components/AccountSettingsPanel.test.ts b/tests/client/components/AccountSettingsPanel.test.ts index 84cbe2d4619..09353377f5e 100644 --- a/tests/client/components/AccountSettingsPanel.test.ts +++ b/tests/client/components/AccountSettingsPanel.test.ts @@ -73,9 +73,10 @@ describe("AccountSettingsPanel — marketing-consent card", () => { await panel.updateComplete; } + // The email-updates switch (the Privacy card below has its own). function findSwitch(): HTMLButtonElement | null { return panel.querySelector( - 'button[role="switch"]', + 'button[role="switch"][aria-label="account_modal.marketing_title"]', ) as HTMLButtonElement | null; } @@ -222,11 +223,11 @@ describe("AccountSettingsPanel — privacy card (hide my level)", () => { ) as HTMLButtonElement | null; const preview = () => panel.querySelector(".level-visibility-preview"); - it("renders no card when /users/@me has no levelHidden (older API)", async () => { + it("shows the switch on, as the game server reads it, should levelHidden be absent", async () => { await show(makePlayer({ progress, username: "Kestrel" })); - expect(panel.textContent).not.toContain("account_modal.privacy_title"); - expect(levelSwitch()).toBeNull(); + expect(panel.textContent).toContain("account_modal.privacy_title"); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); }); it("sits between the email-updates card and the delete card", async () => { @@ -460,16 +461,6 @@ describe("AccountSettingsPanel — privacy card (search engines)", () => { 'button[role="switch"][aria-label="account_modal.level_visibility_title"]', ) as HTMLButtonElement | null; - it("renders no search row when /users/@me has no searchHidden (older API)", async () => { - await show(makePlayer({ levelHidden: false })); - - expect(levelSwitch()).toBeTruthy(); - expect(searchSwitch()).toBeNull(); - expect(panel.textContent).not.toContain( - "account_modal.search_visibility_title", - ); - }); - it("sits below the level row, under a divider", async () => { await show(makePlayer({ levelHidden: false, searchHidden: false })); @@ -484,14 +475,25 @@ describe("AccountSettingsPanel — privacy card (search engines)", () => { expect(row!.contains(levelSwitch())).toBe(false); }); - it("shows the card with only the search row when levelHidden is absent", async () => { - await show(makePlayer({ searchHidden: false })); + it("shows both rows, the absent one on, should either setting be absent", async () => { + await show(makePlayer({ searchHidden: true })); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); - expect(panel.textContent).toContain("account_modal.privacy_title"); - expect(searchSwitch()).toBeTruthy(); - expect(levelSwitch()).toBeNull(); - // Nothing above it to divide from. - expect(searchSwitch()!.closest("div.border-t")).toBeNull(); + await show(makePlayer({ levelHidden: true })); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + }); + + it("turning off a setting that was absent PUTs hidden=true", async () => { + const { setSearchVisibility } = await import("../../../src/client/Api"); + const player = makePlayer({ levelHidden: false }); + await show(player); + + searchSwitch()!.click(); + await settle(); + expect(setSearchVisibility).toHaveBeenCalledWith(true); + expect(player.searchHidden).toBe(true); }); it("is on when shown in search (the default) and off when hidden", async () => { From d9443c592b5c2dcd94cdc10b1eb29b9e26ffad5f Mon Sep 17 00:00:00 2001 From: iamlewis Date: Mon, 12 Oct 2026 13:18:36 +0100 Subject: [PATCH 30/30] Privacy settings: keep the guard for an API without the settings Reverts 9e663f58b. The API always sends levelHidden and searchHidden once it is deployed, but the client can reach players first; without the guard the switches would show and saving would fail with an error. The card again needs levelHidden and the search row needs searchHidden. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 16 ++--- src/client/components/AccountSettingsPanel.ts | 61 +++++++++++-------- .../components/AccountSettingsPanel.test.ts | 44 +++++++------ 3 files changed, 65 insertions(+), 56 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 1c52566edc4..a148d961f86 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -436,16 +436,18 @@ export const UserMeResponseSchema = z.object({ 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. The API always - // sends it. 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. + // 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), // "Show my profile in search engines" turned off: the public profile page // (openfront.io/player/) is served noindex. It still opens for - // anyone with the link, and link previews are unchanged. The API always - // sends it. A malformed value reads as hidden, like levelHidden. + // anyone with the link, and link previews are unchanged. Optional so an + // older API without the setting still parses — absent hides the row in + // account settings. A malformed value reads as hidden, like levelHidden. searchHidden: z.boolean().optional().catch(true), clans: z .array( diff --git a/src/client/components/AccountSettingsPanel.ts b/src/client/components/AccountSettingsPanel.ts index eaefb73cb1f..206f30783ee 100644 --- a/src/client/components/AccountSettingsPanel.ts +++ b/src/client/components/AccountSettingsPanel.ts @@ -160,14 +160,15 @@ export class AccountSettingsPanel extends LitElement { } // Privacy: "Show my level to other players" and "Show my profile in search - // engines". /users/@me always carries both settings (a malformed one reads - // as hidden); one that is somehow absent shows as on, as the game server - // reads it. + // engines". Each row only when /users/@me carries its setting at all, and + // the card only when it carries either — an older API without them gets no + // card. private renderPrivacyCard(): TemplateResult | typeof nothing { - const player = this.player; - if (!player) return nothing; - const levelShown = player.levelHidden !== true; - const searchShown = player.searchHidden !== true; + const levelHidden = this.player?.levelHidden; + const searchHidden = this.player?.searchHidden; + if (levelHidden === undefined && searchHidden === undefined) { + return nothing; + } return html`
${translateText("account_modal.privacy_title")}
- ${this.renderPrivacySwitchRow( - translateText("account_modal.level_visibility_title"), - translateText("account_modal.level_visibility_desc"), - levelShown, - this.privacyBusy.levelHidden, - (shown) => this.setPrivacyShown("levelHidden", shown), - )} - ${this.renderLevelPreview(levelShown)} -
- ${this.renderPrivacySwitchRow( - translateText("account_modal.search_visibility_title"), - translateText("account_modal.search_visibility_desc"), - searchShown, - this.privacyBusy.searchHidden, - (shown) => this.setPrivacyShown("searchHidden", shown), - )} -
+ ${levelHidden === undefined + ? nothing + : html`${this.renderPrivacySwitchRow( + translateText("account_modal.level_visibility_title"), + translateText("account_modal.level_visibility_desc"), + !levelHidden, + this.privacyBusy.levelHidden, + (shown) => this.setPrivacyShown("levelHidden", shown), + )} + ${this.renderLevelPreview(!levelHidden)}`} + ${searchHidden === undefined + ? nothing + : html`
+ ${this.renderPrivacySwitchRow( + translateText("account_modal.search_visibility_title"), + translateText("account_modal.search_visibility_desc"), + !searchHidden, + this.privacyBusy.searchHidden, + (shown) => this.setPrivacyShown("searchHidden", shown), + )} +
`}
`; } @@ -438,9 +447,9 @@ export class AccountSettingsPanel extends LitElement { shown: boolean, ): Promise { const player = this.player; - if (!player) return; + if (!player || player[field] === undefined) return; if (this.privacyBusy[field]) return; - const previous = player[field] === true; + const previous = player[field]; const hidden = !shown; if (previous === hidden) return; diff --git a/tests/client/components/AccountSettingsPanel.test.ts b/tests/client/components/AccountSettingsPanel.test.ts index 09353377f5e..84cbe2d4619 100644 --- a/tests/client/components/AccountSettingsPanel.test.ts +++ b/tests/client/components/AccountSettingsPanel.test.ts @@ -73,10 +73,9 @@ describe("AccountSettingsPanel — marketing-consent card", () => { await panel.updateComplete; } - // The email-updates switch (the Privacy card below has its own). function findSwitch(): HTMLButtonElement | null { return panel.querySelector( - 'button[role="switch"][aria-label="account_modal.marketing_title"]', + 'button[role="switch"]', ) as HTMLButtonElement | null; } @@ -223,11 +222,11 @@ describe("AccountSettingsPanel — privacy card (hide my level)", () => { ) as HTMLButtonElement | null; const preview = () => panel.querySelector(".level-visibility-preview"); - it("shows the switch on, as the game server reads it, should levelHidden be absent", async () => { + it("renders no card when /users/@me has no levelHidden (older API)", async () => { await show(makePlayer({ progress, username: "Kestrel" })); - expect(panel.textContent).toContain("account_modal.privacy_title"); - expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(panel.textContent).not.toContain("account_modal.privacy_title"); + expect(levelSwitch()).toBeNull(); }); it("sits between the email-updates card and the delete card", async () => { @@ -461,6 +460,16 @@ describe("AccountSettingsPanel — privacy card (search engines)", () => { 'button[role="switch"][aria-label="account_modal.level_visibility_title"]', ) as HTMLButtonElement | null; + it("renders no search row when /users/@me has no searchHidden (older API)", async () => { + await show(makePlayer({ levelHidden: false })); + + expect(levelSwitch()).toBeTruthy(); + expect(searchSwitch()).toBeNull(); + expect(panel.textContent).not.toContain( + "account_modal.search_visibility_title", + ); + }); + it("sits below the level row, under a divider", async () => { await show(makePlayer({ levelHidden: false, searchHidden: false })); @@ -475,25 +484,14 @@ describe("AccountSettingsPanel — privacy card (search engines)", () => { expect(row!.contains(levelSwitch())).toBe(false); }); - it("shows both rows, the absent one on, should either setting be absent", async () => { - await show(makePlayer({ searchHidden: true })); - expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); - expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); - - await show(makePlayer({ levelHidden: true })); - expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); - expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); - }); - - it("turning off a setting that was absent PUTs hidden=true", async () => { - const { setSearchVisibility } = await import("../../../src/client/Api"); - const player = makePlayer({ levelHidden: false }); - await show(player); + it("shows the card with only the search row when levelHidden is absent", async () => { + await show(makePlayer({ searchHidden: false })); - searchSwitch()!.click(); - await settle(); - expect(setSearchVisibility).toHaveBeenCalledWith(true); - expect(player.searchHidden).toBe(true); + expect(panel.textContent).toContain("account_modal.privacy_title"); + expect(searchSwitch()).toBeTruthy(); + expect(levelSwitch()).toBeNull(); + // Nothing above it to divide from. + expect(searchSwitch()!.closest("div.border-t")).toBeNull(); }); it("is on when shown in search (the default) and off when hidden", async () => {