From 53b26e0c4e956842401898db2b1f613bb6324c89 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 1 Oct 2026 00:16:58 +0100 Subject: [PATCH 01/62] 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/62] 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 e7ef291e29ef8b873a425e56a0200d949d453ccb Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 1 Oct 2026 12:59:46 +0100 Subject: [PATCH 03/62] Add profile cards and the prestige flow A profile card heads the Stats tab and public profiles, with a compact row on the Account tab. At level 100 your own card offers Prestige: a full-screen confirmation (the new emblem, the prestige track, what it unlocks), held down to confirm, then a ceremony on the menu background. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 21 +- src/client/AccountModal.ts | 65 +- src/client/PlayerProfileModal.ts | 50 +- src/client/ProgressionApi.ts | 37 + src/client/components/LevelBadge.ts | 21 + src/client/components/PrestigeFlow.ts | 985 +++++++++++++++++++++++++ src/client/components/ProfileCard.ts | 331 +++++++++ src/core/ApiSchemas.ts | 13 + tests/client/PrestigeFlow.test.ts | 301 ++++++++ tests/client/ProfileCard.test.ts | 120 +++ tests/client/ProgressionModals.test.ts | 50 +- 11 files changed, 1933 insertions(+), 61 deletions(-) create mode 100644 src/client/components/PrestigeFlow.ts create mode 100644 src/client/components/ProfileCard.ts create mode 100644 tests/client/PrestigeFlow.test.ts create mode 100644 tests/client/ProfileCard.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 3394da3cbad..dc142304385 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1749,6 +1749,23 @@ "nation": "Nation", "player": "Player" }, + "prestige": { + "button": "Prestige", + "ceremony_title": "Prestige {rank}", + "confirm": "Hold to prestige", + "continue": "Continue", + "error": "Couldn't prestige. Nothing has changed, so try again.", + "gain_caps": "Caps bonus", + "gain_cosmetic": "Exclusive cosmetic", + "gain_emblem": "Prestige {rank} emblem", + "irreversible": "This can't be undone", + "keep_holding": "Keep holding…", + "ready": "Level 100! Until you prestige, XP only adds to your lifetime total.", + "submitting": "Prestiging…", + "summary": "You go back to level 1. You keep your lifetime stats, XP and cosmetics.", + "title": "Enter Prestige {rank}", + "unlocks": "Unlocks" + }, "private_lobby": { "disabled_units": "Disabled Units", "enter_id": "Enter Lobby ID", @@ -1775,6 +1792,9 @@ "title": "Join Lobby", "version_mismatch": "This game was created with a different version. Cannot join." }, + "profile_card": { + "lifetime_xp": "Lifetime XP" + }, "progression": { "awaiting_end": "XP is awarded when the game ends.", "bonus_line": "+{percent}% XP ({source})", @@ -1792,7 +1812,6 @@ "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", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 15431b5e32d..7c1a1e4c1b0 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -1,7 +1,11 @@ import { html, nothing, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; -import { PlayerStatsTree, UserMeResponse } from "../core/ApiSchemas"; +import { + PlayerStatsTree, + PrestigeResponse, + UserMeResponse, +} from "../core/ApiSchemas"; import { assetUrl } from "../core/AssetUrls"; import { hasLinkedIdentity } from "./AccountIdentity"; import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api"; @@ -28,6 +32,9 @@ import type { CreatorChangedDetail } from "./components/CreatorCodePanel"; import "./components/CurrencyDisplay"; import "./components/Difficulties"; import "./components/FriendsList"; +import "./components/PrestigeFlow"; +import type { PrestigeFlow } from "./components/PrestigeFlow"; +import "./components/ProfileCard"; import "./components/RewardsPanel"; import type { RewardsChangedDetail } from "./components/RewardsPanel"; import { googleLinkButton } from "./components/ui/GoogleLinkButton"; @@ -186,6 +193,7 @@ export class AccountModal extends BaseModal {
${this.renderTab(tab)}
+ `; } @@ -248,6 +256,7 @@ export class AccountModal extends BaseModal { } return html`
+ ${this.renderProfileCard("compact")}
+ ${this.renderProfileCard("compact")}
+ ${this.renderProfileCard("full")} + ${this.hasAnyStats() + ? html`` + : this.renderEmptyState("📊", translateText("account_modal.no_stats"))} `; } @@ -458,6 +465,44 @@ export class AccountModal extends BaseModal { this.requestUpdate(); }; + // Your level at a glance, the way a shared profile link shows it: one row + // on the account page, the full card heading the Stats tab. Nothing while + // progression is off. + private renderProfileCard( + variant: "full" | "compact", + ): TemplateResult | typeof nothing { + const player = this.userMeResponse?.player; + if (!player?.publicId || !player.progress) return nothing; + return html``; + } + + private handlePrestigeRequest = (): void => { + const progress = this.userMeResponse?.player?.progress; + if (!progress) return; + this.querySelector("prestige-flow")?.open(progress); + }; + + // The server has prestiged the player: show the new rank behind the + // ceremony, list the rewards it granted, and drop the cached /users/@me. + private handlePrestiged = (event: CustomEvent): void => { + if (!this.userMeResponse) return; + const player = this.userMeResponse.player; + player.progress = event.detail.progress; + if (event.detail.rewards.length > 0) { + player.rewards = [...(player.rewards ?? []), ...event.detail.rewards]; + } + invalidateUserMe(); + this.requestUpdate(); + }; + private renderCurrency(): TemplateResult { const currency = this.userMeResponse?.player?.currency; if (!currency) return html``; diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 690d6e97909..c7db813b813 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -13,12 +13,11 @@ import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/P import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; import "./components/clan/ClanCard"; -import "./components/LevelBadge"; import "./components/PlayerName"; +import "./components/ProfileCard"; 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"; @@ -218,51 +217,22 @@ export class PlayerProfileModal extends BaseModal { if (!this.profileLoaded()) { return this.renderNotFound(); } + // The card heads the stats; games and wins stay in the stats below it. return html` - ${this.renderLevel()} + ${this.progress === null + ? nothing + : html``} `; } - // 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 { diff --git a/src/client/ProgressionApi.ts b/src/client/ProgressionApi.ts index 14475e905e3..e065989f36a 100644 --- a/src/client/ProgressionApi.ts +++ b/src/client/ProgressionApi.ts @@ -1,6 +1,8 @@ import { type GameXpResponse, GameXpResponseSchema, + type PrestigeResponse, + PrestigeResponseSchema, type ProgressionConfig, ProgressionConfigSchema, type PublicProgress, @@ -131,6 +133,41 @@ export async function fetchPublicPlayerProgress( } } +// POST /users/@me/prestige — the opt-in reset at level 100. The idempotency +// key is per confirmation, so a retried request (a slow network, a double +// submit) prestiges once. Unlike the reads above this does surface failure: +// the player asked for it and must be told it didn't happen. +export async function prestigeMe( + idempotencyKey: string, +): Promise<{ ok: true; data: PrestigeResponse } | { ok: false }> { + try { + const authorization = await getAuthHeader(); + if (authorization === "") return { ok: false }; + const res = await fetch(`${getApiBase()}/users/@me/prestige`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: authorization, + "Idempotency-Key": idempotencyKey, + }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) { + console.warn("prestigeMe: unexpected status", res.status); + return { ok: false }; + } + const parsed = PrestigeResponseSchema.safeParse(await res.json()); + if (!parsed.success) { + console.warn("prestigeMe: Zod validation failed", parsed.error); + return { ok: false }; + } + return { ok: true, data: parsed.data }; + } catch (err) { + console.warn("prestigeMe: request failed", err); + return { ok: false }; + } +} + let progressionConfig: Promise | null = null; // GET /public/progression/config — the level curve, and whether progression is diff --git a/src/client/components/LevelBadge.ts b/src/client/components/LevelBadge.ts index 1432b7d2ee3..2b5fede29f0 100644 --- a/src/client/components/LevelBadge.ts +++ b/src/client/components/LevelBadge.ts @@ -141,6 +141,27 @@ const BANDS: readonly BandStyle[] = [ }, ]; +// The light colour of each band's frame (its Tailwind stroke, written out), +// by levelBand: the profile card's glow behind the badge. +const BAND_ACCENTS = [ + "#cbd5e1", + "#6ee7b7", + "#5eead4", + "#7dd3fc", + "#93c5fd", + "#a5b4fc", + "#c4b5fd", + "#f0abfc", + "#fda4af", + "#fdba74", + "#fef08a", +] as const; + +/** The badge's accent colour, for a glow or a highlight around it. */ +export function levelBadgeAccent(level: number, legend: boolean): string { + return legend ? "#facc15" : BAND_ACCENTS[levelBand(level)]; +} + function displayLevel(level: number): number { if (!Number.isFinite(level)) return 1; return Math.min(MAX_LEVEL, Math.max(1, Math.floor(level))); diff --git a/src/client/components/PrestigeFlow.ts b/src/client/components/PrestigeFlow.ts new file mode 100644 index 00000000000..fe9d0b578a4 --- /dev/null +++ b/src/client/components/PrestigeFlow.ts @@ -0,0 +1,985 @@ +import { + html, + LitElement, + render as litRender, + nothing, + svg, + TemplateResult, +} from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { PrestigeResponse, Progress } from "../../core/ApiSchemas"; +import { + clampPrestige, + MAX_PRESTIGE, + PrestigeTier, + prestigeTier, +} from "../Progression"; +import { prestigeMe } from "../ProgressionApi"; +import { translateText } from "../Utils"; +import "./CapIcon"; +import "./LevelBadge"; + +// Prestiging, start to finish: the confirmation (the new emblem, where it sits +// on the prestige track, what it unlocks, and that it can't be undone), then a +// full-screen ceremony: the level-100 badge charges up and shatters, the new +// prestige emblem forms out of the flash, and the new rank slams in with its +// rewards. Tapping skips the ceremony to its end. +// +// Emits `prestiged` (detail: PrestigeResponse) as soon as the server agrees, +// so the page behind can update while the ceremony plays. + +type Stage = "closed" | "confirm" | "submitting" | "ceremony"; + +// The ceremony's beats, in ms from its start. +const BEATS = { + shatter: 1900, + reveal: 2100, + title: 2700, + done: 3500, +} as const; +type Beat = keyof typeof BEATS | "charge"; +const BEAT_ORDER: Beat[] = ["charge", "shatter", "reveal", "title", "done"]; + +// How long the confirm button must be held down to prestige. +export const HOLD_MS = 1500; + +// Ranks that come with an exclusive cosmetic (the levels plan, §3.4). +const COSMETIC_RANKS = [1, 5, 10]; + +const TIER_COLORS: Record = { + none: "#facc15", + ring: "#f59e0b", + double: "#e2e8f0", + sunburst: "#facc15", + radiant: "#d946ef", +}; + +// The honeycomb baked into the menu background (resources/images/ +// background.webp, 2500x1382): flat-topped hexes with 147px sides, one of +// whose rows of horizontal edges runs along y=1260 with an edge centred on +// x=144. Measured from the image, so traced hexes sit on its own lines when +// drawn over it at the same scale and position (cover, centred). `d` is each +// hex's distance from the middle, 0..1, for the waves. +const HONEYCOMB = (() => { + const width = 2500; + const height = 1382; + const side = 147; + const halfHeight = (Math.sqrt(3) * side) / 2; + const originX = 144; + const originY = 1260 - halfHeight; + const hexes: { points: string; d: number }[] = []; + const maxDist = Math.hypot(width / 2, height / 2); + for (let col = -2; col <= Math.ceil(width / (1.5 * side)) + 1; col++) { + const cx = originX + col * 1.5 * side; + const shift = col % 2 === 0 ? 0 : halfHeight; + for (let row = -2; row <= Math.ceil(height / (2 * halfHeight)) + 2; row++) { + const cy = originY - shift - row * 2 * halfHeight; + if (cx < -side || cx > width + side) continue; + if (cy < -halfHeight * 2 || cy > height + halfHeight * 2) continue; + const points = [0, 60, 120, 180, 240, 300] + .map((deg) => { + const a = (deg * Math.PI) / 180; + return `${(cx + side * Math.cos(a)).toFixed(1)},${(cy + side * Math.sin(a)).toFixed(1)}`; + }) + .join(" "); + const d = Math.min( + 1, + Math.hypot(cx - width / 2, cy - height / 2) / maxDist, + ); + hexes.push({ points, d: Number(d.toFixed(3)) }); + } + } + return { width, height, hexes }; +})(); + +function prefersReducedMotion(): boolean { + try { + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + +function newIdempotencyKey(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +@customElement("prestige-flow") +export class PrestigeFlow extends LitElement { + @state() private stage: Stage = "closed"; + // Progress before prestiging. + @state() private prior: Progress | null = null; + @state() private outcome: PrestigeResponse | null = null; + @state() private failed = false; + @state() private beat: Beat = "charge"; + // True when the ceremony follows the confirmation, whose backdrop it takes + // over as is: fading in again would show the page behind for a moment. + private fromConfirm = false; + // The confirm button's hold: 0..1, and when it started (null when up). + @state() private holdProgress = 0; + private holdStart: number | null = null; + private holdFrame = 0; + + // Replaceable for tests and previews. + submit: (idempotencyKey: string) => ReturnType = + prestigeMe; + + private portal: HTMLDivElement | null = null; + private timers: ReturnType[] = []; + private idempotencyKey = ""; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this.portal = document.createElement("div"); + document.body.appendChild(this.portal); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.clearTimers(); + this.endHold(); + if (this.portal) { + litRender(html``, this.portal); + this.portal.remove(); + this.portal = null; + } + } + + /** Opens the confirmation for a player who can prestige. */ + open(progress: Progress): void { + if (!progress.canPrestige) return; + this.prior = progress; + this.outcome = null; + this.failed = false; + this.holdProgress = 0; + // One key per confirmation: a retry after a failure is the same request. + this.idempotencyKey = newIdempotencyKey(); + this.stage = "confirm"; + } + + /** Plays the ceremony for a prestige that already happened (previews). */ + celebrate(before: Progress, result: PrestigeResponse): void { + this.prior = before; + this.outcome = result; + this.fromConfirm = false; + this.startCeremony(); + } + + get isOpen(): boolean { + return this.stage !== "closed"; + } + + private close(): void { + this.clearTimers(); + this.endHold(); + this.stage = "closed"; + } + + render() { + if (this.portal) litRender(this.renderOverlay(), this.portal); + return nothing; + } + + private renderOverlay(): TemplateResult | typeof nothing { + if (this.prior === null) return nothing; + switch (this.stage) { + case "closed": + return nothing; + case "confirm": + case "submitting": + return this.renderConfirm(this.prior); + case "ceremony": + return this.renderCeremony(this.prior); + } + } + + // --------------------------------------------------------------------------- + // Confirmation + // --------------------------------------------------------------------------- + + private nextRank(before: Progress): number { + return Math.min(MAX_PRESTIGE, clampPrestige(before.prestige) + 1); + } + + private renderConfirm(before: Progress): TemplateResult { + const rank = this.nextRank(before); + const submitting = this.stage === "submitting"; + const holding = this.holdProgress > 0 && !submitting; + return html``; + } + + // Every rank in a row, Legend at the end: the ones already earned lit, the + // one about to be entered raised, the rest dimmed. + private renderTrack(current: number, next: number): TemplateResult { + const ranks = Array.from({ length: MAX_PRESTIGE }, (_, i) => i + 1); + const state = (rank: number) => + rank === next ? "next" : rank <= current ? "earned" : "locked"; + return html`
    + ${ranks.map( + (rank) => + html`
  1. + +
  2. `, + )} +
  3. + +
  4. +
`; + } + + // What the new rank unlocks, as tiles. + private renderUnlocks(rank: number): TemplateResult { + const tiles: { key: string; icon: TemplateResult; label: string }[] = [ + { + key: "emblem", + icon: html``, + label: translateText("prestige.gain_emblem", { rank }), + }, + { + key: "caps", + icon: html``, + label: translateText("prestige.gain_caps"), + }, + ]; + if (COSMETIC_RANKS.includes(rank)) { + tiles.push({ + key: "cosmetic", + icon: html``, + label: translateText("prestige.gain_cosmetic"), + }); + } + return html`
+
+ ${translateText("prestige.unlocks")} +
+
+ ${tiles.map( + (tile) => + html`
+
+
${tile.icon}
+
+ ${tile.label} +
+
+
`, + )} +
+
`; + } + + // Holding the confirm button fills it; letting go early empties it again. + // A full hold prestiges. + private startHold(): void { + if (this.stage !== "confirm" || this.holdStart !== null) return; + this.holdStart = performance.now(); + const tick = () => { + if (this.holdStart === null) return; + const elapsed = performance.now() - this.holdStart; + this.holdProgress = Math.min(1, Math.max(0.01, elapsed / HOLD_MS)); + if (this.holdProgress >= 1) { + this.holdStart = null; + void this.confirm(); + return; + } + this.holdFrame = requestAnimationFrame(tick); + }; + this.holdFrame = requestAnimationFrame(tick); + } + + private endHold(): void { + if (this.holdStart === null) return; + this.holdStart = null; + cancelAnimationFrame(this.holdFrame); + this.holdProgress = 0; + } + + private async confirm(): Promise { + // The stage check stops a second hold from sending twice. + if (this.stage !== "confirm") return; + this.failed = false; + this.stage = "submitting"; + const response = await this.submit(this.idempotencyKey); + if (!response.ok) { + this.stage = "confirm"; + this.holdProgress = 0; + this.failed = true; + return; + } + this.outcome = response.data; + this.dispatchEvent( + new CustomEvent("prestiged", { + detail: response.data, + bubbles: true, + composed: true, + }), + ); + this.fromConfirm = true; + this.startCeremony(); + } + + // --------------------------------------------------------------------------- + // Ceremony + // --------------------------------------------------------------------------- + + private clearTimers(): void { + for (const t of this.timers) clearTimeout(t); + this.timers = []; + } + + private startCeremony(): void { + this.clearTimers(); + this.stage = "ceremony"; + if (prefersReducedMotion()) { + this.beat = "done"; + return; + } + this.beat = "charge"; + for (const [beat, at] of Object.entries(BEATS) as [Beat, number][]) { + this.timers.push(setTimeout(() => (this.beat = beat), at)); + } + } + + private reached(beat: Beat): boolean { + return BEAT_ORDER.indexOf(this.beat) >= BEAT_ORDER.indexOf(beat); + } + + private skipCeremony(): void { + if (this.beat === "done") return; + this.clearTimers(); + this.beat = "done"; + } + + private renderCeremony(before: Progress): TemplateResult { + const after = this.outcome?.progress; + const rank = after?.prestige ?? this.nextRank(before); + const color = TIER_COLORS[prestigeTier(rank)]; + const shattered = this.reached("shatter"); + const revealed = this.reached("reveal"); + const done = this.beat === "done"; + // Per-element angles, so the particles and shards spread evenly. + const particles = Array.from({ length: 18 }, (_, i) => (i * 360) / 18); + const shards = Array.from({ length: 14 }, (_, i) => (i * 360) / 14 + 7); + return html`
this.skipCeremony()} + > + ${this.renderCeremonyStyles()} + + ${this.renderHoneycomb( + !shattered ? "hx-charge" : done ? "hx-idle" : "hx-burst", + )} + + +
+ ${!shattered + ? html` +
+ +
` + : nothing} + ${shattered && !done + ? html`` + : nothing} + ${revealed + ? html`
+ +
` + : nothing} +
+ +
+ ${this.reached("title") + ? html`
+ ${translateText("prestige.ceremony_title", { rank })} +
` + : nothing} + ${done + ? html`` + : nothing} +
+ ${shattered && !this.reached("title") + ? html`` + : nothing} +
`; + } + + // The menu's honeycomb, traced in cyan over the background image's own hex + // lines (see HONEYCOMB) and lit in waves: in toward the badge while it + // charges, out from it when it shatters, then a slow shimmer. + private renderHoneycomb(mode: string): TemplateResult { + return html``; + } + + private renderCeremonyStyles(): TemplateResult { + return html``; + } +} diff --git a/src/client/components/ProfileCard.ts b/src/client/components/ProfileCard.ts new file mode 100644 index 00000000000..67a07b84973 --- /dev/null +++ b/src/client/components/ProfileCard.ts @@ -0,0 +1,331 @@ +import { html, LitElement, nothing, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { clampPrestige, levelFraction } from "../Progression"; +import { translateText } from "../Utils"; +import "./LevelBadge"; +import { levelBadgeAccent } from "./LevelBadge"; +import { formatXp, xpProgressText } from "./XpBar"; + +// The level side of a player, at a glance: their badge, name and clan, the +// level line and the XP bar. The same look a shared profile link unfurls with +// (the API renders that one as an image), so a player recognises it. +// +// `full` heads a Stats tab (yours and public profiles): a big badge, and the +// lifetime XP. Games and wins are left to the stats right under it, so +// nothing shows twice. `compact` is one row, for the account page. + +export interface ProfileCardProgress { + prestige: number; + level: number; + legend: boolean; + lifetimeXp: number; + // Missing when the source doesn't say (an older public endpoint): the XP + // bar is left out then. + xpInLevel?: number; + xpForNext?: number; + // At level 100 with ranks left: the Prestige button shows on your own card. + canPrestige?: boolean; +} + +// Segments the XP bar is drawn in, as on the end-of-game screen. +const BAR_SEGMENTS = 10; + +@customElement("profile-card") +export class ProfileCard extends LitElement { + @property({ type: String }) variant: "full" | "compact" = "full"; + @property({ type: String }) username = ""; + @property({ attribute: false }) clanTag: string | null = null; + // Null when progression is off: nothing renders. + @property({ attribute: false }) progress: ProfileCardProgress | null = null; + // Your own card: offers Prestige when you can. Emits `prestige-request`. + @property({ type: Boolean }) prestigeable = false; + + createRenderRoot() { + return this; + } + + private get compact(): boolean { + return this.variant === "compact"; + } + + // "PRESTIGE 3 · LEVEL 47", "LEVEL 47", or "LEGEND · PRESTIGE 10". + private renderLevelLine(progress: ProfileCardProgress): TemplateResult { + const size = this.compact + ? "text-sm tracking-wider" + : "text-lg tracking-widest sm:text-xl"; + const prestige = clampPrestige(progress.prestige); + if (progress.legend) { + return html`
+ ${translateText("progression.legend")} + · + ${translateText("progression.prestige", { prestige })} +
`; + } + return html`
+ ${prestige > 0 + ? html`${translateText("progression.prestige", { prestige })} + ·` + : nothing} + ${translateText("progression.level", { level: progress.level })} +
`; + } + + private renderBar(fill: number): TemplateResult { + return html`
+
+ ${Array.from( + { length: BAR_SEGMENTS - 1 }, + (_, i) => + html``, + )} +
`; + } + + // The XP bar, with the lifetime XP on the left under it (full only) and + // the progress through the level on the right. + private renderXp(progress: ProfileCardProgress): TemplateResult { + const { xpInLevel, xpForNext } = progress; + const hasBar = xpInLevel !== undefined && xpForNext !== undefined; + const fill = progress.legend + ? 1 + : levelFraction(xpInLevel ?? 0, xpForNext ?? 0); + const lifetime = this.compact + ? nothing + : html`
+ ${translateText("profile_card.lifetime_xp")} + ${formatXp(progress.lifetimeXp)} +
`; + return html`
+ ${hasBar ? this.renderBar(fill) : nothing} +
+ ${lifetime} + ${hasBar + ? html`
+ ${xpProgressText(xpInLevel, progress.legend ? 0 : xpForNext)} +
` + : nothing} +
+
`; + } + + // The compact card's progress: the XP figure over the bar, as one block of + // a fixed width, so the bar doesn't stretch across the whole card. + private renderCompactXp( + progress: ProfileCardProgress, + ): TemplateResult | typeof nothing { + const { xpInLevel, xpForNext } = progress; + if (xpInLevel === undefined || xpForNext === undefined) return nothing; + const fill = progress.legend ? 1 : levelFraction(xpInLevel, xpForNext); + return html`
+
+ ${xpProgressText(xpInLevel, progress.legend ? 0 : xpForNext)} +
+ ${this.renderBar(fill)} +
`; + } + + private canPrestige(progress: ProfileCardProgress): boolean { + return this.prestigeable && progress.canPrestige === true; + } + + private renderPrestigeButton(): TemplateResult { + return html` + `; + } + + private renderBackground(): TemplateResult { + return html``; + } + + private renderBadge( + progress: ProfileCardProgress, + accent: string, + ): TemplateResult { + return html`
+ + +
`; + } + + render() { + const progress = this.progress; + if (progress === null) return nothing; + const accent = levelBadgeAccent(progress.level, progress.legend); + const clan = + this.clanTag === null + ? nothing + : html`[${this.clanTag}]`; + const name = html`${this.username}`; + + if (this.compact) { + // One row: the badge and who you are on the left, the level's progress + // in a block of its own on the right (under them on a phone). + return html`
+ ${this.renderBackground()} +
+
+ ${this.renderBadge(progress, accent)} +
+
+ ${clan} ${name} +
+
${this.renderLevelLine(progress)}
+
+
+ ${this.canPrestige(progress) + ? html`
+ ${this.renderPrestigeButton()} +
` + : this.renderCompactXp(progress)} +
+
`; + } + + return html`
+ ${this.renderBackground()} +
+ ${this.renderBadge(progress, accent)} +
+ ${clan} ${name} ${this.renderLevelLine(progress)} + ${this.renderXp(progress)} + ${this.canPrestige(progress) + ? html`
+ ${translateText("prestige.ready")} + ${this.renderPrestigeButton()} +
` + : nothing} +
+
+
`; + } +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 2a3b196806e..c05174330c9 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -121,6 +121,14 @@ export const ProgressSchema = z.object({ }); export type Progress = z.infer; +// POST /users/@me/prestige — the player's progress after prestiging, and the +// rewards it granted (unclaimed, like every reward; a Caps bonus at least). +export const PrestigeResponseSchema = z.object({ + progress: ProgressSchema, + rewards: RewardSchema.array().optional().default([]), +}); +export type PrestigeResponse = z.infer; + export const ProgressPositionSchema = z.object({ prestige: z.number(), level: z.number(), @@ -182,6 +190,11 @@ export const PublicProgressSchema = z.object({ level: z.number(), lifetimeXp: z.number(), legend: z.boolean(), + // Progress through the current level (both 0 at level 100), for the profile + // card's XP bar. Optional: an API without them still parses, and the bar + // is left out. + xpInLevel: z.number().optional(), + xpForNext: z.number().optional(), }); export type PublicProgress = z.infer; diff --git a/tests/client/PrestigeFlow.test.ts b/tests/client/PrestigeFlow.test.ts new file mode 100644 index 00000000000..8280f759341 --- /dev/null +++ b/tests/client/PrestigeFlow.test.ts @@ -0,0 +1,301 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, +})); +vi.mock("../../src/client/ProgressionApi", () => ({ + prestigeMe: vi.fn(), +})); + +import { + HOLD_MS, + PrestigeFlow, +} from "../../src/client/components/PrestigeFlow"; +import { ProfileCard } from "../../src/client/components/ProfileCard"; +import type { PrestigeResponse, Progress } from "../../src/core/ApiSchemas"; + +if (!customElements.get("prestige-flow")) { + customElements.define("prestige-flow", PrestigeFlow); +} +if (!customElements.get("profile-card")) { + customElements.define("profile-card", ProfileCard); +} + +const AT_100: Progress = { + prestige: 3, + level: 100, + xpInLevel: 0, + xpForNext: 0, + lifetimeXp: 820000, + legend: false, + canPrestige: true, +}; + +const PRESTIGED: PrestigeResponse = { + progress: { + ...AT_100, + prestige: 4, + level: 1, + xpForNext: 150, + canPrestige: false, + }, + rewards: [ + { + id: "r1", + currencyType: "soft", + amount: "5000", + reason: "prestige", + note: null, + }, + ], +}; + +describe("", () => { + let flow: PrestigeFlow; + let submit: Mock; + + beforeEach(async () => { + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "requestAnimationFrame", + "cancelAnimationFrame", + "performance", + ], + }); + flow = document.createElement("prestige-flow") as PrestigeFlow; + submit = vi + .fn() + .mockResolvedValue({ ok: true, data: PRESTIGED }); + flow.submit = submit; + document.body.appendChild(flow); + await flow.updateComplete; + }); + + afterEach(() => { + flow.remove(); + vi.useRealTimers(); + }); + + const q = (sel: string) => document.body.querySelector(sel); + async function settle(ms = 0): Promise { + await vi.advanceTimersByTimeAsync(ms); + await flow.updateComplete; + } + const confirmButton = () => q("[data-prestige-confirm-button]")!; + const fill = () => + q("[data-prestige-confirm-button] .prestige-hold-fill")!.style.transform; + // Presses the confirm button for `ms`, then lets go. + async function hold(ms = HOLD_MS + 100): Promise { + confirmButton().dispatchEvent( + new MouseEvent("pointerdown", { button: 0, bubbles: true }), + ); + await settle(ms); + // Gone if the hold went through and the ceremony took over. + q("[data-prestige-confirm-button]")?.dispatchEvent( + new MouseEvent("pointerup", { bubbles: true }), + ); + await settle(); + } + + it("shows the new rank, what it unlocks and what's kept", async () => { + flow.open(AT_100); + await settle(); + const dialog = q("[data-prestige-confirm]")!; + expect(dialog.textContent).toContain('prestige.title:{"rank":4}'); + expect(dialog.textContent).toContain("prestige.summary"); + // No lifetime XP figure on the confirmation. + expect(dialog.textContent).not.toContain("820,000"); + expect(q("[data-prestige-unlock='emblem']")!.textContent).toContain( + 'prestige.gain_emblem:{"rank":4}', + ); + expect(q("[data-prestige-unlock='caps']")).not.toBeNull(); + // No exclusive cosmetic at rank 4 (only 1, 5 and 10). + expect(q("[data-prestige-unlock='cosmetic']")).toBeNull(); + }); + + it("marks earned, next and locked ranks on the track", async () => { + flow.open(AT_100); + await settle(); + const stateOf = (rank: string) => + q(`[data-prestige-track] [data-rank='${rank}']`)!.dataset.state; + expect(stateOf("3")).toBe("earned"); + expect(stateOf("4")).toBe("next"); + expect(stateOf("5")).toBe("locked"); + expect(stateOf("legend")).toBe("locked"); + }); + + it("lists the exclusive cosmetic at a cosmetic rank", async () => { + flow.open({ ...AT_100, prestige: 4 }); + await settle(); + expect(q("[data-prestige-unlock='cosmetic']")).not.toBeNull(); + }); + + it("doesn't open for a player who can't prestige", async () => { + flow.open({ ...AT_100, canPrestige: false }); + await settle(); + expect(q("[data-prestige-confirm]")).toBeNull(); + }); + + it("does nothing on a click or a short press, and Cancel closes it", async () => { + flow.open(AT_100); + await settle(3000); + expect(confirmButton().textContent).toContain("prestige.confirm"); + confirmButton().click(); + await settle(); + expect(submit).not.toHaveBeenCalled(); + + // Let go early: the button empties again and nothing is sent. + confirmButton().dispatchEvent( + new MouseEvent("pointerdown", { button: 0, bubbles: true }), + ); + await settle(HOLD_MS / 2); + expect(confirmButton().textContent).toContain("prestige.keep_holding"); + expect(fill()).not.toBe("scaleX(0)"); + confirmButton().dispatchEvent( + new MouseEvent("pointerup", { bubbles: true }), + ); + await settle(HOLD_MS); + expect(submit).not.toHaveBeenCalled(); + expect(fill()).toBe("scaleX(0)"); + + [ + ...document.body.querySelectorAll( + "[data-prestige-confirm] button", + ), + ] + .find((b) => b.textContent!.includes("common.cancel"))! + .click(); + await settle(); + expect(q("[data-prestige-confirm]")).toBeNull(); + expect(submit).not.toHaveBeenCalled(); + }); + + it("prestiges with the keyboard too", async () => { + flow.open(AT_100); + await settle(); + confirmButton().dispatchEvent( + new KeyboardEvent("keydown", { key: " ", bubbles: true }), + ); + await settle(HOLD_MS + 100); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it("sends once however many times it's held", async () => { + let finish!: (v: Awaited>) => void; + submit.mockReturnValueOnce(new Promise((r) => (finish = r))); + flow.open(AT_100); + await settle(); + await hold(); + await hold(); + expect(submit).toHaveBeenCalledTimes(1); + finish({ ok: true, data: PRESTIGED }); + await settle(); + expect(q("[data-prestige-ceremony]")).not.toBeNull(); + }); + + it("prestiges on a full hold, then plays the ceremony to its end", async () => { + const prestiged = vi.fn(); + flow.addEventListener("prestiged", (e) => + prestiged((e as CustomEvent).detail), + ); + flow.open(AT_100); + await settle(); + await hold(); + + expect(submit).toHaveBeenCalledTimes(1); + expect(prestiged).toHaveBeenCalledWith(PRESTIGED); + const ceremony = () => q("[data-prestige-ceremony]"); + expect(ceremony()).not.toBeNull(); + expect(ceremony()!.getAttribute("data-beat")).toBe("charge"); + // Takes over the confirmation's backdrop rather than fading in over the + // page behind. + expect(ceremony()!.hasAttribute("data-from-confirm")).toBe(true); + expect(q("[data-prestige-new-badge]")).toBeNull(); + + await settle(2200); + expect(q("[data-prestige-new-badge]")).not.toBeNull(); + await settle(2500); + expect(ceremony()!.getAttribute("data-beat")).toBe("done"); + expect(q("[data-prestige-title]")!.textContent).toContain( + 'prestige.ceremony_title:{"rank":4}', + ); + // Just the rank: no reward chips or reset line under it. + expect(q("[data-prestige-ceremony]")!.textContent).not.toContain("5,000"); + + q("[data-prestige-continue]")!.click(); + await settle(); + expect(ceremony()).toBeNull(); + expect(flow.isOpen).toBe(false); + }); + + it("skips the ceremony to its end on a tap", async () => { + flow.celebrate(AT_100, PRESTIGED); + await settle(300); + expect( + q("[data-prestige-ceremony]")!.hasAttribute("data-from-confirm"), + ).toBe(false); + q("[data-prestige-ceremony]")!.click(); + await settle(); + expect(q("[data-prestige-ceremony]")!.getAttribute("data-beat")).toBe( + "done", + ); + expect(q("[data-prestige-continue]")).not.toBeNull(); + }); + + it("says so when it fails, and retries with the same key", async () => { + submit.mockResolvedValueOnce({ ok: false }); + flow.open(AT_100); + await settle(); + await hold(); + expect(q("[data-prestige-error]")).not.toBeNull(); + expect(q("[data-prestige-ceremony]")).toBeNull(); + expect(fill()).toBe("scaleX(0)"); + + await hold(); + expect(submit).toHaveBeenCalledTimes(2); + expect(submit.mock.calls[1][0]).toBe(submit.mock.calls[0][0]); + expect(q("[data-prestige-ceremony]")).not.toBeNull(); + }); +}); + +describe(" prestige button", () => { + let card: ProfileCard; + afterEach(() => card?.remove()); + + async function render(props: Partial): Promise { + card = document.createElement("profile-card") as ProfileCard; + Object.assign(card, { username: "Iamlewis", ...props }); + document.body.appendChild(card); + await card.updateComplete; + } + + it("offers Prestige on your own card at level 100", async () => { + const requested = vi.fn(); + await render({ progress: AT_100, prestigeable: true, variant: "compact" }); + card.addEventListener("prestige-request", requested); + card.querySelector("[data-profile-prestige]")!.click(); + expect(requested).toHaveBeenCalledTimes(1); + }); + + it("never on someone else's card, or before level 100", async () => { + await render({ progress: AT_100, prestigeable: false }); + expect(card.querySelector("[data-profile-prestige]")).toBeNull(); + card.remove(); + await render({ + progress: { ...AT_100, level: 99, canPrestige: false }, + prestigeable: true, + }); + expect(card.querySelector("[data-profile-prestige]")).toBeNull(); + }); +}); diff --git a/tests/client/ProfileCard.test.ts b/tests/client/ProfileCard.test.ts new file mode 100644 index 00000000000..9fe035b52dc --- /dev/null +++ b/tests/client/ProfileCard.test.ts @@ -0,0 +1,120 @@ +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 { + ProfileCard, + type ProfileCardProgress, +} from "../../src/client/components/ProfileCard"; + +if (!customElements.get("profile-card")) { + customElements.define("profile-card", ProfileCard); +} + +const PRESTIGE_3: ProfileCardProgress = { + prestige: 3, + level: 47, + legend: false, + lifetimeXp: 212400, + xpInLevel: 1490, + xpForNext: 2410, +}; + +describe("", () => { + let card: ProfileCard; + + afterEach(() => card?.remove()); + + async function render(props: Partial): Promise { + card = document.createElement("profile-card") as ProfileCard; + Object.assign(card, props); + document.body.appendChild(card); + await card.updateComplete; + return card; + } + + const text = (sel: string) => card.querySelector(sel)?.textContent ?? null; + + it("shows the name, clan, prestige and level, the XP bar and lifetime XP", async () => { + await render({ username: "Iamlewis", clanTag: "OF", progress: PRESTIGE_3 }); + expect(card.querySelector("[data-profile-card='full']")).not.toBeNull(); + expect(text("[data-profile-name]")).toContain("Iamlewis"); + expect(text("[data-profile-clan]")).toContain("[OF]"); + const line = text("[data-profile-level-line]")!; + expect(line).toContain('progression.prestige:{"prestige":3}'); + expect(line).toContain('progression.level:{"level":47}'); + expect(card.querySelector("level-badge")).not.toBeNull(); + expect( + card.querySelector("[data-xp-bar]")!.getAttribute("aria-valuenow"), + ).toBe("62"); + expect(text("[data-profile-xp]")).toContain( + 'progression.xp_progress:{"current":"1,490","next":"2,410"}', + ); + expect(text("[data-profile-lifetime]")).toContain("212,400"); + }); + + it("never repeats the games and wins the stats below it show", async () => { + await render({ username: "Iamlewis", progress: PRESTIGE_3 }); + expect(card.textContent).not.toContain("player_stats_tree.stats_played"); + expect(card.textContent).not.toContain("player_stats_tree.stats_victories"); + }); + + it("is one row on the account page, without the lifetime XP", async () => { + await render({ + variant: "compact", + username: "Iamlewis", + clanTag: "OF", + progress: PRESTIGE_3, + }); + expect(card.querySelector("[data-profile-card='compact']")).not.toBeNull(); + expect(text("[data-profile-name]")).toContain("Iamlewis"); + expect(text("[data-profile-level-line]")).toContain( + 'progression.level:{"level":47}', + ); + expect(card.querySelector("[data-xp-bar]")).not.toBeNull(); + expect(card.querySelector("[data-profile-lifetime]")).toBeNull(); + expect( + (card.querySelector("level-badge") as HTMLElement & { size: number }) + .size, + ).toBe(64); + }); + + it("leaves prestige out before the first one, and the bar without XP-in-level", async () => { + await render({ + username: "New", + progress: { prestige: 0, level: 5, legend: false, lifetimeXp: 900 }, + }); + expect(text("[data-profile-level-line]")).not.toContain( + "progression.prestige", + ); + expect(card.querySelector("[data-xp-bar]")).toBeNull(); + expect(text("[data-profile-lifetime]")).toContain("900"); + }); + + it("shows a Legend as Legend, with a full bar at max level", async () => { + await render({ + username: "Umbra", + progress: { + prestige: 10, + level: 100, + legend: true, + lifetimeXp: 2106720, + xpInLevel: 0, + xpForNext: 0, + }, + }); + expect(text("[data-profile-level-line]")).toContain("progression.legend"); + expect( + card.querySelector("[data-xp-bar]")!.getAttribute("aria-valuenow"), + ).toBe("100"); + expect(text("[data-profile-xp]")).toContain("progression.max_level"); + }); + + it("renders nothing while progression is off", async () => { + await render({ username: "Player", progress: null }); + expect(card.querySelector("[data-profile-card]")).toBeNull(); + }); +}); diff --git a/tests/client/ProgressionModals.test.ts b/tests/client/ProgressionModals.test.ts index 88af119e5f8..0e2b0bb342d 100644 --- a/tests/client/ProgressionModals.test.ts +++ b/tests/client/ProgressionModals.test.ts @@ -188,6 +188,10 @@ describe("player profile level", () => { }); } + // The profile card's level parts. + const levelLine = () => + modal.querySelector("profile-card [data-profile-level-line]"); + it("shows another player's level from the public endpoint", async () => { fetchPublicPlayerProgress.mockResolvedValue({ prestige: 0, @@ -196,26 +200,52 @@ describe("player profile level", () => { 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(levelLine()!.textContent).toContain( + 'progression.level:{"level":12}', + ); + expect(levelLine()!.textContent).not.toContain("progression.prestige"); + expect( + modal.querySelector("[data-profile-lifetime]")!.textContent, + ).toContain("4,200"); + // No XP-in-level from this API: no bar. + expect(modal.querySelector("profile-card [data-xp-bar]")).toBeNull(); expect(fetchPublicPlayerProgress).toHaveBeenCalledWith("someone-else"); }); + it("draws the XP bar when the public endpoint says how far through the level", async () => { + fetchPublicPlayerProgress.mockResolvedValue({ + prestige: 0, + level: 12, + lifetimeXp: 4200, + legend: false, + xpInLevel: 150, + xpForNext: 600, + }); + await open("someone-else"); + expect( + modal + .querySelector("profile-card [data-xp-bar]")! + .getAttribute("aria-valuenow"), + ).toBe("25"); + }); + 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(levelLine()!.textContent).toContain( + 'progression.level:{"level":64}', + ); + expect(levelLine()!.textContent).toContain( + 'progression.prestige:{"prestige":2}', + ); expect(fetchPublicPlayerProgress).not.toHaveBeenCalled(); }); - it("hides the level when there is no progress", async () => { + it("shows no card when there is no progress", async () => { fetchPublicPlayerProgress.mockResolvedValue(false); await open("no-progress"); - expect(modal.querySelector("[data-profile-level]")).toBeNull(); + // No card at all: the stats below still show. + expect(modal.querySelector("profile-card")).toBeNull(); + expect(modal.querySelector("player-stats-tree-view")).not.toBeNull(); }); }); From 12d5fb64f9d08209cf5c06aafc9acb1396304ad8 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 1 Oct 2026 15:49:11 +0100 Subject: [PATCH 04/62] Harden the prestige hold and recover from a lost answer - End a hold when the button loses focus, the window blurs or the tab is hidden, so a key released elsewhere can't let it run to a prestige. - Ignore open() while the flow is up, focus the hold button on open, keep Tab inside the confirmation, and take Escape before the page behind does (cancel the confirmation; skip, then close, the ceremony). - Keep the idempotency key per rank until a prestige succeeds, so reopening after a lost answer gets that prestige back instead of a 409. A 409 now reloads the account; any other failure no longer claims nothing changed. - Give each profile card its own hex pattern id. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 2 +- src/client/AccountModal.ts | 24 ++++- src/client/ProgressionApi.ts | 25 +++-- src/client/components/PrestigeFlow.ts | 91 +++++++++++++++++- src/client/components/ProfileCard.ts | 10 +- tests/client/PrestigeFlow.test.ts | 130 +++++++++++++++++++++++++- 6 files changed, 265 insertions(+), 17 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index dc142304385..f426a78fcd4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1754,7 +1754,7 @@ "ceremony_title": "Prestige {rank}", "confirm": "Hold to prestige", "continue": "Continue", - "error": "Couldn't prestige. Nothing has changed, so try again.", + "error": "Couldn't confirm your prestige. Check your connection and hold again.", "gain_caps": "Caps bonus", "gain_cosmetic": "Exclusive cosmetic", "gain_emblem": "Prestige {rank} emblem", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 7c1a1e4c1b0..581943a9103 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -193,7 +193,10 @@ export class AccountModal extends BaseModal {
${this.renderTab(tab)}
- + `; } @@ -503,6 +506,25 @@ export class AccountModal extends BaseModal { this.requestUpdate(); }; + // The server says the player can't prestige from where this page thinks + // they are: most likely an earlier prestige whose answer never arrived. + // Reload the account so the card shows where they really are. + private handlePrestigeStale = async (): Promise => { + invalidateUserMe(); + const userMe = await getUserMe(); + if (userMe) this.userMeResponse = userMe; + this.requestUpdate(); + }; + + // Escape belongs to the prestige confirmation or ceremony while it's up, + // not to the page behind it. + public confirmBeforeClose(): boolean | Promise { + if (this.querySelector("prestige-flow")?.isOpen) { + return false; + } + return super.confirmBeforeClose(); + } + private renderCurrency(): TemplateResult { const currency = this.userMeResponse?.player?.currency; if (!currency) return html``; diff --git a/src/client/ProgressionApi.ts b/src/client/ProgressionApi.ts index e065989f36a..494c13903be 100644 --- a/src/client/ProgressionApi.ts +++ b/src/client/ProgressionApi.ts @@ -133,16 +133,23 @@ export async function fetchPublicPlayerProgress( } } -// POST /users/@me/prestige — the opt-in reset at level 100. The idempotency -// key is per confirmation, so a retried request (a slow network, a double -// submit) prestiges once. Unlike the reads above this does surface failure: -// the player asked for it and must be told it didn't happen. +// POST /users/@me/prestige — the opt-in reset at level 100. The caller keeps +// one idempotency key until a prestige succeeds, so a retried request (a +// slow network, a lost response) prestiges once. Unlike the reads above this +// does surface failure: the player asked for it and must be told. "refused" +// is the server saying the player can't prestige (409): usually an earlier +// prestige whose answer never arrived. Anything else may or may not have +// gone through, and a retry with the same key finds out. +export type PrestigeResult = + | { ok: true; data: PrestigeResponse } + | { ok: false; refused: boolean }; + export async function prestigeMe( idempotencyKey: string, -): Promise<{ ok: true; data: PrestigeResponse } | { ok: false }> { +): Promise { try { const authorization = await getAuthHeader(); - if (authorization === "") return { ok: false }; + if (authorization === "") return { ok: false, refused: false }; const res = await fetch(`${getApiBase()}/users/@me/prestige`, { method: "POST", headers: { @@ -154,17 +161,17 @@ export async function prestigeMe( }); if (!res.ok) { console.warn("prestigeMe: unexpected status", res.status); - return { ok: false }; + return { ok: false, refused: res.status === 409 }; } const parsed = PrestigeResponseSchema.safeParse(await res.json()); if (!parsed.success) { console.warn("prestigeMe: Zod validation failed", parsed.error); - return { ok: false }; + return { ok: false, refused: false }; } return { ok: true, data: parsed.data }; } catch (err) { console.warn("prestigeMe: request failed", err); - return { ok: false }; + return { ok: false, refused: false }; } } diff --git a/src/client/components/PrestigeFlow.ts b/src/client/components/PrestigeFlow.ts index fe9d0b578a4..1391129ea0a 100644 --- a/src/client/components/PrestigeFlow.ts +++ b/src/client/components/PrestigeFlow.ts @@ -100,6 +100,12 @@ function prefersReducedMotion(): boolean { } } +// The key for a prestige from each rank, kept until that prestige succeeds. +// Closing and reopening the confirmation must not mint a new one: if an +// earlier request went through but its answer was lost, the same key gets +// that prestige back, where a new key would be refused. +const pendingKeys = new Map(); + function newIdempotencyKey(): string { return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() @@ -138,10 +144,17 @@ export class PrestigeFlow extends LitElement { super.connectedCallback(); this.portal = document.createElement("div"); document.body.appendChild(this.portal); + // Capture, so the overlay sees keys before the page behind it does. + window.addEventListener("keydown", this.onKeyDown, true); + window.addEventListener("blur", this.onFocusLost); + document.addEventListener("visibilitychange", this.onFocusLost); } disconnectedCallback() { super.disconnectedCallback(); + window.removeEventListener("keydown", this.onKeyDown, true); + window.removeEventListener("blur", this.onFocusLost); + document.removeEventListener("visibilitychange", this.onFocusLost); this.clearTimers(); this.endHold(); if (this.portal) { @@ -153,16 +166,70 @@ export class PrestigeFlow extends LitElement { /** Opens the confirmation for a player who can prestige. */ open(progress: Progress): void { - if (!progress.canPrestige) return; + // Already up (a second press on the card behind it): leave it alone, and + // never swap the key of a request that's in flight. + if (this.stage !== "closed" || !progress.canPrestige) return; this.prior = progress; this.outcome = null; this.failed = false; this.holdProgress = 0; - // One key per confirmation: a retry after a failure is the same request. - this.idempotencyKey = newIdempotencyKey(); + let key = pendingKeys.get(progress.prestige); + if (key === undefined) { + key = newIdempotencyKey(); + pendingKeys.set(progress.prestige, key); + } + this.idempotencyKey = key; this.stage = "confirm"; + void this.updateComplete.then(() => this.focusConfirmButton()); + } + + private focusConfirmButton(): void { + this.portal + ?.querySelector("[data-prestige-confirm-button]") + ?.focus(); } + // Keys while the overlay is up: Escape cancels the confirmation (or skips, + // then closes, the ceremony), and Tab stays inside the confirmation. The + // page behind never sees them. + private onKeyDown = (e: KeyboardEvent): void => { + if (this.stage === "closed") return; + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (this.stage === "confirm") this.close(); + else if (this.stage === "ceremony") { + if (this.beat === "done") this.close(); + else this.skipCeremony(); + } + return; + } + if (e.key === "Tab" && this.stage !== "ceremony") { + const buttons = [ + ...(this.portal?.querySelectorAll( + "[data-prestige-confirm] button:not([disabled])", + ) ?? []), + ]; + if (buttons.length === 0) return; + const at = buttons.indexOf(document.activeElement as HTMLButtonElement); + const next = + at === -1 + ? 0 + : (at + (e.shiftKey ? -1 : 1) + buttons.length) % buttons.length; + e.preventDefault(); + e.stopPropagation(); + buttons[next].focus(); + } + }; + + // A hold only counts while the button keeps it: switching window or tab + // lets go, wherever the key or pointer is released. + private onFocusLost = (): void => { + if (document.visibilityState === "hidden" || !document.hasFocus()) { + this.endHold(); + } + }; + /** Plays the ceremony for a prestige that already happened (previews). */ celebrate(before: Progress, result: PrestigeResponse): void { this.prior = before; @@ -284,6 +351,7 @@ export class PrestigeFlow extends LitElement { @pointerup=${() => this.endHold()} @pointerleave=${() => this.endHold()} @pointercancel=${() => this.endHold()} + @blur=${() => this.endHold()} @keydown=${(e: KeyboardEvent) => { if (e.key !== " " && e.key !== "Enter") return; e.preventDefault(); @@ -440,13 +508,27 @@ export class PrestigeFlow extends LitElement { if (this.stage !== "confirm") return; this.failed = false; this.stage = "submitting"; + const prior = this.prior; const response = await this.submit(this.idempotencyKey); if (!response.ok) { + if (response.refused) { + // The server won't prestige from here: the page is out of date. Drop + // the key and let the page reload where the player really is. + if (prior) pendingKeys.delete(prior.prestige); + this.close(); + this.dispatchEvent( + new CustomEvent("prestige-stale", { bubbles: true, composed: true }), + ); + return; + } + // It may or may not have gone through: the same key finds out. this.stage = "confirm"; this.holdProgress = 0; this.failed = true; + void this.updateComplete.then(() => this.focusConfirmButton()); return; } + if (prior) pendingKeys.delete(prior.prestige); this.outcome = response.data; this.dispatchEvent( new CustomEvent("prestiged", { @@ -503,6 +585,9 @@ export class PrestigeFlow extends LitElement { const shards = Array.from({ length: 14 }, (_, i) => (i * 360) / 14 + 7); return html`