Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
696786d
feat: level badges on the ranked leaderboard and clan member lists
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
adf5447
Merge roster-levels (packed level badge) into leaderboard-levels
iiamlewis Oct 5, 2026
f6bf428
perf: draw a full lobby's level badges over a few frames
iiamlewis Oct 5, 2026
460e8cf
Merge roster-levels (staggered badge fill) into leaderboard-levels
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
59a8e74
Merge roster-levels: 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
735f76d
Merge remote-tracking branch 'origin/roster-levels' into leaderboard-…
iiamlewis Oct 6, 2026
1271d1f
Merge main: lazy-loaded homepage modals
iiamlewis Oct 6, 2026
5fec34e
fix: tolerate malformed row level fields; stagger list badges; let na…
iiamlewis Oct 8, 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
cd55b4b
Merge roster-levels (review fixes)
iiamlewis Oct 8, 2026
0111c2c
Merge main (#5726 and #5778 landed)
iiamlewis Oct 9, 2026
31b1adf
Merge roster-levels (main sync)
iiamlewis Oct 9, 2026
021c072
Merge origin/main into leaderboard-levels
iiamlewis Oct 9, 2026
5b34dae
Merge origin/main into leaderboard-levels
iiamlewis Oct 11, 2026
1596c6b
Merge branch 'main' into leaderboard-levels
Celant Oct 12, 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
30 changes: 30 additions & 0 deletions packages/shared/src/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
} from "@openfront/engine-api/game/GameTypes";
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import {
LEVEL_BADGE_MAX_LEVEL,
LEVEL_BADGE_MAX_PRESTIGE,
} from "./LevelBadgeWire";

const RequiredClanTagSchema = ClanTagSchema.unwrap();

Expand Down Expand Up @@ -205,6 +209,30 @@ export const PublicProgressSchema = z.object({
});
export type PublicProgress = z.infer<typeof PublicProgressSchema>;

// A player's level as stamped onto rows in other players' lists (the ranked
// leaderboard, clan member lists), for drawing a level badge. Sent together
// for a player with progress; absent for one without, while progression is
// off, and from an API that predates them. Display-only, so a malformed or
// out-of-range value reads as absent (that row shows no badge) rather than
// failing the whole list's parse or drawing a level the player doesn't have.
export const RowLevelFields = {
level: z
.number()
.int()
.min(1)
.max(LEVEL_BADGE_MAX_LEVEL)
.optional()
.catch(undefined),
prestige: z
.number()
.int()
.min(0)
.max(LEVEL_BADGE_MAX_PRESTIGE)
.optional()
.catch(undefined),
legend: z.boolean().optional().catch(undefined),
};

// A flare staff put on the level track: what reaching a point on it grants.
// `kind` says what the point is:
// level — reaching `level` in run `prestige` (0 = the first run), or in
Expand Down Expand Up @@ -1031,6 +1059,7 @@ export const PlayerLeaderboardEntrySchema = z.object({
wins: z.number(),
losses: z.number(),
winRate: z.number(),
...RowLevelFields,
});
export type PlayerLeaderboardEntry = z.infer<
typeof PlayerLeaderboardEntrySchema
Expand All @@ -1054,6 +1083,7 @@ export const RankedLeaderboardEntrySchema = z.object({
// Account username (null = never set). The client displays
// `accountUsername ?? public_id`.
accountUsername: z.string().nullable(),
...RowLevelFields,
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/ClanApiSchemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ClanTagSchema } from "@openfront/engine-api/Schemas";
import { z } from "zod";
import { RowLevelFields } from "./ApiSchemas";

const RequiredClanTagSchema = ClanTagSchema.unwrap();

Expand Down Expand Up @@ -141,6 +142,7 @@ export const ClanMemberSchema = z.object({
// responses from an API without the field still parse.
username: z.string().nullable().optional(),
stats: ClanMemberStatsSchema.optional(),
...RowLevelFields,
});
export type ClanMember = z.infer<typeof ClanMemberSchema>;

Expand Down
76 changes: 76 additions & 0 deletions src/client/components/LevelBadgeSlot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { html, nothing, type TemplateResult } from "lit";
import "./LevelBadge";

/**
* A row's level fields as the API sends them on leaderboard and clan member
* rows: all three present for a player with progress, all absent otherwise
* (no progress yet, or progression switched off). The schema drops a
* malformed field on its own, so a row can also arrive with only some.
*/
export interface LevelFields {
level?: number;
prestige?: number;
legend?: boolean;
}

/**
* The row's badge, or undefined unless all three fields are present: with
* one dropped as malformed the row shows no badge rather than a guessed one.
*/
function rowBadge(row: LevelFields): Required<LevelFields> | undefined {
const { level, prestige, legend } = row;
if (level === undefined || prestige === undefined || legend === undefined) {
return undefined;
}
return { level, prestige, legend };
}

/** True when the row carries a level to draw. */
export function hasLevel(row: LevelFields): boolean {
return rowBadge(row) !== undefined;
}

/**
* True when the row has prestiged at least once or is a Legend. The ranked
* leaderboard badges only these players, so that the badge stays rare enough
* there to mean something and the board doesn't get cluttered; clan member
* lists and the lobby roster badge anyone with a level.
*/
export function isPrestigedOrLegend(row: LevelFields): boolean {
const badge = rowBadge(row);
return badge !== undefined && (badge.legend || badge.prestige > 0);
}

/**
* The level badge in front of a name in a list, or — when `show` is false —
* an empty slot of the same width so names stay lined up with the rows that
* do have one. With `reserveSlot` false (no row in the list has a badge),
* renders nothing at all, so lists look unchanged until levels appear.
* Badges are `stagger`ed: a page of them arriving at once is drawn over a
* few frames (see LevelBadgeFill).
*/
export function levelBadgeSlot(
row: LevelFields,
show: boolean,
reserveSlot: boolean,
size = 24,
): TemplateResult | typeof nothing {
const badge = show ? rowBadge(row) : undefined;
if (badge !== undefined) {
return html`<level-badge
Comment thread
iiamlewis marked this conversation as resolved.
stagger
class="shrink-0"
.level=${badge.level}
.prestige=${badge.prestige}
?legend=${badge.legend}
size=${size}
></level-badge>`;
}
if (!reserveSlot) return nothing;
return html`<span
class="shrink-0"
style="width:${size}px;height:${size}px"
aria-hidden="true"
data-level-slot
></span>`;
}
6 changes: 5 additions & 1 deletion src/client/components/clan/ClanDetailView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type ClanRole,
defaultOrderForSort,
filterMembersBySearch,
membersHaveLevels,
renderLoadingSpinner,
renderMemberPagination,
renderMemberRow,
Expand Down Expand Up @@ -580,6 +581,7 @@ export class ClanDetailView extends LitElement {

private renderMembersList() {
const filtered = filterMembersBySearch(this.members, this.memberSearch);
const levelSlot = membersHaveLevels(filtered);
const toggleLabel = translateText(
this.allStatsExpanded ? "common.collapse_all" : "common.expand_all",
);
Expand Down Expand Up @@ -611,7 +613,9 @@ export class ClanDetailView extends LitElement {
),
)}
<div class="space-y-2">
${filtered.map((m) => renderMemberRow(m, this.myPublicId, this))}
${filtered.map((m) =>
renderMemberRow(m, this.myPublicId, this, levelSlot),
)}
</div>
${renderMemberPagination(
this.memberPage,
Expand Down
22 changes: 20 additions & 2 deletions src/client/components/clan/ClanShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ClanMemberStats,
} from "../../ClanApi";
import { showToast, translateText } from "../../Utils";
import { hasLevel, levelBadgeSlot } from "../LevelBadgeSlot";
import { playerNameLink } from "../ui/PlayerNameLink";
import "./ClanStatsBreakdown";
export { renderLoadingSpinner } from "../BaseModal";
Expand Down Expand Up @@ -378,11 +379,22 @@ export function renderMemberStats(
`;
}

/**
* Whether member rows in a list should keep a level-badge slot: true when any
* member in it has a level, so the names of members without one line up.
*/
export function membersHaveLevels(members: readonly ClanMember[]): boolean {
return members.some(hasLevel);
}

// `host` raises the `view-profile` event that opens the profile modal.
// `levelSlot` (see membersHaveLevels) keeps an empty badge-width slot in
// front of the name of a member without a level.
export function renderMemberRow(
member: ClanMember,
myPublicId: string | null,
host: HTMLElement,
levelSlot = false,
): TemplateResult {
const isMe = member.publicId === myPublicId;
return html`
Expand All @@ -403,8 +415,14 @@ export function renderMemberRow(
</div>
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
${playerNameLink(host, member.username, member.publicId)}
<div class="min-w-0 flex items-center gap-2">
Comment thread
iiamlewis marked this conversation as resolved.
${levelBadgeSlot(member, hasLevel(member), levelSlot)}
${playerNameLink(
host,
member.username,
member.publicId,
"min-w-0",
)}
</div>
<div class="flex items-center gap-2 shrink-0">
<span
Expand Down
38 changes: 28 additions & 10 deletions src/client/components/leaderboard/LeaderboardPlayerList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
import { loadModal } from "../../LazyModals";
import { translateText } from "../../Utils";
import { isPrestigedOrLegend, levelBadgeSlot } from "../LevelBadgeSlot";
import "../PlayerName";

/** One ranked ladder's loaded rows. */
Expand Down Expand Up @@ -36,6 +37,9 @@ const toPlayerEntry = (
wins: entry.wins,
losses: entry.losses,
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
level: entry.level,
prestige: entry.prestige,
legend: entry.legend,
});

@customElement("leaderboard-player-list")
Expand Down Expand Up @@ -296,7 +300,7 @@ export class LeaderboardPlayerList extends LitElement {
// the container, so the resolved widths depend on the modal size and cannot
// be reproduced by a separate element; and below 34rem the table scrolls
// horizontally, which an overlay outside .scroll-container would not follow.
private renderStickyUserRow() {
private renderStickyUserRow(badgeSlot: boolean) {
if (!this.currentUserEntry || !this.showStickyUser) return nothing;
const entry = this.currentUserEntry;

Expand All @@ -316,12 +320,16 @@ export class LeaderboardPlayerList extends LitElement {
class="text-[10px] uppercase font-bold text-blue-200/80 leading-tight"
>${translateText("leaderboard_modal.your_ranking")}</span
>
<player-name
.username=${entry.accountUsername}
.publicId=${entry.playerId}
.nameClass=${"font-bold text-white truncate text-base hover:underline"}
.onNameClick=${() => this.openProfile(entry.playerId)}
></player-name>
<div class="flex items-center gap-2 min-w-0">
${levelBadgeSlot(entry, isPrestigedOrLegend(entry), badgeSlot)}
<player-name
class="min-w-0"
.username=${entry.accountUsername}
.publicId=${entry.playerId}
.nameClass=${"font-bold text-white truncate text-base hover:underline"}
.onNameClick=${() => this.openProfile(entry.playerId)}
></player-name>
</div>
</div>
</td>
<td class="py-3 px-4 text-right">
Expand All @@ -346,7 +354,7 @@ export class LeaderboardPlayerList extends LitElement {
`;
}

private renderPlayerRow(player: PlayerLeaderboardEntry) {
private renderPlayerRow(player: PlayerLeaderboardEntry, badgeSlot: boolean) {
const isCurrentUser = this.currentUserEntry?.playerId === player.playerId;
const displayRank = player.rank;

Expand Down Expand Up @@ -380,7 +388,9 @@ export class LeaderboardPlayerList extends LitElement {
</td>
<td class="py-3 px-4">
<div class="flex items-center gap-2">
${levelBadgeSlot(player, isPrestigedOrLegend(player), badgeSlot)}
<player-name
class="min-w-0"
.username=${player.accountUsername}
.publicId=${player.playerId}
.nameClass=${"font-bold text-blue-300 truncate text-base hover:underline"}
Expand Down Expand Up @@ -533,6 +543,12 @@ export class LeaderboardPlayerList extends LitElement {
if (this.hasLoadedPlayers && this.playerData.length === 0)
return this.renderNoData();

// Level badges mark prestiged players and Legends only (see
// isPrestigedOrLegend for why). Rows without one keep an empty slot so
// names line up — but only once some loaded row in this ladder has a
// badge, so the board is unchanged until then.
const badgeSlot = this.playerData.some(isPrestigedOrLegend);

return html`
<div class="h-full">
<!--
Expand Down Expand Up @@ -577,9 +593,11 @@ export class LeaderboardPlayerList extends LitElement {
</tr>
</thead>
<tbody>
${this.playerData.map((player) => this.renderPlayerRow(player))}
${this.playerData.map((player) =>
this.renderPlayerRow(player, badgeSlot),
)}
</tbody>
${this.renderStickyUserRow()}
${this.renderStickyUserRow(badgeSlot)}
</table>
${this.renderPlayerFooter()}
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/client/components/ui/PlayerNameLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@ export function dispatchViewProfile(host: HTMLElement, publicId: string): void {

/**
* Player identity that opens their profile on click — `<player-name>` wired to
* dispatchViewProfile from `host`.
* dispatchViewProfile from `host`. `className` goes on the `<player-name>`
* itself, e.g. `min-w-0` so it can shrink as a flex item and the name truncate.
*/
export function playerNameLink(
host: HTMLElement,
username: string | null | undefined,
publicId: string,
className = "",
): TemplateResult {
return html`<player-name
class=${className}
.username=${username}
.publicId=${publicId}
.nameClass=${NAME_CLASS}
Expand Down
Loading
Loading