diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 4f52d7f1c23..5479769c415 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -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(); @@ -205,6 +209,30 @@ export const PublicProgressSchema = z.object({ }); export type PublicProgress = z.infer; +// 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 @@ -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 @@ -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 diff --git a/packages/shared/src/ClanApiSchemas.ts b/packages/shared/src/ClanApiSchemas.ts index 23d00a910f6..c5e3884f705 100644 --- a/packages/shared/src/ClanApiSchemas.ts +++ b/packages/shared/src/ClanApiSchemas.ts @@ -1,5 +1,6 @@ import { ClanTagSchema } from "@openfront/engine-api/Schemas"; import { z } from "zod"; +import { RowLevelFields } from "./ApiSchemas"; const RequiredClanTagSchema = ClanTagSchema.unwrap(); @@ -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; diff --git a/src/client/components/LevelBadgeSlot.ts b/src/client/components/LevelBadgeSlot.ts new file mode 100644 index 00000000000..d9fd3d66ed2 --- /dev/null +++ b/src/client/components/LevelBadgeSlot.ts @@ -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 | 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``; + } + if (!reserveSlot) return nothing; + return html``; +} diff --git a/src/client/components/clan/ClanDetailView.ts b/src/client/components/clan/ClanDetailView.ts index d26e378802f..b94b10185d8 100644 --- a/src/client/components/clan/ClanDetailView.ts +++ b/src/client/components/clan/ClanDetailView.ts @@ -21,6 +21,7 @@ import { type ClanRole, defaultOrderForSort, filterMembersBySearch, + membersHaveLevels, renderLoadingSpinner, renderMemberPagination, renderMemberRow, @@ -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", ); @@ -611,7 +613,9 @@ export class ClanDetailView extends LitElement { ), )}
- ${filtered.map((m) => renderMemberRow(m, this.myPublicId, this))} + ${filtered.map((m) => + renderMemberRow(m, this.myPublicId, this, levelSlot), + )}
${renderMemberPagination( this.memberPage, diff --git a/src/client/components/clan/ClanShared.ts b/src/client/components/clan/ClanShared.ts index c7bf9786178..ad259467e04 100644 --- a/src/client/components/clan/ClanShared.ts +++ b/src/client/components/clan/ClanShared.ts @@ -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"; @@ -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` @@ -403,8 +415,14 @@ export function renderMemberRow(
-
- ${playerNameLink(host, member.username, member.publicId)} +
+ ${levelBadgeSlot(member, hasLevel(member), levelSlot)} + ${playerNameLink( + host, + member.username, + member.publicId, + "min-w-0", + )}
0 ? entry.wins / entry.total : 0, + level: entry.level, + prestige: entry.prestige, + legend: entry.legend, }); @customElement("leaderboard-player-list") @@ -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; @@ -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")} - this.openProfile(entry.playerId)} - > +
+ ${levelBadgeSlot(entry, isPrestigedOrLegend(entry), badgeSlot)} + this.openProfile(entry.playerId)} + > +
@@ -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; @@ -380,7 +388,9 @@ export class LeaderboardPlayerList extends LitElement {
+ ${levelBadgeSlot(player, isPrestigedOrLegend(player), badgeSlot)}