Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
53b26e0
Show player levels and the XP each game earns
iiamlewis Sep 30, 2026
6ff9abf
Strip Lit's comment markers in the badge tests as nodes, not by regex
iiamlewis Sep 30, 2026
c91fed6
Merge branch 'main' into player-levels-ui
iiamlewis Oct 1, 2026
0ef94ba
Harden player levels UI from PR review
iiamlewis Oct 1, 2026
23be312
Merge branch 'main' into player-levels-ui
iiamlewis Oct 2, 2026
b899cb0
Address review on player levels: unverified games, off-screen reveals…
iiamlewis Oct 2, 2026
216851d
Rename the first-win XP line to first game of the day
iiamlewis Oct 2, 2026
a9c27cb
Recognise the new subscriber XP boosts: +25% / +50% / +100%
iiamlewis Oct 2, 2026
399acf7
feat: stamp player levels onto the lobby roster
iiamlewis Oct 2, 2026
0e2d49c
feat: show level badges in the lobby list and player panel
iiamlewis Oct 2, 2026
5f83874
Stack the XP reveal's counter and level progress on a narrow bar
iiamlewis Oct 3, 2026
6a758f1
Show the XP a past game earned under its summary card
iiamlewis Oct 3, 2026
2edf46b
perf: carry the roster's level badge as one packed integer
iiamlewis Oct 5, 2026
f6bf428
perf: draw a full lobby's level badges over a few frames
iiamlewis Oct 5, 2026
c93e4b0
Merge main: src/core split into engine, engine-api and shared packages
iiamlewis Oct 6, 2026
0a960f1
Merge player-levels-ui: src/core split into packages
iiamlewis Oct 6, 2026
c1f80da
Merge remote-tracking branch 'origin/main' into player-levels-ui
iiamlewis Oct 6, 2026
d1ae378
Point the XP popup test at EventBus's new home in @openfront/shared
iiamlewis Oct 6, 2026
88a6c74
Merge player-levels-ui: bring the branch up to date with main
iiamlewis Oct 6, 2026
1271d1f
Merge main: lazy-loaded homepage modals
iiamlewis Oct 6, 2026
1f01c85
Honour "hide my level" on the roster badge; own badge after a refresh
iiamlewis Oct 8, 2026
812cc15
Address review on the player levels UI
iiamlewis Oct 8, 2026
c1b833f
Merge player-levels-ui (review fixes)
iiamlewis Oct 8, 2026
0111c2c
Merge main (#5726 and #5778 landed)
iiamlewis Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/shared/src/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
71 changes: 71 additions & 0 deletions packages/shared/src/LevelBadgeWire.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
22 changes: 21 additions & 1 deletion packages/shared/src/WireSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ export const LobbyAccentSchema = z.enum(["gold", "blue", "green", "red"]);

export type LobbyAccent = z.infer<typeof LobbyAccentSchema>;

const ClientInfoSchema = z.object({
export type { LevelBadge } from "./LevelBadgeWire";

export const ClientInfoSchema = z.object({
clientID: z.string(),
username: UsernameSchema,
clanTag: ClanTagSchema,
Expand All @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/client/LobbyRosterLevels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ClientID, GameID } from "@openfront/engine-api/Schemas";
Comment thread
iiamlewis marked this conversation as resolved.
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<ClientID, number>();

export function rememberLobbyRoster(
gameID: GameID,
clients: readonly ClientInfo[] | undefined,
): void {
const next = new Map<ClientID, number>();
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));
}
53 changes: 51 additions & 2 deletions src/client/components/LevelBadge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -344,9 +384,18 @@ export class LevelBadge extends LitElement {
}

render() {
const px = Math.max(16, this.size);
Comment thread
iiamlewis marked this conversation as resolved.
if (this.waiting) {
// The drawn badge's exact box, so nothing moves when it is drawn.
return html`<span
class="block shrink-0"
style="width:${px}px;height:${px}px"
aria-hidden="true"
data-badge-waiting
></span>`;
}
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
Expand Down
Loading
Loading