From ad2e705cd0e3f2667beb1e1e073a4491236f9fab Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 28 Jul 2026 15:56:25 -0700 Subject: [PATCH 1/2] feat(leaderboard): add the 2v2 ranked ladder as its own tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /leaderboard/ranked returns both ladders in one payload, but the response schema declared only the 1v1 key, so the 2v2 rows were dropped at the parse boundary and the modal rendered a single board. The two ladders are ranked independently — the same public_id can hold a different elo, record and rank on each — so the player list now keeps one LadderState per RankedType and renders whichever its `rankedType` property selects. Both ladder tabs share one element: a page fetch fills every ladder, so switching tabs neither refetches nor waits. Paging is per ladder rather than inferred from the 1v1 page size. The 2v2 board is younger and has no backfill, so it ends several pages before 1v1 does; tracking one flag per ladder stops the short board from asking for pages that will come back empty. An empty ladder now renders the no-data state instead of a bare header row, which is what a freshly launched ranked type shows until games are played. The tablist wraps: this is the fourth tab, and four uppercase labels overflow a narrow phone with nothing to scroll. The 2v2 key is defaulted in the schema so a client on this build keeps working against an API deployment that predates the ladder. Co-Authored-By: Claude Opus 5 (1M context) --- resources/lang/en.json | 2 + src/client/LeaderboardModal.ts | 37 +++- src/client/components/baseComponents/Modal.ts | 2 +- .../leaderboard/LeaderboardPlayerList.ts | 199 +++++++++++++----- src/core/ApiSchemas.ts | 4 + tests/client/LeaderboardModal.test.ts | 145 +++++++++++++ 6 files changed, 334 insertions(+), 55 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 1404429fac..20d38cc770 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -931,6 +931,8 @@ "no_stats": "No stats available", "player": "Player", "rank": "Rank", + "ranked_2v2_tab": "2v2 Ranked", + "ranked_no_stats": "No ranked games have been played on this ladder yet", "ranked_tab": "1v1 Ranked", "ratio": "Ratio", "refresh_time": "Refreshed every 1 hour", diff --git a/src/client/LeaderboardModal.ts b/src/client/LeaderboardModal.ts index 1fc9495fee..f8a242285d 100644 --- a/src/client/LeaderboardModal.ts +++ b/src/client/LeaderboardModal.ts @@ -1,5 +1,6 @@ import { html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; +import { RankedType } from "../core/game/Game"; import { BaseModal } from "./components/BaseModal"; import "./components/leaderboard/LeaderboardClanTable"; import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable"; @@ -10,7 +11,15 @@ import type { LeaderboardTribeTable } from "./components/leaderboard/Leaderboard import { modalHeader } from "./components/ui/ModalHeader"; import { translateText } from "./Utils"; -const TAB_KEYS = ["players", "clans", "tribes"] as const; +const TAB_KEYS = ["players", "players2v2", "clans", "tribes"] as const; + +// Tab key -> ladder. "players" predates the 2v2 ladder and stays the 1v1 tab +// so existing `#modal=leaderboard&tab=players` links keep working. Both tabs +// share one : a page fetch returns both ladders. +const PLAYER_TABS: Record = { + players: RankedType.OneVOne, + players2v2: RankedType.TwoVTwo, +}; @customElement("leaderboard-modal") export class LeaderboardModal extends BaseModal { @@ -27,6 +36,7 @@ export class LeaderboardModal extends BaseModal { private tribeTable?: LeaderboardTribeTable; private loadToken = 0; + private lastRankedType: RankedType = RankedType.OneVOne; protected modalConfig() { return { @@ -35,14 +45,22 @@ export class LeaderboardModal extends BaseModal { key: "players", label: translateText("leaderboard_modal.ranked_tab"), }, + { + key: "players2v2", + label: translateText("leaderboard_modal.ranked_2v2_tab"), + }, { key: "clans", label: translateText("leaderboard_modal.clans_tab") }, { key: "tribes", label: translateText("leaderboard_modal.tribes_tab") }, ], }; } + private rankedTypeFor(tab: string): RankedType | null { + return PLAYER_TABS[tab] ?? null; + } + private tabComponent(tab: string) { - if (tab === "players") return this.playerList; + if (this.rankedTypeFor(tab) !== null) return this.playerList; if (tab === "clans") return this.clanTable; return this.tribeTable; } @@ -52,6 +70,10 @@ export class LeaderboardModal extends BaseModal { } protected onTabEnter(): void { + // The player list is one element shared by both ladder tabs, so it needs a + // ranked type even while another tab is up: keep showing the last one. + this.lastRankedType = + this.rankedTypeFor(this.activeTab) ?? this.lastRankedType; this.loadActiveTabData(); } @@ -63,7 +85,7 @@ export class LeaderboardModal extends BaseModal { const run = async () => { if (token !== this.loadToken) return; - if (active === "players") { + if (this.rankedTypeFor(active) !== null) { await this.playerList?.ensureLoaded(); if (token !== this.loadToken) return; this.playerList?.handleTabActivated(); @@ -117,7 +139,8 @@ export class LeaderboardModal extends BaseModal { ${translateText("leaderboard_modal.title")} ${this.activeTab === "clans" ? dateRange : ""} - ${this.activeTab === "players" || this.activeTab === "tribes" + ${this.rankedTypeFor(this.activeTab) !== null || + this.activeTab === "tribes" ? refreshTime : ""} @@ -131,7 +154,11 @@ export class LeaderboardModal extends BaseModal { return html`
${this.tabs.map((tab) => { const active = this.activeTab === tab.key; diff --git a/src/client/components/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index e0ed3a4023..4fb1dde5fb 100644 --- a/src/client/components/leaderboard/LeaderboardPlayerList.ts +++ b/src/client/components/leaderboard/LeaderboardPlayerList.ts @@ -1,21 +1,56 @@ -import { html, LitElement, nothing } from "lit"; -import { customElement, query, state } from "lit/decorators.js"; -import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas"; +import { html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { + PlayerLeaderboardEntry, + RankedLeaderboardEntry, +} from "../../../core/ApiSchemas"; import { RankedType } from "../../../core/game/Game"; import { fetchPlayerLeaderboard, getUserMe } from "../../Api"; import { translateText } from "../../Utils"; import "../PlayerName"; +/** One ranked ladder's loaded rows. */ +interface LadderState { + entries: PlayerLeaderboardEntry[]; + userEntry: PlayerLeaderboardEntry | null; + hasMore: boolean; +} + +const RANKED_TYPES = Object.values(RankedType); + +const emptyLadders = (): Record => ({ + [RankedType.OneVOne]: { entries: [], userEntry: null, hasMore: true }, + [RankedType.TwoVTwo]: { entries: [], userEntry: null, hasMore: true }, +}); + +const toPlayerEntry = ( + entry: RankedLeaderboardEntry, +): PlayerLeaderboardEntry => ({ + rank: entry.rank, + playerId: entry.public_id, + accountUsername: entry.accountUsername ?? null, + elo: entry.elo, + games: entry.total, + wins: entry.wins, + losses: entry.losses, + winRate: entry.total > 0 ? entry.wins / entry.total : 0, +}); + @customElement("leaderboard-player-list") export class LeaderboardPlayerList extends LitElement { - @state() private playerData: PlayerLeaderboardEntry[] = []; - @state() private currentUserEntry: PlayerLeaderboardEntry | null = null; + /** + * Which ladder to display. Each ranked type is ranked independently — the + * same player can hold a different elo and rank on each — but one request + * returns both, so the other ladder rides along and is kept for its own tab. + */ + @property({ attribute: false }) rankedType: RankedType = RankedType.OneVOne; + + @state() private ladders: Record = emptyLadders(); @state() private showStickyUser = false; @state() private isLoading = false; @state() private error: string | null = null; @state() private isLoadingMore = false; @state() private loadMoreError: string | null = null; - @state() private playerHasMore = true; private hasLoadedPlayers = false; private readonly playerPageSize = 50; @@ -25,10 +60,42 @@ export class LeaderboardPlayerList extends LitElement { @query(".scroll-container") private scrollContainer?: HTMLElement; + private get playerData(): PlayerLeaderboardEntry[] { + return this.ladders[this.rankedType].entries; + } + + private get currentUserEntry(): PlayerLeaderboardEntry | null { + return this.ladders[this.rankedType].userEntry; + } + + private get playerHasMore(): boolean { + return this.ladders[this.rankedType].hasMore; + } + + private mapLadders( + fn: (ladder: LadderState) => LadderState, + ): Record { + const next = { ...this.ladders }; + for (const rankedType of RANKED_TYPES) { + next[rankedType] = fn(this.ladders[rankedType]); + } + return next; + } + createRenderRoot() { return this; } + protected updated(changed: PropertyValues) { + if (changed.has("rankedType")) { + // The ladders are separate lists sharing one scroll container: show the + // newly selected one from its top rather than the other one's offset. + if (this.scrollContainer) this.scrollContainer.scrollTop = 0; + this.scheduleStickyVisibilityCheck(); + this.schedulePlayerFillCheck(); + } + } + public async ensureLoaded() { if (this.hasLoadedPlayers || this.isLoading) return; await this.loadPlayerLeaderboard(true); @@ -37,10 +104,8 @@ export class LeaderboardPlayerList extends LitElement { public async loadPlayerLeaderboard(reset = false) { if (reset) { this.currentPage = 1; - this.playerHasMore = true; this.loadMoreError = null; - this.playerData = []; - this.currentUserEntry = null; + this.ladders = emptyLadders(); this.showStickyUser = false; } else if (!this.playerHasMore) { return; @@ -65,56 +130,57 @@ export class LeaderboardPlayerList extends LitElement { // Past the last page. Not an error: stop paging, leave the footer clear. if (result === "reached_limit") { - this.playerHasMore = false; + this.ladders = this.mapLadders((ladder) => ({ + ...ladder, + hasMore: false, + })); this.hasLoadedPlayers = true; return; } - const nextPlayers: PlayerLeaderboardEntry[] = result[ - RankedType.OneVOne - ].map((entry) => ({ - rank: entry.rank, - playerId: entry.public_id, - accountUsername: entry.accountUsername ?? null, - elo: entry.elo, - games: entry.total, - wins: entry.wins, - losses: entry.losses, - winRate: entry.total > 0 ? entry.wins / entry.total : 0, - })); - - const receivedCount = nextPlayers.length; - if (reset) { - this.playerData = nextPlayers; - } else { - const existingIds = new Set( - this.playerData.map((player) => player.playerId), - ); - const deduped = nextPlayers.filter( - (player) => !existingIds.has(player.playerId), - ); - this.playerData = [...this.playerData, ...deduped]; - } - - if (receivedCount > 0) { - this.currentPage++; - } - - if (receivedCount < this.playerPageSize) { - this.playerHasMore = false; - } - if (reset && !this.currentUserIdLoaded) { this.currentUserIdLoaded = true; const userMe = await getUserMe(); this.currentUserId = userMe ? userMe.player.publicId : null; } - if (this.currentUserId && !this.currentUserEntry) { - this.currentUserEntry = - nextPlayers.find( - (player) => player.playerId === this.currentUserId, - ) ?? null; + // One page carries a slice of every ladder, so fold them all in — even + // the ones whose tab isn't showing. + let receivedAny = false; + const nextLadders = { ...this.ladders }; + for (const rankedType of RANKED_TYPES) { + const ladder = this.ladders[rankedType]; + const nextPlayers = (result[rankedType] ?? []).map(toPlayerEntry); + if (nextPlayers.length > 0) receivedAny = true; + + const existingIds = new Set( + ladder.entries.map((player) => player.playerId), + ); + nextLadders[rankedType] = { + entries: [ + ...ladder.entries, + ...nextPlayers.filter( + (player) => !existingIds.has(player.playerId), + ), + ], + // Ranks differ per ladder, so the pinned "your ranking" row is + // whichever row this ladder gave the signed-in player. + userEntry: + ladder.userEntry ?? + nextPlayers.find( + (player) => player.playerId === this.currentUserId, + ) ?? + null, + // A short page ends that ladder. The ladders run out at different + // pages — the 2v2 board is younger and has no backfill — so this is + // tracked per ladder rather than off the 1v1 page size. + hasMore: ladder.hasMore && nextPlayers.length >= this.playerPageSize, + }; + } + this.ladders = nextLadders; + + if (receivedAny) { + this.currentPage++; } this.hasLoadedPlayers = true; @@ -420,10 +486,45 @@ export class LeaderboardPlayerList extends LitElement { `; } + // An empty ladder is a normal state, not an error: a ranked type that has + // just launched starts with no rows and fills as games are played. + private renderNoData() { + return html` +
+
+ + + +
+

+ ${translateText("leaderboard_modal.no_data_yet")} +

+

+ ${translateText("leaderboard_modal.ranked_no_stats")} +

+
+ `; + } + render() { if (this.isLoading && this.playerData.length === 0) return this.renderLoading(); if (this.error) return this.renderError(); + if (this.hasLoadedPlayers && this.playerData.length === 0) + return this.renderNoData(); return html`
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index aee0905141..f5d5667e44 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -513,6 +513,10 @@ export type RankedLeaderboardEntry = z.infer< export const RankedLeaderboardResponseSchema = z.object({ [RankedType.OneVOne]: RankedLeaderboardEntrySchema.array(), + // Each ranked type is its own ladder, ranked independently: a player can + // appear on both with a different elo and rank. Defaulted because an API + // deployment that predates the 2v2 ladder omits the key entirely. + [RankedType.TwoVTwo]: RankedLeaderboardEntrySchema.array().default([]), }); export type RankedLeaderboardResponse = z.infer< typeof RankedLeaderboardResponseSchema diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index a6baceb2d7..6163b8de71 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -16,6 +16,8 @@ vi.mock("../../src/client/Utils", () => ({ "Weighted losses based on clan participation and match difficulty", "leaderboard_modal.title": "Leaderboard", "leaderboard_modal.ranked_tab": "Ranked", + "leaderboard_modal.ranked_2v2_tab": "2v2 Ranked", + "leaderboard_modal.ranked_no_stats": "No ranked games on this ladder", "leaderboard_modal.clans_tab": "Clans", "leaderboard_modal.refresh_time": "Refreshed every 1 hour", "leaderboard_modal.error": "Something went wrong", @@ -132,6 +134,7 @@ beforeEach(() => { import "../../src/client/components/baseComponents/Modal"; import { LeaderboardModal } from "../../src/client/LeaderboardModal"; +import { RankedType } from "../../src/core/game/Game"; describe("LeaderboardModal", () => { let modal: LeaderboardModal; @@ -152,10 +155,20 @@ describe("LeaderboardModal", () => { modal.querySelector("leaderboard-player-list") as { loadPlayerLeaderboard: (reset?: boolean) => Promise; updateComplete: Promise; + rankedType: RankedType; playerData: Array>; currentUserEntry?: { playerId: string } | null; showStickyUser: boolean; } | null; + // The list element is shared by both ladder tabs; the modal drives which + // ladder it shows off the active tab. + const showLadder = async (tab: string) => { + (modal as unknown as { activeTab: string }).activeTab = tab; + await modal.updateComplete; + const playerList = getPlayerList()!; + await playerList.updateComplete; + return playerList; + }; beforeEach(async () => { vi.stubGlobal("fetch", vi.fn()); @@ -521,6 +534,138 @@ describe("LeaderboardModal", () => { }); }); + // 1v1 and 2v2 are independent ladders returned by one request: same player, + // own rank and elo on each, own end of list. + describe("Ranked ladders", () => { + const entry = (rank: number, publicId: string, elo: number) => ({ + rank, + elo, + peakElo: elo, + wins: 1, + losses: 1, + total: 2, + public_id: publicId, + username: publicId, + accountUsername: publicId, + }); + + it("ranks a player separately on each ladder", async () => { + const { getUserMe } = await import("../../src/client/Api"); + (getUserMe as ReturnType).mockResolvedValueOnce({ + player: { publicId: "player-2" }, + }); + + const fetchMock = global.fetch as ReturnType; + fetchMock.mockResolvedValueOnce( + jsonRes({ + "1v1": [entry(1, "player-1", 1200), entry(2, "player-2", 1100)], + "2v2": [entry(1, "player-2", 1500), entry(2, "player-3", 1400)], + }), + ); + + const playerList = getPlayerList()!; + await playerList.loadPlayerLeaderboard(true); + await playerList.updateComplete; + + expect(playerList.rankedType).toBe(RankedType.OneVOne); + expect(playerList.playerData.map((p) => p.playerId)).toEqual([ + "player-1", + "player-2", + ]); + expect(playerList.playerData[1].elo).toBe(1100); + expect(playerList.currentUserEntry?.playerId).toBe("player-2"); + + // One request filled both ladders, so switching tabs must not refetch. + const callCount = fetchMock.mock.calls.length; + const twoVTwo = await showLadder("players2v2"); + + expect(twoVTwo.rankedType).toBe(RankedType.TwoVTwo); + expect(twoVTwo.playerData.map((p) => p.playerId)).toEqual([ + "player-2", + "player-3", + ]); + // Same player, other ladder: rank 1 at 1500 rather than rank 2 at 1100. + expect(twoVTwo.playerData[0].rank).toBe(1); + expect(twoVTwo.playerData[0].elo).toBe(1500); + expect(twoVTwo.currentUserEntry?.playerId).toBe("player-2"); + expect(fetchMock.mock.calls.length).toBe(callCount); + }); + + // The 2v2 board starts empty and has no backfill, so an empty ladder is a + // normal state rather than a failure. + it("shows a no data state for an empty ladder", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce( + jsonRes({ "1v1": [entry(1, "player-1", 1200)], "2v2": [] }), + ); + + const playerList = getPlayerList()!; + await playerList.loadPlayerLeaderboard(true); + await showLadder("players2v2"); + + expect(modal.textContent).toContain("No data yet"); + expect(modal.textContent).toContain("No ranked games on this ladder"); + expect(modal.querySelector("leaderboard-player-list table")).toBeNull(); + }); + + it("pages the ladders independently", async () => { + const fetchMock = global.fetch as ReturnType; + fetchMock.mockResolvedValueOnce( + jsonRes({ + "1v1": Array.from({ length: 50 }, (_, i) => + entry(i + 1, `solo-${i + 1}`, 2000 - i), + ), + // Short page: the younger ladder ends first. + "2v2": [entry(1, "duo-1", 1500)], + }), + ); + + const playerList = getPlayerList()!; + await playerList.loadPlayerLeaderboard(true); + await playerList.updateComplete; + expect(playerList.playerData).toHaveLength(50); + + fetchMock.mockResolvedValueOnce( + jsonRes({ "1v1": [entry(51, "solo-51", 1900)], "2v2": [] }), + ); + await playerList.loadPlayerLeaderboard(); + await playerList.updateComplete; + + expect(playerList.playerData).toHaveLength(51); + expect(fetchMock.mock.calls[1][0]).toContain("page=2"); + + // The exhausted ladder keeps its row and stops asking for pages. + const twoVTwo = await showLadder("players2v2"); + expect(twoVTwo.playerData.map((p) => p.playerId)).toEqual(["duo-1"]); + + const callCount = fetchMock.mock.calls.length; + await twoVTwo.loadPlayerLeaderboard(); + expect(fetchMock.mock.calls.length).toBe(callCount); + }); + + it("selects the 2v2 ladder from its tab", async () => { + (global.fetch as ReturnType).mockResolvedValue( + jsonRes({ "1v1": [], "2v2": [] }), + ); + + modal.inline = true; + await modal.updateComplete; + const oModal = modal.querySelector("o-modal"); + await (oModal as unknown as { updateComplete: Promise }) + .updateComplete; + const tab = oModal!.shadowRoot!.querySelector( + 'button[role="tab"][data-key="players2v2"]', + ); + expect(tab).toBeTruthy(); + + tab!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect((modal as unknown as { activeTab: string }).activeTab).toBe( + "players2v2", + ); + await modal.updateComplete; + expect(getPlayerList()!.rankedType).toBe(RankedType.TwoVTwo); + }); + }); + describe("Modal Functionality", () => { it("should initialize with default state", () => { expect(modal).toBeTruthy(); From 535a79cb0badd9722cca8a9be6ab6b3617ffbbd1 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 28 Jul 2026 16:40:31 -0700 Subject: [PATCH 2/2] test(leaderboard): scope the tab-click fetch mock to the ranked URL The blanket mockResolvedValue also answered the clan and tribe prefetches fired by the tab click, feeding them ladder-shaped JSON that crashed their renders after the test ended (unhandled rejection, vitest exit 1). Co-Authored-By: Claude Fable 5 --- tests/client/LeaderboardModal.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index 6163b8de71..3b3d5fba2d 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -643,8 +643,18 @@ describe("LeaderboardModal", () => { }); it("selects the 2v2 ladder from its tab", async () => { - (global.fetch as ReturnType).mockResolvedValue( - jsonRes({ "1v1": [], "2v2": [] }), + // The tab click also prefetches the clan and tribe tabs, so route by + // URL: a blanket resolved value would feed them ladder-shaped JSON and + // crash their renders after the test ends. + (global.fetch as ReturnType).mockImplementation( + async (input: any) => { + const url = + typeof input === "string" ? input : (input?.url ?? String(input)); + if (url.includes("/leaderboard/ranked")) { + return jsonRes({ "1v1": [], "2v2": [] }); + } + return jsonRes({}, false, 404); + }, ); modal.inline = true;