diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index a084809f12d..1f83c3aed15 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -357,6 +357,15 @@ export const UserMeResponseSchema = z.object({ // reads as absent: drift in this cosmetic field must never fail the whole // /users/@me parse, which would make the player look signed out. progress: ProgressSchema.optional().catch(undefined), + // "Hide my level": true when the player opted out of showing their level + // to others. They still earn XP and see their own level; the game server + // then leaves the badge off their lobby roster entry. Optional so an older + // API without the setting still parses — absent reads as shown, and the + // account settings hide the Privacy card. A malformed value reads as + // hidden: the server parses this response at join, so a bad value must + // never fail the parse and reject the join, and when the setting can't be + // read the private answer is the safe one. + levelHidden: z.boolean().optional().catch(true), clans: z .array( z.object({ diff --git a/packages/shared/src/LevelBadgeWire.ts b/packages/shared/src/LevelBadgeWire.ts new file mode 100644 index 00000000000..a88bcb5594f --- /dev/null +++ b/packages/shared/src/LevelBadgeWire.ts @@ -0,0 +1,71 @@ +// A player's level as a name badge: the display subset of the API's +// /users/@me `progress`. Display-only — the simulation never reads it, so it +// cannot desync. +export interface LevelBadge { + level: number; + prestige: number; + legend: boolean; +} + +// The lobby roster (ClientInfo.levelBadge) carries a badge as ONE small +// integer rather than a nested { level, prestige, legend } object. The roster +// goes to every client once a second, encoded once per recipient, so a +// 150-player lobby encodes 22,500 entries a second: a nested object cost each +// entry its own header byte plus two varints and a per-object encode/decode +// step; a packed varint is one or two bytes and a plain number. +// +// bits 0-6 level 1..100 +// bits 7-10 prestige 0..10 +// bit 11 legend +// +// Prestige 0 (most players) packs below 128, so one varint byte; anything +// else, up to legend at P10 L100 (3428), is two. +export const LEVEL_BADGE_MAX_LEVEL = 100; +export const LEVEL_BADGE_MAX_PRESTIGE = 10; +const LEVEL_BITS = 7; +const LEVEL_MASK = (1 << LEVEL_BITS) - 1; +const PRESTIGE_SHIFT = LEVEL_BITS; +const PRESTIGE_MASK = 0b1111; +const LEGEND_BIT = 1 << 11; +const ALL_BITS = (1 << 12) - 1; + +function inRange(level: number, prestige: number): boolean { + return ( + Number.isInteger(level) && + level >= 1 && + level <= LEVEL_BADGE_MAX_LEVEL && + Number.isInteger(prestige) && + prestige >= 0 && + prestige <= LEVEL_BADGE_MAX_PRESTIGE + ); +} + +// The wire form of a badge, or undefined when it holds anything the packing +// cannot represent (so no badge is sent rather than a wrong one). +export function packLevelBadge( + badge: LevelBadge | undefined, +): number | undefined { + if (badge === undefined) return undefined; + const { level, prestige, legend } = badge; + if (!inRange(level, prestige) || typeof legend !== "boolean") { + return undefined; + } + return level | (prestige << PRESTIGE_SHIFT) | (legend ? LEGEND_BIT : 0); +} + +// Back to { level, prestige, legend }. Undefined for no badge and for any +// value no server would send — out-of-range level or prestige, reserved bits +// set, not a whole number — so a bad roster entry shows no badge instead of +// a wrong one or an exception. +export function unpackLevelBadge( + packed: number | undefined, +): LevelBadge | undefined { + if (packed === undefined) return undefined; + if (!Number.isInteger(packed) || packed < 0 || packed > ALL_BITS) { + return undefined; + } + const level = packed & LEVEL_MASK; + const prestige = (packed >> PRESTIGE_SHIFT) & PRESTIGE_MASK; + if (!inRange(level, prestige)) return undefined; + return { level, prestige, legend: (packed & LEGEND_BIT) !== 0 }; +} diff --git a/packages/shared/src/WireSchemas.ts b/packages/shared/src/WireSchemas.ts index ec9854afbae..02a138bdecc 100644 --- a/packages/shared/src/WireSchemas.ts +++ b/packages/shared/src/WireSchemas.ts @@ -201,7 +201,9 @@ export const LobbyAccentSchema = z.enum(["gold", "blue", "green", "red"]); export type LobbyAccent = z.infer; -const ClientInfoSchema = z.object({ +export type { LevelBadge } from "./LevelBadgeWire"; + +export const ClientInfoSchema = z.object({ clientID: z.string(), username: UsernameSchema, clanTag: ClanTagSchema, @@ -217,6 +219,21 @@ const ClientInfoSchema = z.object({ // preview can honour the pins instead of re-deriving teams that the server // will overrule at start. Absent when the game isn't matchmade. teamIndex: zb.uint().optional(), + // Signed-in players' level, for the badge next to their name: level, + // prestige and legend packed into one varint by packLevelBadge + // (LevelBadgeWire.ts); read it with unpackLevelBadge. Stamped by the game + // server from its own /users/@me lookup, never taken from the client. + // Display-only. Absent for guests, for players who hide their level (on + // every copy of the roster, their own included), when the API has + // progression off or the lookup failed, and on anonymized entries (a badge + // beside an anonymous name would point out the veterans). Must stay the + // LAST field: its one presence bit is the eighth and last bit of this + // object's one-byte header, so an entry without it encodes byte-identically + // to one from before it existed, and a pre-badge frame still decodes. + // Another field here would need a second header byte. No range check here + // on purpose: one bad value must not fail the whole roster's parse, so + // unpackLevelBadge drops it instead. + levelBadge: zb.uint().optional(), }); export const GameInfoSchema = z.object({ @@ -340,6 +357,9 @@ export interface ClientInfo { spectator?: boolean; // Server-pinned team slot for matchmade team games; absent when not matchmade. teamIndex?: number; + // Server-stamped level for the name badge, packed (packLevelBadge); read it + // with unpackLevelBadge. Display-only; never set on anonymized entries. + levelBadge?: number; } export enum LogSeverity { diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 3eebd7dbca5..1984941e0ee 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -45,6 +45,7 @@ import { TickMetricsEvent, ToggleRenderDebugGuiEvent, } from "./InputHandler"; +import { rememberLobbyRoster } from "./LobbyRosterLevels"; import { pagePin } from "./PagePin"; import { groupTokenOf, loggableStartMessage } from "./PresenceGroup"; import { versionedPathForMismatchedGame } from "./ServerList"; @@ -221,6 +222,8 @@ export function joinLobby( // Server tells us our assigned clientID clientID = message.myClientID; eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID)); + // Kept past game start for the in-game level badges. + rememberLobbyRoster(message.lobby.gameID, message.lobby.clients); // Preload the map while still in the lobby so game start can reuse the // cached result instead of blocking on the download in the short // prestart->start window. The preload is debounced: it only fires once diff --git a/src/client/LobbyRosterLevels.ts b/src/client/LobbyRosterLevels.ts new file mode 100644 index 00000000000..bc16bb7bc05 --- /dev/null +++ b/src/client/LobbyRosterLevels.ts @@ -0,0 +1,34 @@ +import { ClientID, GameID } from "@openfront/engine-api/Schemas"; +import { LevelBadge, unpackLevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { ClientInfo } from "@openfront/shared/WireSchemas"; + +// Level badges from the last lobby roster this tab was sent, for in-game UI. +// The start message carries no levels, so a tab that never saw the lobby (a +// refresh or late join) has none. Kept in wire form, as a roster arrives every +// second and a badge is read only when a panel opens. + +let rosterGameID: GameID | null = null; +let badges = new Map(); + +export function rememberLobbyRoster( + gameID: GameID, + clients: readonly ClientInfo[] | undefined, +): void { + const next = new Map(); + for (const c of clients ?? []) { + if (c.levelBadge !== undefined) next.set(c.clientID, c.levelBadge); + } + rosterGameID = gameID; + badges = next; +} + +// The player's badge in game `gameID`, or undefined when they have none (a +// guest, an anonymized name, progression off, their level hidden, a value out +// of range) or this tab never saw them in that game's lobby. +export function lobbyLevelBadge( + gameID: GameID, + clientID: ClientID | null, +): LevelBadge | undefined { + if (clientID === null || gameID !== rosterGameID) return undefined; + return unpackLevelBadge(badges.get(clientID)); +} diff --git a/src/client/components/LevelBadge.ts b/src/client/components/LevelBadge.ts index d04cf15aabc..ea64f8dfbe2 100644 --- a/src/client/components/LevelBadge.ts +++ b/src/client/components/LevelBadge.ts @@ -8,6 +8,13 @@ import { prestigeTier, } from "../Progression"; import { translateText } from "../Utils"; +import { + badgeArrived, + dequeueBadge, + queueBadge, + shouldStagger, + type StaggeredBadge, +} from "./LevelBadgeFill"; // A player's level as a small emblem: the number inside a frame whose SHAPE // (not just colour, for colour-blind players) changes every ten levels, a @@ -162,17 +169,50 @@ export function levelBadgeLabel( } @customElement("level-badge") -export class LevelBadge extends LitElement { +export class LevelBadge extends LitElement implements StaggeredBadge { @property({ type: Number }) level = 1; @property({ type: Number }) prestige = 0; @property({ type: Boolean }) legend = false; // Rendered size in CSS pixels (square). 16 is the smallest supported. @property({ type: Number }) size = 24; + // With the `stagger` attribute (long lists): when many badges appear at + // once, this one first holds its square empty and is drawn on a later + // frame (see LevelBadgeFill). Decided once, at its first render. The host + // is aria-hidden meanwhile, so a screen reader meets no run of unnamed + // elements. + private waiting = false; + createRenderRoot() { return this; } + connectedCallback(): void { + super.connectedCallback(); + if (!this.hasUpdated && this.hasAttribute("stagger")) badgeArrived(); + else if (this.waiting) queueBadge(this); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + if (this.waiting) dequeueBadge(this); + } + + protected willUpdate(): void { + if (!this.hasUpdated && this.hasAttribute("stagger") && shouldStagger()) { + this.waiting = true; + this.setAttribute("aria-hidden", "true"); + queueBadge(this); + } + } + + draw(): void { + if (!this.waiting) return; + this.waiting = false; + this.removeAttribute("aria-hidden"); + this.requestUpdate(); + } + /** Accessible name, e.g. "Level 42, Prestige 3". */ label(): string { return levelBadgeLabel(this.level, this.prestige, this.legend); @@ -344,9 +384,18 @@ export class LevelBadge extends LitElement { } render() { + const px = Math.max(16, this.size); + if (this.waiting) { + // The drawn badge's exact box, so nothing moves when it is drawn. + return html``; + } const band = BANDS[levelBand(this.level)]; const label = this.label(); - const px = Math.max(16, this.size); const prestige = clampPrestige(this.prestige); const tier = this.legend ? "none" : prestigeTier(prestige); const glow = this.legend diff --git a/src/client/components/LevelBadgeFill.ts b/src/client/components/LevelBadgeFill.ts new file mode 100644 index 00000000000..915d479c19b --- /dev/null +++ b/src/client/components/LevelBadgeFill.ts @@ -0,0 +1,158 @@ +// Staggered drawing for long lists of level badges. +// +// A badge is cheap to build but not to lay out: a full 150-player lobby puts +// 150 badges (300 in team mode, with the team cards) on screen at once, and +// the browser's style and layout of all those SVGs in one frame is what made +// the roster's first paint slow. Badges marked `stagger` that arrive many in +// one render are therefore drawn a few per frame instead: each first holds its +// square empty (so nothing moves when it is drawn) and this loop draws them, +// nearest the viewport first, sized so each frame stays inside a small +// budget. A few badges arriving at once (a player joining, a badge changing) +// are drawn straight away, as before. + +/** What the loop needs from a badge waiting to be drawn. */ +export interface StaggeredBadge { + /** Draws the badge now in place of its empty square. */ + draw(): void; + readonly updateComplete: Promise; + readonly isConnected: boolean; + getBoundingClientRect(): DOMRect; +} + +// Up to this many staggered badges arriving in one render are drawn +// straight away: about a millisecond or two of layout. +export const STAGGER_ABOVE = 8; +// Each frame's share of the drawing: its script plus the style and layout it +// causes, measured, steers how many badges the next frame draws. +const FRAME_BUDGET_MS = 5; +const FIRST_BATCH = 12; +const MIN_BATCH = 4; +const MAX_BATCH = 64; + +// Waiting badges, in arrival (document) order until sorted. +const waiting = new Set(); +let frame: number | null = null; +// New badges have arrived since the queue was last ordered. +let unsorted = false; +let batch = FIRST_BATCH; +// Staggered badges put on the page and not yet rendered: one render of a +// list. Counted afresh once that render's badges have all decided. +let arrivals = 0; +let countDone = false; +let idleWaiters: (() => void)[] = []; + +export function badgeArrived(): void { + arrivals++; +} + +/** + * At a staggered badge's first render: true when it arrived with too many + * others to draw them all at once. A list's render puts all its badges on + * the page before any of them renders (each renders in its own microtask, + * queued as it is attached), so every badge of that render gets the same + * answer; the count starts afresh in a microtask queued behind theirs. + */ +export function shouldStagger(): boolean { + if (!countDone) { + countDone = true; + queueMicrotask(() => { + arrivals = 0; + countDone = false; + }); + } + return arrivals > STAGGER_ABOVE; +} + +export function queueBadge(badge: StaggeredBadge): void { + waiting.add(badge); + unsorted = true; + schedule(); +} + +export function dequeueBadge(badge: StaggeredBadge): void { + if (!waiting.delete(badge) || waiting.size > 0) return; + if (frame !== null) { + cancelAnimationFrame(frame); + frame = null; + } + settle(); +} + +export function pendingBadges(): number { + return waiting.size; +} + +export function badgeFrameScheduled(): boolean { + return frame !== null; +} + +/** Resolves once every waiting badge has been drawn (or removed). */ +export function badgesDrawn(): Promise { + if (waiting.size === 0) return Promise.resolve(); + return new Promise((resolve) => idleWaiters.push(resolve)); +} + +function schedule(): void { + frame ??= requestAnimationFrame(() => { + frame = null; + void tick(); + }); +} + +function settle(): void { + batch = FIRST_BATCH; + const waiters = idleWaiters; + idleWaiters = []; + for (const resolve of waiters) resolve(); +} + +// How far a badge is from the visible part of the page, in pixels. +function offscreenDistance(badge: StaggeredBadge): number { + const r = badge.getBoundingClientRect(); + const h = window.innerHeight; + if (r.bottom < 0) return -r.bottom; + if (r.top > h) return r.top - h; + return 0; +} + +async function tick(): Promise { + if (unsorted) { + // The frame the new rows arrive in: order them nearest the viewport + // first and draw nothing yet, so that frame lays out only the rows. The + // rects are read before anything changes here, from the layout this + // frame needs anyway. + unsorted = false; + const order = [...waiting] + .map((badge) => ({ badge, d: offscreenDistance(badge) })) + .sort((a, b) => a.d - b.d); + waiting.clear(); + for (const { badge } of order) waiting.add(badge); + schedule(); + return; + } + const start = performance.now(); + const drawn: StaggeredBadge[] = []; + for (const badge of waiting) { + if (drawn.length >= batch) break; + waiting.delete(badge); + if (!badge.isConnected) continue; + badge.draw(); + drawn.push(badge); + } + if (drawn.length > 0) { + await Promise.all(drawn.map((b) => b.updateComplete)); + // Lay the new badges out now rather than after this callback, so their + // real cost is measured; the browser would do the same work this frame. + drawn[drawn.length - 1].getBoundingClientRect(); + const cost = performance.now() - start; + const scale = FRAME_BUDGET_MS / Math.max(cost, 0.25); + batch = Math.round( + Math.min( + MAX_BATCH, + Math.max(MIN_BATCH, batch * Math.min(2, Math.max(0.5, scale))), + ), + ); + } + if (waiting.size > 0) schedule(); + else if (frame === null) settle(); +} diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 98a6e409186..5a1634a487d 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -12,6 +12,10 @@ import { } from "@openfront/engine-api/game/GameTypes"; import { ClientID, TeamCountConfig } from "@openfront/engine-api/Schemas"; import { assignTeamsLobbyPreview } from "@openfront/engine-lib/game/TeamAssignment"; +import { + type LevelBadge, + unpackLevelBadge, +} from "@openfront/shared/LevelBadgeWire"; import { createRandomName } from "@openfront/shared/SharedUtil"; import { ClientInfo } from "@openfront/shared/WireSchemas"; import { LitElement, html } from "lit"; @@ -24,6 +28,7 @@ import { resolveTeamClanTag, translateText, } from "../Utils"; +import "./LevelBadge"; export interface TeamPreviewData { team: Team; @@ -172,7 +177,10 @@ export class LobbyTeamView extends LitElement { : ""}" > ${this.getClientDisplayName(client)} + >${this.renderLevelBadge( + client, + false, + )}${this.getClientDisplayName(client)} ${this.renderVerifiedBadge(client)} `, @@ -193,6 +201,7 @@ export class LobbyTeamView extends LitElement { const empty = this.teamPreview.filter( (t) => t.players.length === 0 && t.team !== ColoredTeams.Nations, ); + const listSlot = this.anyVisibleLevelBadge(this.activePlayers); return html`
@@ -213,7 +222,8 @@ export class LobbyTeamView extends LitElement { ? "bg-malibu-blue/20 border-sky-500/40" : "bg-gray-700/70 border-transparent"}" > - ${displayName} ${this.renderVerifiedBadge(client)} + ${this.renderLevelBadge(client, listSlot)}${displayName} + ${this.renderVerifiedBadge(client)} ${this.renderFriendBadge(client)}
`; }, @@ -277,7 +287,8 @@ export class LobbyTeamView extends LitElement { : ""}" > ${displayName} ${this.renderVerifiedBadge(client)} + >${this.renderLevelBadge(client, false)}${displayName} + ${this.renderVerifiedBadge(client)} ${this.renderFriendBadge(client)} ${this.renderRevealToggle(client.clientID)} @@ -324,6 +335,7 @@ export class LobbyTeamView extends LitElement { const clanTag = this._teamClanTags.get(preview.team) ?? null; const teamLabel = getTranslatedPlayerTeamLabel(preview.team, clanTag); + const badgeSlot = this.anyVisibleLevelBadge(preview.players); return html`
+ ${this.renderLevelBadge(p, badgeSlot)} ${displayName} ${this.renderVerifiedBadge(p)} ${this.renderFriendBadge(p)} @@ -595,6 +608,47 @@ export class LobbyTeamView extends LitElement { `; } + // The level badge this viewer may see for `client`: none for guests and + // server-anonymized entries (they arrive without one), and none for other + // players while the viewer has anonymous names on — the badge belongs to + // the real name, like the verified check above. + private visibleLevelBadge(client: ClientInfo): LevelBadge | undefined { + const anonymized = + this.userSettings.anonymousNames() && !this.isCurrentPlayer(client); + return anonymized ? undefined : unpackLevelBadge(client.levelBadge); + } + + private anyVisibleLevelBadge(clients: readonly ClientInfo[]): boolean { + return clients.some((c) => this.visibleLevelBadge(c) !== undefined); + } + + // The player's level badge, in front of their name. With `slot`, a player + // without one gets an empty space the same size instead, so the names in a + // column line up; column layouts pass it only when someone in the list has + // a visible badge, so a list with no badges renders exactly as before. + // `stagger`: a full lobby's badges arriving at once are drawn over the + // next few frames instead of all in one (see LevelBadgeFill); a few at a + // time, like a player joining, are drawn straight away. + private renderLevelBadge(client: ClientInfo, slot: boolean) { + const badge = this.visibleLevelBadge(client); + if (badge === undefined) { + return slot + ? html`` + : html``; + } + return html``; + } + // A mark for players on the viewer's friends list private renderFriendBadge(client: ClientInfo) { if (!this._viewerFriends.has(client.clientID)) return html``; diff --git a/src/client/hud/layers/PlayerPanel.ts b/src/client/hud/layers/PlayerPanel.ts index 1a24bbae37b..028ccbcdf79 100644 --- a/src/client/hud/layers/PlayerPanel.ts +++ b/src/client/hud/layers/PlayerPanel.ts @@ -9,13 +9,19 @@ import { } from "@openfront/engine-api/game/GameTypes"; import { Emoji, flattenedEmojiTable } from "@openfront/engine-api/Schemas"; import { renderNumber, renderTroops } from "@openfront/engine-lib/Format"; +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; import { assetUrl } from "@openfront/shared/AssetUrls"; import { EventBus } from "@openfront/shared/EventBus"; +import { + type LevelBadge, + packLevelBadge, +} from "@openfront/shared/LevelBadgeWire"; import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import Countries from "resources/countries.json" with { type: "json" }; -import { fetchLobbyListed } from "../../Api"; +import { fetchLobbyListed, getUserMe } from "../../Api"; import "../../components/EquippedCosmeticsRow"; +import "../../components/LevelBadge"; import { actionButton } from "../../components/ui/ActionButton"; import "../../components/ui/Divider"; import { Controller } from "../../Controller"; @@ -24,6 +30,7 @@ import { MouseUpEvent, SwapRocketDirectionEvent, } from "../../InputHandler"; +import { lobbyLevelBadge } from "../../LobbyRosterLevels"; import { PlayerReportedEvent, SendAllianceRequestIntentEvent, @@ -34,6 +41,7 @@ import { SendTargetPlayerIntentEvent, } from "../../Transport"; import { UIState } from "../../UIState"; +import { UserSettings } from "../../UserSettings"; import { renderDuration, showToast, translateText } from "../../Utils"; import { GameView, PlayerView } from "../../view"; import { ChatModal } from "./ChatModal"; @@ -54,6 +62,18 @@ const startTradingIcon = assetUrl("images/TradingIconWhite.svg"); const traitorIcon = assetUrl("images/TraitorIconLightRed.svg"); const breakAllianceIcon = assetUrl("images/TraitorIconWhite.svg"); +// The viewer's own badge from their /users/@me progress. Shown even when they +// hide their level from others, as their profile does. +export function ownLevelBadgeFrom( + me: UserMeResponse | false, +): LevelBadge | undefined { + const progress = me === false ? undefined : me.player.progress; + if (progress === undefined) return undefined; + const { level, prestige, legend } = progress; + const badge = { level, prestige, legend }; + return packLevelBadge(badge) === undefined ? undefined : badge; +} + @customElement("player-panel") export class PlayerPanel extends LitElement implements Controller { public g: GameView; @@ -65,6 +85,7 @@ export class PlayerPanel extends LitElement implements Controller { private tile: TileRef | null = null; private _profileForPlayerId: number | null = null; private kickedPlayerIDs = new Set(); + private userSettings = new UserSettings(); @state() private sendTarget: PlayerView | null = null; @state() private sendMode: "troops" | "gold" | "none" = "none"; @@ -82,6 +103,9 @@ export class PlayerPanel extends LitElement implements Controller { // Whether this game is a publicly listed lobby. Kept out of // GameStartInfo (never touches records), so it's fetched from the worker. @state() private gameListed = false; + // Fallback for the viewer's own badge when this tab never saw the lobby + // roster (a refresh or late join goes straight into the game). + private ownLevelBadge: LevelBadge | undefined; setRole(role: string | null): void { this.playerRole = role; @@ -134,6 +158,11 @@ export class PlayerPanel extends LitElement implements Controller { this.gameListed = listed; }); } + + // Memoised: normally answered from the profile loaded at sign-in. + void getUserMe().then((me) => { + this.ownLevelBadge = ownLevelBadgeFrom(me); + }); } async tick() { @@ -567,6 +596,22 @@ export class PlayerPanel extends LitElement implements Controller { `; } + // The level badge from the lobby roster, for human players only; the + // viewer's own falls back to their /users/@me. Hidden for anyone but the + // viewer's own player while anonymous names are on: their name is + // anonymized then (PlayerView.displayName), and the badge belongs to the + // real one. + private levelBadgeFor(other: PlayerView): LevelBadge | undefined { + if (other.type() !== PlayerType.Human) return undefined; + const clientID = other.clientID(); + const isSelf = clientID !== null && clientID === this.g.myClientID(); + if (!isSelf && this.userSettings.anonymousNames()) return undefined; + return ( + lobbyLevelBadge(this.g.gameID(), clientID) ?? + (isSelf ? this.ownLevelBadge : undefined) + ); + } + private renderIdentityRow(other: PlayerView, my: PlayerView) { const flagPath = other.cosmetics.flag; const flagCode = flagPath?.match(/\/flags\/(.+)\.svg$/)?.[1]; @@ -579,6 +624,7 @@ export class PlayerPanel extends LitElement implements Controller { other.type() === PlayerType.Human ? null : this.identityChipProps(other.type()); + const levelBadge = this.levelBadgeFor(other); return html`
@@ -594,9 +640,18 @@ export class PlayerPanel extends LitElement implements Controller { />` : ""} -
+
+ ${levelBadge + ? html`` + : ""}

${other.displayName()} diff --git a/src/server/Client.ts b/src/server/Client.ts index a6d23fbc7e2..22d5a305059 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -1,6 +1,10 @@ import { Tick } from "@openfront/engine-api/game/GameTypes"; import { ClientID, Winner } from "@openfront/engine-api/Schemas"; import { TokenPayload } from "@openfront/shared/ApiSchemas"; +import { + type LevelBadge, + packLevelBadge, +} from "@openfront/shared/LevelBadgeWire"; import { ClientPlatform, PlayerCosmetics } from "@openfront/shared/WireSchemas"; import WebSocket from "ws"; @@ -12,6 +16,10 @@ export class Client { // This client's winner vote, keyed on winner and stats (Consensus.voteKey). public reportedVote: { winner: Winner; key: string } | null = null; + // levelBadge as the lobby roster carries it (ClientInfo.levelBadge), packed + // once here rather than for every entry of every per-recipient broadcast. + public readonly wireLevelBadge: number | undefined; + constructor( public readonly clientID: ClientID, public readonly persistentID: string, @@ -33,5 +41,11 @@ export class Client { public readonly trusted: boolean = false, // Client-reported and unverified; metric dimension only. public readonly platform: ClientPlatform | "unknown" = "unknown", - ) {} + // From the server's /users/@me lookup at join (levelBadgeForPlayer); + // undefined for guests, players who hide their level, or when the API sent + // no progress. Display-only. + public readonly levelBadge: LevelBadge | undefined = undefined, + ) { + this.wireLevelBadge = packLevelBadge(levelBadge); + } } diff --git a/src/server/LevelBadge.ts b/src/server/LevelBadge.ts new file mode 100644 index 00000000000..495c60425dd --- /dev/null +++ b/src/server/LevelBadge.ts @@ -0,0 +1,30 @@ +import { Progress } from "@openfront/shared/ApiSchemas"; +import { LevelBadge, packLevelBadge } from "@openfront/shared/LevelBadgeWire"; + +// The roster's level badge for a joining player, from the `player` of the game +// server's own /users/@me lookup — never from anything the client sent. No +// badge when the player chose to hide their level (`levelHidden`); an API +// without the setting omits the field, which reads as shown. +export function levelBadgeForPlayer(player: { + progress?: Progress; + levelHidden?: boolean; +}): LevelBadge | undefined { + if (player.levelHidden === true) return undefined; + return levelBadgeFromProgress(player.progress); +} + +// The badge from a /users/@me `progress`. Undefined (no badge) when there is +// no progress: a guest, the API has progression off, or the field was +// malformed (UserMeResponseSchema already reads a malformed object as absent). +// +// The API types these as plain numbers, but the roster packs them into one +// small integer (packLevelBadge: level 1..100, prestige 0..10), so anything +// it cannot pack drops the badge rather than sending a wrong one. +export function levelBadgeFromProgress( + progress: Progress | undefined, +): LevelBadge | undefined { + if (progress === undefined) return undefined; + const { level, prestige, legend } = progress; + const badge = { level, prestige, legend }; + return packLevelBadge(badge) === undefined ? undefined : badge; +} diff --git a/src/server/NameVisibility.ts b/src/server/NameVisibility.ts index 2c906ee7017..ea684dd4371 100644 --- a/src/server/NameVisibility.ts +++ b/src/server/NameVisibility.ts @@ -211,6 +211,10 @@ export class NameVisibility { verified: c.cosmetics?.verified, spectator: c.spectator || undefined, teamIndex: this.view.teamIndex(c), + // Only beside a real name — the anonymized branch above omits it, as a + // level beside an anonymous name would point out the veterans. Kept on + // a teammate reveal: it describes the teammate alone, like `verified`. + levelBadge: c.wireLevelBadge, }; }); } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8f9cc168e91..125013e3977 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,6 +7,7 @@ import { ClientMessage, ClientPlatformSchema, HOSTED_LOBBY_AUTO_START_MS, + LevelBadge, MAX_HOSTED_LOBBIES, MAX_HOSTED_LOBBY_PLAYERS, MIN_HOSTED_LOBBY_AUTO_START_MS, @@ -36,6 +37,7 @@ import { registerGamePreviewRoute } from "./GamePreviewRoute"; import { GamePhase, type GameServer } from "./GameServer"; import { isSteamAuthenticated, planJoinVerify, verifyJoin } from "./JoinVerify"; import { getUserMe, userMeFailureClose, verifyClientToken } from "./jwt"; +import { levelBadgeForPlayer } from "./LevelBadge"; import { payForLobbyQueue, queueListedLobby } from "./LobbyQueuePayment"; import { logger } from "./Logger"; import { resolveVerifiedJoin } from "./Privilege"; @@ -711,6 +713,7 @@ export async function startWorker() { let friends: string[] = []; let ownedClanTags: string[] = []; let trusted = false; + let levelBadge: LevelBadge | undefined; let accountUsername: | { username?: string | null; @@ -744,6 +747,9 @@ export async function startWorker() { ownedClanTags = result.response.player.clans?.map((c) => c.tag) ?? []; accountUsername = result.response.player; trusted = result.response.player.trustTier === "trusted"; + // Display-only: the roster badge comes from this lookup alone, never + // from the join message. None when the player hides their level. + levelBadge = levelBadgeForPlayer(result.response.player); if (allowedFlares !== undefined) { const allowed = @@ -828,6 +834,7 @@ export async function startWorker() { clientMsg.spectator === true, trusted, clientMsg.platform, + levelBadge, ); const joinResult = gm.joinClient(client, clientMsg.gameID); diff --git a/tests/client/LobbyRosterLevels.test.ts b/tests/client/LobbyRosterLevels.test.ts new file mode 100644 index 00000000000..9c55c8cbf55 --- /dev/null +++ b/tests/client/LobbyRosterLevels.test.ts @@ -0,0 +1,93 @@ +import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { describe, expect, it } from "vitest"; +import { + lobbyLevelBadge, + rememberLobbyRoster, +} from "../../src/client/LobbyRosterLevels"; + +const VET = { level: 100, prestige: 10, legend: true }; +// As the roster carries it. +const VET_WIRE = packLevelBadge(VET)!; + +describe("lobbyLevelBadge", () => { + it("returns the badge from the last roster of that game", () => { + rememberLobbyRoster("game0001", [ + { + clientID: "vet00001", + username: "Veteran", + clanTag: null, + levelBadge: VET_WIRE, + }, + { clientID: "guest001", username: "Guest", clanTag: null }, + ]); + expect(lobbyLevelBadge("game0001", "vet00001")).toEqual(VET); + expect(lobbyLevelBadge("game0001", "guest001")).toBeUndefined(); + expect(lobbyLevelBadge("game0001", "nobody01")).toBeUndefined(); + expect(lobbyLevelBadge("game0001", null)).toBeUndefined(); + }); + + it("never answers for a different game", () => { + rememberLobbyRoster("game0001", [ + { + clientID: "vet00001", + username: "Veteran", + clanTag: null, + levelBadge: VET_WIRE, + }, + ]); + expect(lobbyLevelBadge("game0002", "vet00001")).toBeUndefined(); + }); + + it("each broadcast replaces the last: a badge dropped by the server goes", () => { + // e.g. the host turns anonymizeNames on mid-lobby. + rememberLobbyRoster("game0001", [ + { + clientID: "vet00001", + username: "Veteran", + clanTag: null, + levelBadge: VET_WIRE, + }, + ]); + rememberLobbyRoster("game0001", [ + { clientID: "vet00001", username: "Quiet Otter", clanTag: null }, + ]); + expect(lobbyLevelBadge("game0001", "vet00001")).toBeUndefined(); + }); + + it("tolerates a roster with no clients", () => { + rememberLobbyRoster("game0003", undefined); + expect(lobbyLevelBadge("game0003", "vet00001")).toBeUndefined(); + }); +}); + +describe("lobbyLevelBadge decodes the packed roster value", () => { + it.each([ + { level: 1, prestige: 0, legend: false }, + { level: 57, prestige: 3, legend: false }, + { level: 100, prestige: 10, legend: false }, + { level: 100, prestige: 10, legend: true }, + ])("back to %o", (badge) => { + rememberLobbyRoster("game0004", [ + { + clientID: "pl000001", + username: "Player", + clanTag: null, + levelBadge: packLevelBadge(badge), + }, + ]); + expect(lobbyLevelBadge("game0004", "pl000001")).toEqual(badge); + }); + + it("reads a value no server sends as no badge, without throwing", () => { + const clients = [0, 101, 11 * 128 + 5, 4096 + 5, -1, 1.5].map((v, i) => ({ + clientID: `bad0000${i}`, + username: "Odd", + clanTag: null, + levelBadge: v, + })); + rememberLobbyRoster("game0005", clients); + for (const c of clients) { + expect(lobbyLevelBadge("game0005", c.clientID)).toBeUndefined(); + } + }); +}); diff --git a/tests/client/components/LobbyPlayerView.badgeFill.test.ts b/tests/client/components/LobbyPlayerView.badgeFill.test.ts new file mode 100644 index 00000000000..b42fb4a5965 --- /dev/null +++ b/tests/client/components/LobbyPlayerView.badgeFill.test.ts @@ -0,0 +1,276 @@ +import { GameMode } from "@openfront/engine-api/game/GameTypes"; +import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; +import type { ClientInfo } from "@openfront/shared/WireSchemas"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LevelBadge as LevelBadgeElement } from "../../../src/client/components/LevelBadge"; +import { + badgeFrameScheduled, + badgesDrawn, + pendingBadges, + STAGGER_ABOVE, +} from "../../../src/client/components/LevelBadgeFill"; +import "../../../src/client/components/LobbyPlayerView"; +import type { LobbyTeamView } from "../../../src/client/components/LobbyPlayerView"; +import { UserSettings } from "../../../src/client/UserSettings"; + +// A full lobby's badges arrive in one render; past a handful they are drawn +// over the following frames (LevelBadgeFill) instead of all in one. + +function roster(n: number, from = 0): ClientInfo[] { + return Array.from({ length: n }, (_, k) => { + const i = from + k; + return { + clientID: `c${i}`, + username: `Player${i}`, + clanTag: null, + levelBadge: packLevelBadge({ + level: 1 + ((i * 37) % 100), + prestige: i % 11, + legend: i === 7, + }), + }; + }); +} + +const badges = (view: Element) => + Array.from(view.querySelectorAll("level-badge")); +const waitingSquares = (view: Element) => + view.querySelectorAll("level-badge > [data-badge-waiting]"); +const drawnBadges = (view: Element) => + view.querySelectorAll("level-badge > svg[role=img]"); +const ariaHiddenBadges = (view: Element) => + view.querySelectorAll('level-badge[aria-hidden="true"]'); + +// Mounts the view and lets every badge finish its first render, but no +// animation frame: what the first paint shows. +async function mount( + clients: ClientInfo[], + props: Partial = {}, +): Promise { + const view = document.createElement("lobby-player-view") as LobbyTeamView; + view.gameMode = GameMode.FFA; + view.teamCount = 2; + Object.assign(view, props, { clients }); + document.body.append(view); + await view.updateComplete; + await Promise.all(badges(view).map((b) => b.updateComplete)); + return view; +} + +async function update(view: LobbyTeamView, clients: ClientInfo[]) { + view.clients = clients; + await view.updateComplete; + await Promise.all(badges(view).map((b) => b.updateComplete)); +} + +// A 150-player roster is slow to build in jsdom (300 badges in team mode), +// more so with the rest of the suite running alongside. +describe("lobby badges arriving many at once", { timeout: 20_000 }, () => { + // jsdom's frames are 16 ms apart and its layout-free "frames" are slow to + // build badges in, which would make a 300-badge fill take seconds; frames + // here come back as soon as the event loop is free. + let cancel: ReturnType; + beforeEach(() => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation( + (cb) => setTimeout(() => cb(performance.now()), 0) as unknown as number, + ); + cancel = vi + .spyOn(window, "cancelAnimationFrame") + .mockImplementation((id) => clearTimeout(id)); + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + }); + + afterEach(async () => { + document.body.replaceChildren(); + await badgesDrawn(); + vi.restoreAllMocks(); + }); + + for (const [mode, perPlayer] of [ + [GameMode.FFA, 1], + [GameMode.Team, 2], + ] as const) { + describe(`${mode}`, () => { + it("draws 150 players' badges over later frames, then all of them", async () => { + const view = await mount(roster(150), { gameMode: mode }); + + // First paint: every row has its badge element, holding its square. + expect(badges(view).length).toBe(150 * perPlayer); + expect(waitingSquares(view).length).toBe(150 * perPlayer); + expect(drawnBadges(view).length).toBe(0); + expect(badgeFrameScheduled()).toBe(true); + // A waiting badge has no name yet, so it is out of the + // accessibility tree until drawn. + expect(ariaHiddenBadges(view).length).toBe(150 * perPlayer); + + await badgesDrawn(); + + expect(waitingSquares(view).length).toBe(0); + expect(drawnBadges(view).length).toBe(150 * perPlayer); + expect(ariaHiddenBadges(view).length).toBe(0); + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + // Each one is the player's own badge. + const labels = badges(view) + .slice(0, 3) + .map((b) => b.querySelector("svg")?.getAttribute("aria-label")); + expect(labels).toEqual([ + "progression.level", + "progression.level, progression.prestige", + "progression.level, progression.prestige", + ]); + }); + }); + } + + it("draws an 8-player FFA lobby straight away", async () => { + expect(8).toBeLessThanOrEqual(STAGGER_ABOVE); + const view = await mount(roster(8)); + + expect(drawnBadges(view).length).toBe(8); + expect(waitingSquares(view).length).toBe(0); + expect(ariaHiddenBadges(view).length).toBe(0); + expect(pendingBadges()).toBe(0); + }); + + it("staggers an 8-player team lobby's 16 badges and completes", async () => { + const view = await mount(roster(8), { gameMode: GameMode.Team }); + + expect(waitingSquares(view).length).toBe(16); + await badgesDrawn(); + expect(drawnBadges(view).length).toBe(16); + expect(waitingSquares(view).length).toBe(0); + }); + + it("holds exactly the drawn badge's box while waiting", async () => { + const view = await mount(roster(150)); + const first = badges(view)[0]; + const square = first.querySelector("[data-badge-waiting]")!; + const hostClass = first.className; + + expect(square.style.width).toBe("24px"); + expect(square.style.height).toBe("24px"); + expect(square.classList.contains("block")).toBe(true); + expect(square.getAttribute("aria-hidden")).toBe("true"); + // The badge sits in front of the name from the first paint on. + expect(first.parentElement!.firstElementChild).toBe(first); + expect(first.parentElement!.textContent).toContain("Player0"); + + await badgesDrawn(); + + const svg = first.querySelector("svg")!; + expect(svg.getAttribute("width")).toBe("24"); + expect(svg.getAttribute("height")).toBe("24"); + expect(svg.classList.contains("block")).toBe(true); + // Same host element, same classes: the row around it is unchanged. + expect(badges(view)[0]).toBe(first); + expect(first.className).toBe(hostClass); + }); + + it("draws a single join straight away, also during a fill", async () => { + const players = roster(150); + const view = await mount(players); + expect(pendingBadges()).toBe(150); + + await update(view, [...players, ...roster(1, 150)]); + + const joined = badges(view)[badges(view).length - 1]; + expect(joined.querySelector("svg[role=img]")).not.toBeNull(); + expect(pendingBadges()).toBe(150); + + await badgesDrawn(); + await update(view, [...players, ...roster(2, 150)]); + const last = badges(view)[badges(view).length - 1]; + expect(last.querySelector("svg[role=img]")).not.toBeNull(); + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + }); + + it("redraws a changed badge on a drawn list straight away", async () => { + const players = roster(150); + const view = await mount(players); + await badgesDrawn(); + + const changed = players.map((c, i) => + i === 3 + ? { + ...c, + levelBadge: packLevelBadge({ + level: 1, + prestige: 0, + legend: true, + }), + } + : c, + ); + await update(view, changed); + + expect( + badges(view)[3].querySelector("svg")?.getAttribute("data-level-band"), + ).toBe("legend"); + expect(pendingBadges()).toBe(0); + }); + + it("shows no other player's badge with anonymous names on", async () => { + new UserSettings().toggleRandomName(); + const view = await mount(roster(150), { currentClientID: "c0" }); + + // Only the viewer's own badge, drawn at once (it arrives alone). + expect(badges(view).length).toBe(1); + expect(drawnBadges(view).length).toBe(1); + expect(pendingBadges()).toBe(0); + }); + + it("shows no badges, and holds no squares, for an anonymised roster", async () => { + // The server sends anonymised entries without a level badge. + const anonymous = roster(150).map(({ levelBadge: _, ...c }) => c); + const view = await mount(anonymous, { + gameMode: GameMode.Team, + anonymizeNames: true, + }); + + expect(badges(view).length).toBe(0); + expect(view.querySelector(".lobby-level-slot")).toBeNull(); + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + }); + + it("cancels the pending frames when the list is removed mid-fill", async () => { + cancel.mockClear(); + const view = await mount(roster(150), { gameMode: GameMode.Team }); + // Let the fill get going: the ordering frame, then a drawing frame. + await new Promise((r) => requestAnimationFrame(r)); + await new Promise((r) => requestAnimationFrame(r)); + await new Promise((r) => setTimeout(r, 0)); + const waitingBefore = pendingBadges(); + expect(waitingBefore).toBeGreaterThan(0); + expect(waitingBefore).toBeLessThan(300); + + view.remove(); + + expect(pendingBadges()).toBe(0); + expect(badgeFrameScheduled()).toBe(false); + expect(cancel).toHaveBeenCalled(); + // Nothing is drawn into the removed list afterwards. + const stillWaiting = waitingSquares(view).length; + for (let i = 0; i < 3; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + expect(waitingSquares(view).length).toBe(stillWaiting); + await expect(badgesDrawn()).resolves.toBeUndefined(); + }); + + it("draws a list re-attached mid-fill once it is back", async () => { + const view = await mount(roster(150)); + view.remove(); + expect(pendingBadges()).toBe(0); + + document.body.append(view); + expect(pendingBadges()).toBe(150); + await badgesDrawn(); + expect(drawnBadges(view).length).toBe(150); + }); +}); diff --git a/tests/client/components/LobbyPlayerView.levelBadge.test.ts b/tests/client/components/LobbyPlayerView.levelBadge.test.ts new file mode 100644 index 00000000000..c1f8142adb9 --- /dev/null +++ b/tests/client/components/LobbyPlayerView.levelBadge.test.ts @@ -0,0 +1,227 @@ +import { + GameMode, + HumansVsNations, +} from "@openfront/engine-api/game/GameTypes"; +import type { LevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; +import type { ClientInfo } from "@openfront/shared/WireSchemas"; +import { beforeEach, describe, expect, it } from "vitest"; +import "../../../src/client/components/LobbyPlayerView"; +import type { LobbyTeamView } from "../../../src/client/components/LobbyPlayerView"; +import { UserSettings } from "../../../src/client/UserSettings"; + +const BADGE: LevelBadge = { level: 42, prestige: 2, legend: false }; +// As the roster carries it. +const BADGE_WIRE = packLevelBadge(BADGE)!; + +function client( + clientID: string, + overrides: Partial = {}, +): ClientInfo { + return { + clientID, + username: clientID, + clanTag: null, + ...overrides, + }; +} + +async function mount( + props: Partial & { clients: ClientInfo[] }, +): Promise { + const view = document.createElement("lobby-player-view") as LobbyTeamView; + view.gameMode = GameMode.FFA; + Object.assign(view, props); + document.body.append(view); + await view.updateComplete; + return view; +} + +// The visible pieces of one name container, in DOM order: "badge:" +// for a level badge, "slot" for the empty alignment slot, and the trimmed +// text of each non-empty text node. Icons (verified check, friend mark) and +// the host label sit after the name and aren't needed here. +function tokens(container: Element): string[] { + const out: string[] = []; + for (const node of Array.from(container.childNodes)) { + if (node instanceof Element && node.tagName === "LEVEL-BADGE") { + out.push(`badge:${node.getAttribute("size")}`); + } else if ( + node instanceof Element && + node.classList.contains("lobby-level-slot") + ) { + out.push("slot"); + } else if (node instanceof Element && node.matches("span.truncate")) { + out.push(node.textContent?.trim() ?? ""); + } else if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent?.trim(); + if (text) out.push(text); + } + } + return out; +} + +const pills = (view: LobbyTeamView) => + Array.from(view.querySelectorAll(".player-tag > span.text-white")).map( + tokens, + ); +// Team mode: the "Players" column on the left. +const playerRows = (view: LobbyTeamView) => + Array.from(view.querySelectorAll("div.break-words")).map(tokens); +// Team mode: the rows inside each team card, grouped per card. +const cardRows = (view: LobbyTeamView) => + Array.from(view.querySelectorAll("div.rounded-xl")) + .map((card) => + Array.from(card.querySelectorAll("span.flex.items-center.min-w-0")).map( + tokens, + ), + ) + .filter((rows) => rows.length > 0); + +describe("lobby level badges", () => { + beforeEach(() => { + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + document.body.replaceChildren(); + }); + + it("puts a 24px badge in front of the name in FFA pills", async () => { + const view = await mount({ + currentClientID: "me", + clients: [client("me", { levelBadge: BADGE_WIRE }), client("guest")], + }); + + // No alignment slot in the wrapped pills. + expect(pills(view)).toEqual([["badge:24", "me"], ["guest"]]); + }); + + it("passes the badge's level, prestige and legend through", async () => { + const view = await mount({ + clients: [ + client("vet", { + levelBadge: packLevelBadge({ level: 7, prestige: 3, legend: true }), + }), + ], + }); + + const badge = view.querySelector("level-badge") as HTMLElement & { + level: number; + prestige: number; + legend: boolean; + }; + expect(badge.level).toBe(7); + expect(badge.prestige).toBe(3); + expect(badge.legend).toBe(true); + }); + + it("puts the badge in front of the name in spectator pills", async () => { + const view = await mount({ + clients: [ + client("player"), + client("watcher", { spectator: true, levelBadge: BADGE_WIRE }), + client("lurker", { spectator: true }), + ], + }); + + expect(pills(view)).toEqual([ + ["player"], + ["badge:24", "watcher"], + ["lurker"], + ]); + }); + + it("aligns names in the team-mode lists when someone has a badge", async () => { + const view = await mount({ + gameMode: GameMode.Team, + teamCount: HumansVsNations, + clients: [client("vet", { levelBadge: BADGE_WIRE }), client("guest")], + }); + + expect(playerRows(view)).toEqual([ + ["badge:24", "vet"], + ["slot", "guest"], + ]); + expect(cardRows(view)).toEqual([ + [ + ["badge:24", "vet"], + ["slot", "guest"], + ], + ]); + const slot = view.querySelector(".lobby-level-slot"); + expect(slot?.getAttribute("aria-hidden")).toBe("true"); + expect(slot?.classList.contains("w-6")).toBe(true); + }); + + it("adds no slots when nobody in the list has a badge", async () => { + const view = await mount({ + gameMode: GameMode.Team, + teamCount: HumansVsNations, + clients: [client("a"), client("b")], + }); + + expect(playerRows(view)).toEqual([["a"], ["b"]]); + expect(cardRows(view)).toEqual([[["a"], ["b"]]]); + expect(view.querySelector(".lobby-level-slot")).toBeNull(); + expect(view.querySelector("level-badge")).toBeNull(); + }); + + it("decides the slot per team card", async () => { + const view = await mount({ + gameMode: GameMode.Team, + teamCount: 2, + clients: [ + client("vet", { levelBadge: BADGE_WIRE }), + client("a"), + client("b"), + client("c"), + ], + }); + + const cards = cardRows(view); + expect(cards.length).toBe(2); + for (const rows of cards) { + const hasBadge = rows.some((r) => r[0] === "badge:24"); + for (const row of rows) { + if (row[0] === "badge:24") continue; + // A badge-less row gets a slot exactly when its card has a badge. + expect(row[0] === "slot").toBe(hasBadge); + } + } + expect(cards.flat().filter((r) => r[0] === "badge:24").length).toBe(1); + }); + + describe("with anonymous names on", () => { + beforeEach(() => { + new UserSettings().toggleRandomName(); + }); + + it("hides other players' badges but keeps the viewer's own", async () => { + const view = await mount({ + currentClientID: "me", + clients: [ + client("me", { levelBadge: BADGE_WIRE }), + client("other", { levelBadge: BADGE_WIRE }), + ], + }); + + const rows = pills(view); + expect(rows[0]).toEqual(["badge:24", "me"]); + expect(rows[1][0]).not.toBe("badge:24"); + expect(view.querySelectorAll("level-badge").length).toBe(1); + }); + + it("counts hidden badges as none for the alignment slot", async () => { + const view = await mount({ + gameMode: GameMode.Team, + teamCount: HumansVsNations, + currentClientID: "me", + clients: [client("me"), client("other", { levelBadge: BADGE_WIRE })], + }); + + expect(view.querySelector("level-badge")).toBeNull(); + expect(view.querySelector(".lobby-level-slot")).toBeNull(); + }); + }); +}); diff --git a/tests/client/graphics/layers/PlayerPanelActions.test.ts b/tests/client/graphics/layers/PlayerPanelActions.test.ts index 428fbd1ff8c..264068f2fba 100644 --- a/tests/client/graphics/layers/PlayerPanelActions.test.ts +++ b/tests/client/graphics/layers/PlayerPanelActions.test.ts @@ -29,6 +29,8 @@ vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ actionButton: vi.fn((props: unknown) => props), })); +vi.mock("../../../../src/client/components/LevelBadge", () => ({})); + vi.mock("../../../../src/client/InGameModal", () => ({ showInGameConfirm: vi.fn(), showInGameAlert: vi.fn(), diff --git a/tests/client/graphics/layers/PlayerPanelAllianceCountdown.test.ts b/tests/client/graphics/layers/PlayerPanelAllianceCountdown.test.ts index 8aeeba6d760..caff061969b 100644 --- a/tests/client/graphics/layers/PlayerPanelAllianceCountdown.test.ts +++ b/tests/client/graphics/layers/PlayerPanelAllianceCountdown.test.ts @@ -28,6 +28,8 @@ vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ actionButton: vi.fn((props: unknown) => props), })); +vi.mock("../../../../src/client/components/LevelBadge", () => ({})); + vi.mock("../../../../src/client/InGameModal", () => ({ showInGameConfirm: vi.fn(), showInGameAlert: vi.fn(), diff --git a/tests/client/graphics/layers/PlayerPanelKick.test.ts b/tests/client/graphics/layers/PlayerPanelKick.test.ts index ac5b7678b5c..62cd1f796ab 100644 --- a/tests/client/graphics/layers/PlayerPanelKick.test.ts +++ b/tests/client/graphics/layers/PlayerPanelKick.test.ts @@ -29,6 +29,8 @@ vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ actionButton: vi.fn((props: unknown) => props), })); +vi.mock("../../../../src/client/components/LevelBadge", () => ({})); + vi.mock("../../../../src/client/InGameModal", () => ({ showInGameConfirm: vi.fn(), showInGameAlert: vi.fn(), diff --git a/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts b/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts new file mode 100644 index 00000000000..f0173e842cd --- /dev/null +++ b/tests/client/graphics/layers/PlayerPanelLevelBadge.test.ts @@ -0,0 +1,284 @@ +vi.mock("lit", () => ({ + html: (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + }), + LitElement: class extends EventTarget { + requestUpdate() {} + }, +})); + +vi.mock("lit/decorators.js", () => ({ + customElement: () => (clazz: unknown) => clazz, + state: () => () => {}, + property: () => () => {}, + query: () => () => {}, +})); + +vi.mock("../../../../src/client/Utils", () => ({ + translateText: vi.fn((key: string) => key), + renderDuration: vi.fn(), + renderNumber: vi.fn(), + renderTroops: vi.fn(), + showToast: vi.fn(), +})); + +vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ + actionButton: vi.fn((props: unknown) => props), +})); + +vi.mock("../../../../src/client/components/LevelBadge", () => ({})); + +const getUserMe = vi.hoisted(() => + vi.fn<() => Promise>(async () => false), +); +vi.mock("../../../../src/client/Api", async (importOriginal) => ({ + ...(await importOriginal()), + getUserMe, +})); + +vi.mock("../../../../src/client/InGameModal", () => ({ + showInGameConfirm: vi.fn(), + showInGameAlert: vi.fn(), +})); + +import { GameType, PlayerType } from "@openfront/engine-api/game/GameTypes"; +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; +import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { + ownLevelBadgeFrom, + PlayerPanel, +} from "../../../../src/client/hud/layers/PlayerPanel"; +import { rememberLobbyRoster } from "../../../../src/client/LobbyRosterLevels"; +import { UserSettings } from "../../../../src/client/UserSettings"; +import { PlayerView } from "../../../../src/client/view"; + +// With lit mocked, html`` yields { strings, values }: flatten one back into +// markup so the identity row can be checked as text. +function flatten(value: unknown): string { + if (Array.isArray(value)) return value.map(flatten).join(""); + if (value && typeof value === "object" && "strings" in value) { + const t = value as { strings: readonly string[]; values: unknown[] }; + return t.strings + .map((s, i) => s + (i < t.values.length ? flatten(t.values[i]) : "")) + .join(""); + } + if (typeof value === "function" || value === null || value === undefined) { + return ""; + } + return String(value); +} + +function player( + clientID: string | null, + type: PlayerType = PlayerType.Human, +): PlayerView { + return { + id: () => clientID ?? "bot", + displayName: () => `name-${clientID}`, + type: () => type, + clientID: () => clientID, + cosmetics: { flag: "/flags/gb.svg" }, + } as unknown as PlayerView; +} + +describe("PlayerPanel - level badge", () => { + let panel: PlayerPanel; + + beforeEach(() => { + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + rememberLobbyRoster("game-1", [ + { + clientID: "me", + username: "me", + clanTag: null, + levelBadge: packLevelBadge({ level: 12, prestige: 0, legend: false }), + }, + { + clientID: "vet", + username: "vet", + clanTag: null, + levelBadge: packLevelBadge({ level: 42, prestige: 3, legend: true }), + }, + { clientID: "guest", username: "guest", clanTag: null }, + ]); + panel = new PlayerPanel(); + (panel as any).g = { + gameID: () => "game-1", + myClientID: () => "me", + }; + (panel as any).renderTraitorBadge = () => ""; + (panel as any).renderRelationPillIfNation = () => ""; + }); + + const row = (other: PlayerView) => + flatten((panel as any).renderIdentityRow(other, player("me"))); + + test("shows a 28px badge between the flag and the name", () => { + const out = row(player("vet")); + + const flag = out.indexOf("")); + expect(tag).toContain('size="28"'); + expect(tag).toContain(".level=42"); + expect(tag).toContain(".prestige=3"); + expect(tag).toContain("?legend=true"); + }); + + test("keeps the name truncating beside the badge", () => { + const out = row(player("vet")); + const h2 = out.slice(out.indexOf("")); + expect(h2).toContain("min-w-0"); + expect(h2).toContain("truncate"); + }); + + test("shows nothing for a human with no roster badge", () => { + expect(row(player("guest"))).not.toContain(" { + expect(row(player(null, PlayerType.Bot))).not.toContain(" { + (panel as any).g.gameID = () => "game-2"; + expect(row(player("vet"))).not.toContain(" { + new UserSettings().toggleRandomName(); + + expect(row(player("vet"))).not.toContain("): UserMeResponse { + return { + user: {}, + player: { publicId: "me-pub", ...player }, + } as unknown as UserMeResponse; +} + +describe("PlayerPanel - your own badge without the lobby roster", () => { + let panel: PlayerPanel; + + // A refresh or late join: this tab saw only another game's lobby. + async function initPanel(gameID = "game-2") { + panel = new PlayerPanel(); + (panel as any).g = { + gameID: () => gameID, + myClientID: () => "me", + config: () => ({ + gameConfig: () => ({ gameType: GameType.Public }), + }), + }; + (panel as any).eventBus = { on: vi.fn() }; + (panel as any).renderTraitorBadge = () => ""; + (panel as any).renderRelationPillIfNation = () => ""; + panel.init(); + await getUserMe.mock.results[0]?.value; + await Promise.resolve(); + } + + const row = (other: PlayerView) => + flatten((panel as any).renderIdentityRow(other, player("me"))); + + beforeEach(() => { + localStorage.clear(); + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); + getUserMe.mockReset(); + rememberLobbyRoster("game-1", [ + { + clientID: "me", + username: "me", + clanTag: null, + levelBadge: packLevelBadge({ level: 12, prestige: 0, legend: false }), + }, + { + clientID: "vet", + username: "vet", + clanTag: null, + levelBadge: packLevelBadge({ level: 42, prestige: 3, legend: true }), + }, + ]); + }); + + test("shows your own badge from /users/@me, and nobody else's", async () => { + getUserMe.mockResolvedValue(userMe({ progress })); + await initPanel(); + + const own = row(player("me")); + expect(own).toContain(" { + getUserMe.mockResolvedValue(userMe({ progress })); + await initPanel("game-1"); + expect(row(player("me"))).toContain(".level=12"); + }); + + test("still shows you your own badge when you hide your level", async () => { + getUserMe.mockResolvedValue(userMe({ progress, levelHidden: true })); + await initPanel(); + expect(row(player("me"))).toContain(".level=37"); + }); + + test("shows nothing signed out or without progress", async () => { + getUserMe.mockResolvedValue(false); + await initPanel(); + expect(row(player("me"))).not.toContain(" { + test("is the badge from /users/@me progress", () => { + expect(ownLevelBadgeFrom(userMe({ progress }))).toEqual({ + level: 37, + prestige: 2, + legend: false, + }); + }); + + test("drops values the badge cannot show, like the server does", () => { + for (const bad of [{ level: 0 }, { level: 101 }, { prestige: 11 }]) { + expect( + ownLevelBadgeFrom(userMe({ progress: { ...progress, ...bad } })), + ).toBeUndefined(); + } + }); +}); diff --git a/tests/core/ClientInfoLevelBadgeWire.test.ts b/tests/core/ClientInfoLevelBadgeWire.test.ts new file mode 100644 index 00000000000..860ce5784bc --- /dev/null +++ b/tests/core/ClientInfoLevelBadgeWire.test.ts @@ -0,0 +1,204 @@ +import { ClanTagSchema, UsernameSchema } from "@openfront/engine-api/Schemas"; +import { + LevelBadge, + packLevelBadge, + unpackLevelBadge, +} from "@openfront/shared/LevelBadgeWire"; +import { + ClientInfo, + ClientInfoSchema, + ClientJoinMessageSchema, + ServerMessage, +} from "@openfront/shared/WireSchemas"; +import { + decodeClientMessage, + decodeServerMessage, + encodeClientMessage, + encodeServerMessage, +} from "@openfront/shared/ZbinWire"; +import { zb } from "@openfront/zbin"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +const pack = (badge: LevelBadge): number => packLevelBadge(badge)!; + +// The roster's level badge rides lobby_info as the last, optional field of +// ClientInfo. These pin the wire contract that comment in Schemas.ts promises: +// it round-trips, an entry without it is byte-identical to the pre-badge +// layout, and a frame encoded before the field existed still decodes. + +const NewClientInfo = zb.object(ClientInfoSchema.shape); +// A frozen copy of ClientInfo as it was before levelBadge was appended — +// spelled out, not derived from the live shape, so a field added after the +// badge (which would spill into a second header byte) fails these tests. +const PreBadgeClientInfo = zb.object({ + clientID: z.string(), + username: UsernameSchema, + clanTag: ClanTagSchema, + friends: z.array(z.string()).optional(), + verified: z.boolean().optional(), + spectator: z.boolean().optional(), + teamIndex: zb.uint().optional(), +}); + +const PLAIN: ClientInfo = { + clientID: "aB3dEf7h", + username: "alpha", + clanTag: null, +}; +// Every other optional/nullable/boolean field set, so all seven pre-badge +// header bits are in play. +const FULL: ClientInfo = { + clientID: "Xk9mNp2q", + username: "bravo", + clanTag: "XY", + friends: ["aB3dEf7h"], + verified: true, + spectator: true, + teamIndex: 3, +}; + +describe("ClientInfo.levelBadge on the wire", () => { + it.each([ + { level: 1, prestige: 0, legend: false }, + { level: 37, prestige: 2, legend: false }, + { level: 100, prestige: 10, legend: true }, + ])("round-trips a badge %o as one packed integer", (badge) => { + const levelBadge = pack(badge); + for (const base of [PLAIN, FULL]) { + const entry = { ...base, levelBadge }; + const back = NewClientInfo.parseBytes(NewClientInfo.serialize(entry)); + expect(back).toEqual(entry); + expect(unpackLevelBadge(back.levelBadge)).toEqual(badge); + } + }); + + it("costs one byte per badge at prestige 0 and two at most", () => { + const size = (levelBadge?: number) => + NewClientInfo.serialize({ ...PLAIN, levelBadge }).byteLength; + const none = size(); + expect(size(pack({ level: 1, prestige: 0, legend: false })) - none).toBe(1); + expect(size(pack({ level: 100, prestige: 0, legend: false })) - none).toBe( + 1, + ); + expect(size(pack({ level: 1, prestige: 1, legend: false })) - none).toBe(2); + expect(size(pack({ level: 100, prestige: 10, legend: true })) - none).toBe( + 2, + ); + }); + + it("round-trips an entry without one, leaving it absent", () => { + for (const entry of [PLAIN, FULL]) { + const back = NewClientInfo.parseBytes(NewClientInfo.serialize(entry)); + expect(back).toEqual(entry); + expect(back.levelBadge).toBeUndefined(); + } + }); + + it("encodes an entry without a badge byte-identically to the pre-badge layout", () => { + // The badge took the last free bit of the one-byte header; a second header + // byte would change every entry, badge or not. + for (const entry of [PLAIN, FULL]) { + expect(NewClientInfo.serialize(entry)).toEqual( + PreBadgeClientInfo.serialize(entry), + ); + } + }); + + it("decodes an entry encoded before the badge existed", () => { + for (const entry of [PLAIN, FULL]) { + const old = PreBadgeClientInfo.serialize(entry); + expect(NewClientInfo.parseBytes(old)).toEqual(entry); + } + }); + + it("rejects a badge the codec cannot carry rather than mis-encoding it", () => { + // Never reached in practice: the server only sends packLevelBadge output. + for (const levelBadge of [1.5, -1]) { + expect(() => NewClientInfo.serialize({ ...PLAIN, levelBadge })).toThrow(); + } + }); + + it("decodes a packed value no server sends, which the accessor then drops", () => { + // The schema puts no range on the field, so one bad entry cannot fail + // the whole roster's parse; unpackLevelBadge reads it as no badge. + for (const levelBadge of [0, 101, 11 * 128 + 5, 4096 + 5, 2 ** 40]) { + const back = NewClientInfo.parseBytes( + NewClientInfo.serialize({ ...PLAIN, levelBadge }), + ); + expect(back.levelBadge).toBe(levelBadge); + expect(unpackLevelBadge(back.levelBadge)).toBeUndefined(); + } + }); + + it("carries badges through a full lobby_info frame", () => { + const msg: ServerMessage = { + type: "lobby_info", + myClientID: "aB3dEf7h", + lobby: { + gameID: "gM3xQ1zR", + serverTime: 1_700_000_000_000, + clients: [ + { + ...PLAIN, + levelBadge: pack({ level: 12, prestige: 1, legend: false }), + }, + FULL, + { + clientID: "Zz8wVu5t", + username: "charlie", + clanTag: null, + levelBadge: pack({ level: 100, prestige: 10, legend: true }), + }, + ], + }, + }; + const back = decodeServerMessage( + encodeServerMessage(msg, undefined), + undefined, + ); + expect(back).toEqual(msg); + }); +}); + +describe("a client cannot send itself a level", () => { + const join = { + type: "join" as const, + token: "9f0f4b3a-0d2e-4a1f-8f0b-1c2d3e4f5a6b", + gameID: "AbCd1234", + username: "caster", + clanTag: null, + turnstileToken: null, + }; + const spoofed = { + ...join, + levelBadge: { level: 100, prestige: 10, legend: true }, + level: 100, + prestige: 10, + legend: true, + }; + + it("the join schema strips level fields", () => { + const parsed = ClientJoinMessageSchema.parse(spoofed) as Record< + string, + unknown + >; + for (const key of ["levelBadge", "level", "prestige", "legend"]) { + expect(parsed).not.toHaveProperty(key); + } + }); + + it("a join frame has no room for them: the server decodes none", () => { + // What the server actually reads off the socket. The positional encoding + // has no slot for a field the schema lacks, so it never leaves the client. + const bytes = encodeClientMessage(spoofed as never, undefined); + expect(bytes).toEqual(encodeClientMessage(join, undefined)); + const decoded = decodeClientMessage(bytes, undefined) as Record< + string, + unknown + >; + for (const key of ["levelBadge", "level", "prestige", "legend"]) { + expect(decoded).not.toHaveProperty(key); + } + }); +}); diff --git a/tests/core/LevelBadgeWire.test.ts b/tests/core/LevelBadgeWire.test.ts new file mode 100644 index 00000000000..9d47118b090 --- /dev/null +++ b/tests/core/LevelBadgeWire.test.ts @@ -0,0 +1,100 @@ +import { + LEVEL_BADGE_MAX_LEVEL, + LEVEL_BADGE_MAX_PRESTIGE, + LevelBadge, + packLevelBadge, + unpackLevelBadge, +} from "@openfront/shared/LevelBadgeWire"; +import { ClientInfoSchema } from "@openfront/shared/WireSchemas"; +import { zb } from "@openfront/zbin"; +import { describe, expect, it } from "vitest"; + +// The roster carries a badge as one packed integer: level in bits 0-6, +// prestige in bits 7-10, legend in bit 11. + +// Bytes a packed badge adds to a roster entry (its presence bit is in the +// header byte the entry has anyway). +const Entry = zb.object(ClientInfoSchema.shape); +const BASE = { clientID: "aB3dEf7h", username: "alpha", clanTag: null }; +const varintBytes = (levelBadge: number) => + Entry.serialize({ ...BASE, levelBadge }).byteLength - + Entry.serialize(BASE).byteLength; + +describe("packLevelBadge / unpackLevelBadge", () => { + it.each<[LevelBadge, number]>([ + [{ level: 1, prestige: 0, legend: false }, 1], + [{ level: 100, prestige: 0, legend: false }, 100], + [{ level: 1, prestige: 1, legend: false }, 129], + [{ level: 37, prestige: 2, legend: false }, 37 + 2 * 128], + [{ level: 100, prestige: 10, legend: false }, 100 + 10 * 128], + [{ level: 100, prestige: 10, legend: true }, 100 + 10 * 128 + 2048], + ])("packs %o as %i and back", (badge, packed) => { + expect(packLevelBadge(badge)).toBe(packed); + expect(unpackLevelBadge(packed)).toEqual(badge); + }); + + it("round-trips every level and prestige, with and without legend", () => { + for (let prestige = 0; prestige <= LEVEL_BADGE_MAX_PRESTIGE; prestige++) { + for (let level = 1; level <= LEVEL_BADGE_MAX_LEVEL; level++) { + for (const legend of [false, true]) { + const badge = { level, prestige, legend }; + expect(unpackLevelBadge(packLevelBadge(badge))).toEqual(badge); + } + } + } + }); + + it("is one varint byte at prestige 0 and two at most", () => { + expect( + varintBytes(packLevelBadge({ level: 1, prestige: 0, legend: false })!), + ).toBe(1); + expect( + varintBytes(packLevelBadge({ level: 100, prestige: 0, legend: false })!), + ).toBe(1); + expect( + varintBytes(packLevelBadge({ level: 1, prestige: 1, legend: false })!), + ).toBe(2); + expect( + varintBytes(packLevelBadge({ level: 100, prestige: 10, legend: true })!), + ).toBe(2); + }); + + it("packs nothing it cannot represent", () => { + for (const bad of [ + { level: 0, prestige: 0, legend: false }, + { level: 101, prestige: 0, legend: false }, + { level: 128, prestige: 0, legend: false }, + { level: -1, prestige: 0, legend: false }, + { level: 12.5, prestige: 0, legend: false }, + { level: Number.NaN, prestige: 0, legend: false }, + { level: 1, prestige: -1, legend: false }, + { level: 1, prestige: 11, legend: false }, + { level: 1, prestige: 16, legend: false }, + { level: 1, prestige: 1.5, legend: false }, + { level: 1, prestige: 0, legend: "yes" as unknown as boolean }, + ]) { + expect(packLevelBadge(bad)).toBeUndefined(); + } + expect(packLevelBadge(undefined)).toBeUndefined(); + }); + + it("decodes values no server sends as no badge, without throwing", () => { + for (const bad of [ + 0, // level 0 + 101, // level 101 + 127, // level 127 + 128, // prestige 1, level 0 + 11 * 128 + 5, // prestige 11 + 15 * 128 + 5, // prestige 15 + 2048, // legend alone, level 0 + 4096 + 5, // a reserved bit + 2 ** 40, // far out of range + -1, + 1.5, + Number.NaN, + ]) { + expect(unpackLevelBadge(bad)).toBeUndefined(); + } + expect(unpackLevelBadge(undefined)).toBeUndefined(); + }); +}); diff --git a/tests/server/RosterLevelBadge.test.ts b/tests/server/RosterLevelBadge.test.ts new file mode 100644 index 00000000000..b0adbd6b41d --- /dev/null +++ b/tests/server/RosterLevelBadge.test.ts @@ -0,0 +1,339 @@ +import { GameType } from "@openfront/engine-api/game/GameTypes"; +import { GameConfig } from "@openfront/engine-api/Schemas"; +import { UserMeResponseSchema } from "@openfront/shared/ApiSchemas"; +import { + LevelBadge, + packLevelBadge, + unpackLevelBadge, +} from "@openfront/shared/LevelBadgeWire"; +import { GameInfo, ServerMessage } from "@openfront/shared/WireSchemas"; +import { + decodeServerMessage, + encodeServerMessage, +} from "@openfront/shared/ZbinWire"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + levelBadgeForPlayer, + levelBadgeFromProgress, +} from "../../src/server/LevelBadge"; +import { makeClient, makeGame } from "../util/GameServerHarness"; + +// The game server stamps each signed-in player's level onto the lobby roster +// from its own /users/@me lookup (Worker.ts join -> levelBadgeForPlayer -> +// Client.levelBadge -> NameVisibility.lobbyClients). Display-only. + +const progress = { + prestige: 2, + level: 37, + xpInLevel: 120, + xpForNext: 900, + lifetimeXp: 123456, + legend: false, + canPrestige: false, +}; + +function userMe(player: Record = {}) { + return UserMeResponseSchema.parse({ + user: { email: "player@example.com" }, + player: { + publicId: "abc", + adfree: false, + unlimitedRanked: false, + canCreatePublicLobbies: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + ...player, + }, + }); +} + +describe("levelBadgeFromProgress: stamping from /users/@me", () => { + it("stamps level, prestige and legend from the response's progress", () => { + expect( + levelBadgeFromProgress(userMe({ progress }).player.progress), + ).toEqual({ level: 37, prestige: 2, legend: false }); + expect( + levelBadgeFromProgress( + userMe({ + progress: { ...progress, level: 100, prestige: 10, legend: true }, + }).player.progress, + ), + ).toEqual({ level: 100, prestige: 10, legend: true }); + }); + + it("stamps nothing when the response has no progress (progression off)", () => { + expect(levelBadgeFromProgress(userMe().player.progress)).toBeUndefined(); + }); + + it("stamps nothing for a malformed progress object", () => { + // UserMeResponseSchema reads it as absent rather than failing the join. + const me = userMe({ progress: { level: "ten" } }); + expect(levelBadgeFromProgress(me.player.progress)).toBeUndefined(); + }); + + it("stamps nothing for values the wire cannot carry", () => { + // The roster packs a badge into one integer (level 1..100, prestige + // 0..10); anything outside that is dropped rather than sent wrong. + for (const bad of [ + { level: 0 }, + { level: -1 }, + { level: 12.5 }, + { level: Number.NaN }, + { prestige: -1 }, + { prestige: 1.5 }, + { level: Number.MAX_SAFE_INTEGER + 2 }, + { level: 101 }, + { level: 128 }, + { prestige: 11 }, + { prestige: 16 }, + ]) { + expect(levelBadgeFromProgress({ ...progress, ...bad })).toBeUndefined(); + } + }); + + it("stamps the edge values", () => { + for (const edge of [ + { level: 1, prestige: 0, legend: false }, + { level: 100, prestige: 10, legend: true }, + ]) { + expect(levelBadgeFromProgress({ ...progress, ...edge })).toEqual(edge); + } + }); + + it("stamps nothing for a guest (no lookup at all)", () => { + expect(levelBadgeFromProgress(undefined)).toBeUndefined(); + }); +}); + +describe("levelBadgeForPlayer: hide my level", () => { + it("parses /users/@me with and without levelHidden", () => { + expect(userMe({ progress }).player.levelHidden).toBeUndefined(); + expect(userMe({ progress, levelHidden: true }).player.levelHidden).toBe( + true, + ); + expect(userMe({ progress, levelHidden: false }).player.levelHidden).toBe( + false, + ); + }); + + it("reads a malformed levelHidden as hidden instead of failing the parse", () => { + for (const bad of [null, "yes", "true", 1, 0, {}]) { + const me = userMe({ progress, levelHidden: bad }); + expect(me.player.levelHidden).toBe(true); + expect(me.player.progress).toBeDefined(); + // Fails closed: no badge for a setting that can't be read. + expect(levelBadgeForPlayer(me.player)).toBeUndefined(); + } + }); + + it("stamps nothing when the player hides their level", () => { + const me = userMe({ progress, levelHidden: true }); + expect(levelBadgeForPlayer(me.player)).toBeUndefined(); + }); + + it("stamps the badge when the level is shown", () => { + const me = userMe({ progress, levelHidden: false }); + expect(levelBadgeForPlayer(me.player)).toEqual({ + level: 37, + prestige: 2, + legend: false, + }); + }); + + it("stamps the badge when an older API omits the setting", () => { + expect(levelBadgeForPlayer(userMe({ progress }).player)).toEqual({ + level: 37, + prestige: 2, + legend: false, + }); + }); + + it("still stamps nothing without progress, hidden or not", () => { + expect(levelBadgeForPlayer(userMe().player)).toBeUndefined(); + expect( + levelBadgeForPlayer(userMe({ levelHidden: false }).player), + ).toBeUndefined(); + }); + + it("a hidden player's entry carries no badge on the wire, for anyone", () => { + const game = makeGame({ config: { gameType: GameType.Private } }); + game.joinClient( + makeClient({ + clientID: "hide0001", + username: "Hider", + publicId: "hide-pub", + levelBadge: levelBadgeForPlayer( + userMe({ progress, levelHidden: true }).player, + ), + }), + ); + game.joinClient( + makeClient({ clientID: "peer0001", username: "Peer", publicId: "p-pub" }), + ); + for (const viewer of ["hide0001", "peer0001"]) { + const msg: ServerMessage = { + type: "lobby_info", + lobby: game.gameInfo(viewer), + myClientID: viewer, + }; + const back = decodeServerMessage( + encodeServerMessage(msg, undefined), + undefined, + ); + if (back.type !== "lobby_info") throw new Error("wrong type"); + expect(byId(back.lobby, "hide0001")).not.toHaveProperty("levelBadge"); + } + }); +}); + +const VET: LevelBadge = { level: 100, prestige: 10, legend: true }; +const MID: LevelBadge = { level: 37, prestige: 2, legend: false }; +const byId = (info: GameInfo, id: string) => + info.clients!.find((c) => c.clientID === id)!; +// The entry's badge, unpacked from its wire form. +const badgeOf = (info: GameInfo, id: string) => + unpackLevelBadge(byId(info, id).levelBadge); + +describe("lobby roster carries the server-stamped badge", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + function lobby( + config: Partial = {}, + matchmakingTeams?: string[][], + ) { + const game = makeGame({ + config: { gameType: GameType.Private, ...config }, + matchmakingTeams, + }); + game.joinClient( + makeClient({ + clientID: "vet00001", + username: "Veteran", + publicId: "vet-pub", + levelBadge: VET, + }), + ); + game.joinClient( + makeClient({ + clientID: "mid00001", + username: "Middling", + publicId: "mid-pub", + levelBadge: MID, + }), + ); + game.joinClient( + makeClient({ + clientID: "guest001", + username: "Guest", + publicId: "g-pub", + }), + ); + return game; + } + + it("names on: every signed-in player's badge, none for the guest", () => { + const info = lobby().gameInfo("guest001"); + // On the roster as the packed integer, decoding to the stamped badge. + expect(byId(info, "vet00001").levelBadge).toBe(packLevelBadge(VET)); + expect(badgeOf(info, "vet00001")).toEqual(VET); + expect(badgeOf(info, "mid00001")).toEqual(MID); + expect(byId(info, "guest001").levelBadge).toBeUndefined(); + }); + + it("survives the lobby_info encoding end to end", () => { + const game = lobby(); + const msg: ServerMessage = { + type: "lobby_info", + lobby: game.gameInfo("guest001"), + myClientID: "guest001", + }; + const back = decodeServerMessage( + encodeServerMessage(msg, undefined), + undefined, + ); + if (back.type !== "lobby_info") throw new Error("wrong type"); + expect(badgeOf(back.lobby, "vet00001")).toEqual(VET); + expect(byId(back.lobby, "guest001").levelBadge).toBeUndefined(); + }); + + it("anonymizeNames: anonymized entries carry no badge", () => { + const info = lobby({ anonymizeNames: true }).gameInfo("guest001"); + for (const id of ["vet00001", "mid00001"]) { + const entry = byId(info, id); + expect(["Veteran", "Middling"]).not.toContain(entry.username); + expect(entry.levelBadge).toBeUndefined(); + expect(entry).not.toHaveProperty("levelBadge"); + } + }); + + it("anonymizeNames: a player still sees their own badge", () => { + const info = lobby({ anonymizeNames: true }).gameInfo("vet00001"); + expect(badgeOf(info, "vet00001")).toEqual(VET); + expect(byId(info, "mid00001").levelBadge).toBeUndefined(); + }); + + it("anonymizeNames: a viewer granted real names sees the badges too", () => { + const info = lobby({ + anonymizeNames: true, + nameReveals: ["guest001"], + }).gameInfo("guest001"); + expect(badgeOf(info, "vet00001")).toEqual(VET); + expect(badgeOf(info, "mid00001")).toEqual(MID); + }); + + it("anonymizeNames: a pinned teammate's badge shows with their real name", () => { + const info = lobby({ anonymizeNames: true }, [ + ["vet-pub", "mid-pub"], + ["g-pub"], + ]).gameInfo("mid00001"); + expect(byId(info, "vet00001").username).toBe("Veteran"); + expect(badgeOf(info, "vet00001")).toEqual(VET); + }); + + it("anonymizeNames: the public HTTP view (no viewer) shows no badges", () => { + const info = lobby({ anonymizeNames: true }).gameInfo(); + for (const c of info.clients!) expect(c.levelBadge).toBeUndefined(); + }); + + it("names on: a hidden player's entry has no badge, even in the public HTTP view", () => { + const game = lobby(); + game.joinClient( + makeClient({ + clientID: "hide0001", + username: "Hider", + publicId: "hide-pub", + levelBadge: levelBadgeForPlayer( + userMe({ progress, levelHidden: true }).player, + ), + }), + ); + for (const viewer of [undefined, "hide0001", "vet00001"]) { + const info = game.gameInfo(viewer); + expect(byId(info, "hide0001").username).toBe("Hider"); + expect(byId(info, "hide0001").levelBadge).toBeUndefined(); + expect(badgeOf(info, "vet00001")).toEqual(VET); + } + }); +}); + +describe("Client packs its badge once, at construction", () => { + it("holds the wire form beside the readable one", () => { + const c = makeClient({ clientID: "vet00001", levelBadge: VET }); + expect(c.levelBadge).toEqual(VET); + expect(c.wireLevelBadge).toBe(packLevelBadge(VET)); + }); + + it("has no wire badge for a guest or a badge it cannot pack", () => { + expect(makeClient({ clientID: "guest001" }).wireLevelBadge).toBeUndefined(); + const odd = makeClient({ + clientID: "odd00001", + levelBadge: { level: 101, prestige: 0, legend: false }, + }); + expect(odd.wireLevelBadge).toBeUndefined(); + }); +}); diff --git a/tests/server/__snapshots__/GameServerWire.test.ts.snap b/tests/server/__snapshots__/GameServerWire.test.ts.snap index 2e9adc0b75a..60f75f7e200 100644 --- a/tests/server/__snapshots__/GameServerWire.test.ts.snap +++ b/tests/server/__snapshots__/GameServerWire.test.ts.snap @@ -1793,6 +1793,7 @@ exports[`GameServer wire transcript > matches the golden transcript for a script "friends": [ "p2000000", ], + "levelBadge": undefined, "spectator": undefined, "teamIndex": undefined, "username": "HostName", @@ -1802,6 +1803,7 @@ exports[`GameServer wire transcript > matches the golden transcript for a script "clanTag": null, "clientID": "p2000000", "friends": undefined, + "levelBadge": undefined, "spectator": undefined, "teamIndex": undefined, "username": "SecondOne", @@ -1811,6 +1813,7 @@ exports[`GameServer wire transcript > matches the golden transcript for a script "clanTag": null, "clientID": "cast0000", "friends": undefined, + "levelBadge": undefined, "spectator": true, "teamIndex": undefined, "username": "Caster", @@ -1820,6 +1823,7 @@ exports[`GameServer wire transcript > matches the golden transcript for a script "clanTag": "TRD", "clientID": "p3000000", "friends": undefined, + "levelBadge": undefined, "spectator": undefined, "teamIndex": undefined, "username": "ThirdOne", diff --git a/tests/util/GameServerHarness.ts b/tests/util/GameServerHarness.ts index 21c92cf141e..363d2e15450 100644 --- a/tests/util/GameServerHarness.ts +++ b/tests/util/GameServerHarness.ts @@ -115,6 +115,7 @@ export interface ClientOpts { spectator?: boolean; trusted?: boolean; platform?: Client["platform"]; + levelBadge?: Client["levelBadge"]; } let nextClient = 1; @@ -142,6 +143,7 @@ export function makeClient(opts: ClientOpts = {}): Client { opts.spectator ?? false, opts.trusted ?? false, opts.platform, + opts.levelBadge, ); }