diff --git a/resources/lang/en.json b/resources/lang/en.json index ba3290ba5..273316c89 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -38,15 +38,12 @@ "marketing_title": "Email updates", "no_games": "No games played yet.", "no_stats": "No stats available yet. Play some games to start tracking.", - "not_found": "Not Found", "or": "OR", - "public_player_id": "Public ID:", "reactivate_subscription": "Reactivate", "recovery_email_sent": "Recovery email sent to {email}", "reward_daily": "Daily subscription reward", "reward_signup_bonus": "Subscription signup bonus", "sign_in_desc": "Sign in to save your stats and progress", - "stats_overview": "Stats Overview", "sub_price_monthly": "{price}/mo", "sub_renews_on": "Renews {date}", "sub_status_active": "Active", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 233571a2c..fd7c2f3b8 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -107,25 +107,19 @@ export class AccountModal extends BaseModal { protected renderHeaderSlot() { const isLoggedIn = !!this.userMeResponse?.user; const publicId = this.userMeResponse?.player?.publicId ?? ""; - const displayId = publicId || translateText("account_modal.not_found"); return modalHeader({ title: translateText("account_modal.title"), onBack: () => this.close(), ariaLabel: translateText("common.back"), rightContent: - isLoggedIn && !this.isLoadingUser + isLoggedIn && !this.isLoadingUser && publicId ? html` -
- ${translateText("account_modal.public_player_id")} - -
+ ` : undefined, }); @@ -412,30 +406,10 @@ export class AccountModal extends BaseModal { translateText("account_modal.no_stats"), ); } - const publicId = this.userMeResponse?.player?.publicId ?? ""; return html` -
-
-

- 📊 - ${translateText("account_modal.stats_overview")} -

- ${publicId - ? html` - - ` - : ""} -
- -
+ `; } diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts index 6a6a578c0..d2dc361c2 100644 --- a/src/client/GameStatsModal.ts +++ b/src/client/GameStatsModal.ts @@ -11,7 +11,7 @@ export class GameStatsModal extends BaseModal { protected routerName = "stats"; @state() private gameId: string | null = null; - private openedFrom: "account" | "clan" | null = null; + private openedFrom: "account" | "clan" | "profile" | null = null; protected modalConfig() { return { maxWidth: "960px" }; @@ -66,6 +66,11 @@ export class GameStatsModal extends BaseModal { this.open({ gameID: gameId }); } + public openFromProfile(gameId: string): void { + this.openedFrom = "profile"; + this.open({ gameID: gameId }); + } + private back(): void { const openedFrom = this.openedFrom; this.close(); @@ -73,6 +78,12 @@ export class GameStatsModal extends BaseModal { document .querySelector("account-modal") ?.returnToGames(); + } else if (openedFrom === "profile") { + document + .querySelector< + HTMLElement & { returnToGames(): void } + >("player-profile-modal") + ?.returnToGames(); } else if (openedFrom === "clan") { document .querySelector< diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 8373f3e41..3b97dbcca 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -1,7 +1,10 @@ import { html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { ClientEnv } from "src/client/ClientEnv"; import { isVerifiedUsername, type PlayerStatsTree } from "../core/ApiSchemas"; import { fetchPublicPlayerProfile } from "./Api"; +import "./components/baseComponents/stats/PlayerGameHistoryView"; +import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; import "./components/PlayerName"; @@ -23,9 +26,23 @@ export class PlayerProfileModal extends BaseModal { @state() private statsTree: PlayerStatsTree | null = null; @state() private loading = false; private openedFrom: "clan" | "leaderboard" | "account" | null = null; + // Mirrors the account modal's Games tab: keep the accumulated history list + + // cursor across tab switches (and the game-stats detour) so re-entering Games + // restores the scroll position the viewer had built up. + private gameHistoryCache: PlayerGameHistoryCache | null = null; + private gamesScrollTop = 0; + private restoreGamesScrollAfterOpen = false; + // Bumped on every profile load so a superseded in-flight response is dropped. + private loadGeneration = 0; protected modalConfig() { - return { maxWidth: "960px" }; + return { + maxWidth: "960px", + tabs: [ + { key: "stats", label: translateText("account_modal.tab_stats") }, + { key: "games", label: translateText("account_modal.tab_games") }, + ], + }; } protected renderHeaderSlot() { @@ -58,14 +75,45 @@ export class PlayerProfileModal extends BaseModal { }); } - protected renderBody() { + protected renderBody(tab: string) { return html` -
- ${this.renderProfile()} +
+
+ ${tab === "games" ? this.renderGames() : this.renderProfile()} +
`; } + private renderGames() { + const publicId = this.publicId; + if (!publicId) { + return html` +
+ 🎮 +

+ ${translateText("account_modal.no_games")} +

+
+ `; + } + return html` + ) => { + this.gameHistoryCache = e.detail; + }} + @view-stats=${(e: CustomEvent<{ gameId: string }>) => + this.openGameStats(e.detail.gameId)} + @view-game=${(e: CustomEvent<{ gameId: string }>) => + this.viewGame(e.detail.gameId)} + > + `; + } + private renderProfile() { if (this.loading) { return this.renderLoadingSpinner(translateText("player_profile.loading")); @@ -81,11 +129,9 @@ export class PlayerProfileModal extends BaseModal { `; } return html` -
- -
+ `; } @@ -94,9 +140,33 @@ export class PlayerProfileModal extends BaseModal { typeof args?.publicID === "string" && args.publicID.length > 0 ? args.publicID : null; + + // Returning from the game-stats modal. The page router closed this modal + // underneath when the stats page showed, but onClose deliberately preserves + // state (like the account modal) — so restore the scroll and keep the + // game-history cache instead of refetching. Preserve even when the profile + // stats never loaded (still pending / failed): the Games tab loads + // independently, so its list should survive the detour regardless. + if ( + this.restoreGamesScrollAfterOpen && + publicId !== null && + publicId === this.publicId + ) { + this.restoreGamesScrollAfterOpen = false; + void this.restoreGamesScroll(); + return; + } + + // Fresh open (router/share link): clear any stale origin. The openFrom* + // helpers re-set it right after open() so back() routes home; the + // return-from-stats path above skips this and keeps the origin intact. + this.openedFrom = null; this.publicId = publicId; this.username = null; this.statsTree = null; + this.gameHistoryCache = null; + this.gamesScrollTop = 0; + this.restoreGamesScrollAfterOpen = false; this.loading = publicId !== null; if (publicId !== null) { void this.loadProfile(publicId); @@ -104,35 +174,77 @@ export class PlayerProfileModal extends BaseModal { } private async loadProfile(publicId: string): Promise { + const gen = ++this.loadGeneration; const profile = await fetchPublicPlayerProfile(publicId); - // A late response must not clobber state after close or re-open. - if (this.publicId !== publicId) return; + // Drop a superseded response: a newer load started, or the modal moved to a + // different player. onClose no longer clears publicId, so the id check alone + // can't reject a stale same-player load started before an earlier close. + if (gen !== this.loadGeneration || this.publicId !== publicId) return; this.loading = false; this.statsTree = profile === false ? null : profile.stats; this.username = profile === false ? null : (profile.username ?? null); } - protected onClose(): void { - this.publicId = null; - this.username = null; - this.statsTree = null; - this.loading = false; - this.openedFrom = null; + // Intentionally preserves publicId/statsTree/history cache/scroll: the page + // router closes this modal when the game-stats page opens on top, and the + // return flow (returnToGames) needs that state intact. onOpen resets it for a + // genuinely new player. Mirrors the account modal. + protected onClose(): void {} + + // Open the game-stats modal on top for a game in this player's history. Stash + // the scroll offset so returning restores it (see returnToGames()). + private openGameStats(gameId: string): void { + this.gamesScrollTop = this.modalEl?.getScrollTop() ?? 0; + const statsModal = document.querySelector< + HTMLElement & { openFromProfile(gameId: string): void } + >("game-stats-modal"); + statsModal?.openFromProfile(gameId); } + private viewGame(gameId: string): void { + this.close(); + const encodedGameId = encodeURIComponent(gameId); + const newUrl = `/${ClientEnv.workerPath(gameId)}/game/${encodedGameId}`; + + history.pushState({ join: gameId }, "", newUrl); + window.dispatchEvent( + new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }), + ); + } + + // Called by the game-stats modal's back button when it was opened from here. + public returnToGames(): void { + this.restoreGamesScrollAfterOpen = true; + this.open({ publicID: this.publicId ?? undefined, tab: "games" }); + } + + private async restoreGamesScroll(): Promise { + await this.updateComplete; + await this.modalEl?.updateComplete; + const historyView = this.querySelector< + HTMLElement & { updateComplete?: Promise } + >("player-game-history-view"); + await historyView?.updateComplete; + this.modalEl?.setScrollTop(this.gamesScrollTop); + } + + // Origin is set after open() in every openFrom* helper: onOpen clears it, and + // back() only reads it later on the user's click, so assigning here survives + // to route the back button home (and survives the game-stats detour, which + // never re-runs onOpen's fresh path). public openFromClan(publicId: string): void { - this.openedFrom = "clan"; this.open({ publicID: publicId }); + this.openedFrom = "clan"; } public openFromLeaderboard(publicId: string): void { - this.openedFrom = "leaderboard"; this.open({ publicID: publicId }); + this.openedFrom = "leaderboard"; } public openFromAccount(publicId: string): void { - this.openedFrom = "account"; this.open({ publicID: publicId }); + this.openedFrom = "account"; } private back(): void { diff --git a/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts index bdc8b7948..d4448c5cc 100644 --- a/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts +++ b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts @@ -1,4 +1,9 @@ -import { html, LitElement, type TemplateResult } from "lit"; +import { + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { type PlayerGameModeFilter, @@ -81,13 +86,25 @@ export class PlayerGameHistoryView extends LitElement { connectedCallback() { super.connectedCallback(); + this.syncToPublicId(); + } + + // Hydrate from the cache when it matches the current player, otherwise load + // that player's history fresh. + private syncToPublicId() { if (this.cachedState && this.cachedState.publicId === this.publicId) { this.games = this.cachedState.games; this.nextCursor = this.cachedState.nextCursor; this.typeFilter = this.cachedState.typeFilter; this.modeFilter = this.cachedState.modeFilter; + this.appendFailed = false; + this.loadState = "ok"; } else if (this.publicId) { - this.reload(); + // Fresh player → show the default (All) view rather than inheriting the + // previous player's filters. No-op on first mount (already defaults). + this.typeFilter = "all"; + this.modeFilter = "all"; + void this.reload(); } } @@ -96,6 +113,21 @@ export class PlayerGameHistoryView extends LitElement { this.teardownObserver(); } + willUpdate(changed: PropertyValues) { + // Reload when the host swaps in a different player after mount (e.g. a + // manual hash edit routes the profile modal to a new publicId). Lit reuses + // this element, so connectedCallback won't fire again. The undefined check + // skips the initial render, which connectedCallback already handled. + const previous = changed.get("publicId"); + if ( + changed.has("publicId") && + previous !== undefined && + previous !== this.publicId + ) { + this.syncToPublicId(); + } + } + updated() { // The IntersectionObserver target only exists when there's more to load AND // we're not mid-request — wire it up after each render so it tracks the diff --git a/src/client/components/baseComponents/stats/PlayerStatsTree.ts b/src/client/components/baseComponents/stats/PlayerStatsTree.ts index ebff61234..ec68507a0 100644 --- a/src/client/components/baseComponents/stats/PlayerStatsTree.ts +++ b/src/client/components/baseComponents/stats/PlayerStatsTree.ts @@ -1,4 +1,4 @@ -import { LitElement, PropertyValues, html } from "lit"; +import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas"; import { @@ -74,6 +74,51 @@ export class PlayerStatsTreeView extends LitElement { : translateText("game_mode.teams"); } + private labelForType(t: GameType | "Ranked") { + return t === "Ranked" + ? translateText("player_stats_tree.ranked") + : t === GameType.Public + ? translateText("player_stats_tree.public") + : t === GameType.Private + ? translateText("player_stats_tree.private") + : translateText("player_stats_tree.solo"); + } + + // A full-width filter row styled like the game-history tab: a pill container + // wrapping tab-style buttons that grow to fill the row. + private renderFilterRow(buttons: TemplateResult[]): TemplateResult { + return html` +
+ ${buttons} +
+ `; + } + + private renderFilterTab( + label: string, + isActive: boolean, + onSelect: () => void, + title?: string, + ): TemplateResult { + return html` + + `; + } + private labelForRankedType(r: RankedType) { switch (r) { case RankedType.OneVOne: @@ -275,103 +320,58 @@ export class PlayerStatsTreeView extends LitElement { return html`
- -
- -
- ${types.map( - (t) => html` - - `, - )} -
- -
- - ${this.selectedType === "Ranked" && rankedTypes.length - ? html`
- ${rankedTypes.map( - (r) => html` - - `, - )} -
` - : html``} - - - ${modes.length - ? html`
- ${modes.map( - (m) => html` - - `, - )} -
` - : html``} - - - ${!this.shouldMergeDifficulties && diffs.length - ? html`
- ${diffs.map( - (d) => - html` `, - )} -
` - : html``} -
+ +
+ ${this.renderFilterRow( + types.map((t) => + this.renderFilterTab( + this.labelForType(t), + this.selectedType === t, + () => this.setGameType(t), + ), + ), + )} + ${this.selectedType === "Ranked" && rankedTypes.length + ? this.renderFilterRow( + rankedTypes.map((r) => + this.renderFilterTab( + this.labelForRankedType(r) ?? "", + this.selectedRankedType === r, + () => this.setRankedType(r), + ), + ), + ) + : nothing} + ${modes.length + ? this.renderFilterRow( + modes.map((m) => + this.renderFilterTab( + this.labelForMode(m), + this.selectedMode === m, + () => this.setMode(m), + translateText("player_stats_tree.mode"), + ), + ), + ) + : nothing} + ${!this.shouldMergeDifficulties && diffs.length + ? this.renderFilterRow( + diffs.map((d) => + this.renderFilterTab( + translateText(`difficulty.${d.toLowerCase()}`), + this.selectedDifficulty === d, + () => this.setDifficulty(d), + translateText("difficulty.difficulty"), + ), + ), + ) + : nothing}
${leaf ? html` -
+
+ vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()), +); + +vi.mock("../../src/client/Api", () => ({ + fetchPublicPlayerProfile: vi.fn(async () => ({ + createdAt: "2026-01-01T00:00:00.000Z", + stats: { Public: { "Free For All": {} } }, + })), + fetchPublicPlayerGames: vi.fn(), +})); + +vi.mock("src/client/ClientEnv", () => ({ + ClientEnv: { workerPath: vi.fn(() => "w0") }, +})); + +vi.mock("../../src/client/TerrainMapFileLoader", () => ({ + terrainMapFileLoader: { + getMapData: vi.fn(() => ({ webpPath: "/maps/world.webp" })), + }, +})); + +vi.mock("../../src/client/Utils", () => ({ + getMapName: vi.fn((name: string) => name), + renderDuration: vi.fn((seconds: number) => `${seconds}s`), + translateText: vi.fn((key: string) => key), + copyToClipboard: copyToClipboardMock, +})); + +vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => { + class FakeGameInfoView extends HTMLElement {} + if (!customElements.get("game-info-view")) { + customElements.define("game-info-view", FakeGameInfoView); + } + return { GameInfoView: FakeGameInfoView }; +}); + +vi.mock( + "../../src/client/components/baseComponents/stats/PlayerStatsTree", + () => { + class FakePlayerStatsTreeView extends HTMLElement {} + if (!customElements.get("player-stats-tree-view")) { + customElements.define("player-stats-tree-view", FakePlayerStatsTreeView); + } + return { PlayerStatsTreeView: FakePlayerStatsTreeView }; + }, +); + +class FakeIntersectionObserver { + constructor(_callback: IntersectionObserverCallback) {} + observe() {} + disconnect() {} + unobserve() {} + takeRecords() { + return []; + } + root = null; + rootMargin = ""; + thresholds = []; +} +vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + +import { fetchPublicPlayerGames } from "../../src/client/Api"; +import { GameStatsModal } from "../../src/client/GameStatsModal"; +import { modalRouter } from "../../src/client/ModalRouter"; +import { initNavigation } from "../../src/client/Navigation"; +import { PlayerProfileModal } from "../../src/client/PlayerProfileModal"; +import { + GameMapType, + GameMode, + GameType, + PlayerInfo, + PlayerType, +} from "../../src/core/game/Game"; +import { setup } from "../util/Setup"; + +type ModalShell = HTMLElement & { + getScrollTop(): number; + setScrollTop(value: number): void; + updateComplete: Promise; +}; + +async function waitForProfile( + modal: PlayerProfileModal, + assertion: () => void, +): Promise { + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + const history = modal.querySelector("player-game-history-view") as + | (HTMLElement & { updateComplete: Promise }) + | null; + await history?.updateComplete; + assertion(); + }); +} + +async function waitForStatsModal( + modal: GameStatsModal, + assertion: () => void, +): Promise { + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + assertion(); + }); +} + +describe("Profile Games stats navigation", () => { + let modal: PlayerProfileModal; + let statsModal: GameStatsModal; + let playPage: HTMLElement; + + beforeAll(() => { + playPage = document.createElement("div"); + playPage.id = "page-play"; + document.body.appendChild(playPage); + initNavigation(); + }); + + afterAll(() => { + playPage.remove(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + const game = await setup( + "world", + { + gameMap: GameMapType.World, + gameMode: GameMode.FFA, + gameType: GameType.Public, + }, + [new PlayerInfo("Player", PlayerType.Human, null, "player-1")], + ); + const player = game.player("player-1"); + game.setWinner(player, game.stats().stats()); + const gameConfig = game.config().gameConfig(); + vi.mocked(fetchPublicPlayerGames).mockResolvedValue({ + results: [ + { + gameId: "game-1", + start: "2026-07-01T12:00:00.000Z", + durationSeconds: game.elapsedGameSeconds(), + map: gameConfig.gameMap, + mode: gameConfig.gameMode, + type: gameConfig.gameType.toLowerCase(), + playerTeams: gameConfig.playerTeams?.toString() ?? null, + rankedType: gameConfig.rankedType ?? "", + result: game.getWinner() === player ? "victory" : "defeat", + totalPlayers: game.players().length, + username: player.name(), + clanTag: player.info().clanTag, + }, + ], + nextCursor: null, + }); + vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) }); + history.replaceState(null, "", "/"); + modalRouter.register("profile", { + tag: "player-profile-modal", + pageId: "page-profile", + }); + modalRouter.register("stats", { + tag: "game-stats-modal", + pageId: "page-stats", + }); + if (!customElements.get("player-profile-modal")) { + customElements.define("player-profile-modal", PlayerProfileModal); + } + if (!customElements.get("game-stats-modal")) { + customElements.define("game-stats-modal", GameStatsModal); + } + + modal = document.createElement( + "player-profile-modal", + ) as PlayerProfileModal; + modal.id = "page-profile"; + modal.setAttribute("inline", ""); + modal.className = "hidden page-content"; + document.body.appendChild(modal); + + statsModal = document.createElement("game-stats-modal") as GameStatsModal; + statsModal.id = "page-stats"; + statsModal.setAttribute("inline", ""); + statsModal.className = "hidden page-content"; + document.body.appendChild(statsModal); + + await modal.updateComplete; + await statsModal.updateComplete; + + history.replaceState(null, "", "/#modal=profile&publicID=player-1"); + expect(modalRouter.routeFromHash()).toBe(true); + await waitForProfile(modal, () => { + expect(modal.isOpen()).toBe(true); + }); + + modal.setActiveTab("games"); + await waitForProfile(modal, () => { + expect(modal.querySelector("player-game-history-view")).not.toBeNull(); + }); + }); + + afterEach(() => { + window.showPage?.("page-play"); + modal.remove(); + statsModal.remove(); + history.replaceState(null, "", "/"); + vi.unstubAllGlobals(); + }); + + it("drills into Stats and returns to the cached Games scroll position", async () => { + const shell = modal.querySelector("o-modal") as ModalShell; + shell.setScrollTop(420); + + const statsButton = Array.from(modal.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "game_list.stats", + ); + expect(statsButton).toBeTruthy(); + statsButton!.click(); + await waitForStatsModal(statsModal, () => { + expect(statsModal.isOpen()).toBe(true); + expect(statsModal.querySelector("game-info-view")).not.toBeNull(); + }); + + expect(modal.isOpen()).toBe(false); + const statsView = statsModal.querySelector( + "game-info-view", + ) as HTMLElement & { gameId: string }; + expect(statsView.gameId).toBe("game-1"); + expect(window.location.hash).toBe("#modal=stats&gameID=game-1"); + + const backButton = statsModal.querySelector( + '[slot="header"] button', + ) as HTMLButtonElement; + backButton.click(); + await waitForProfile(modal, () => { + expect(modal.querySelector("player-game-history-view")).not.toBeNull(); + expect(shell.getScrollTop()).toBe(420); + }); + expect(window.location.hash).toBe( + "#modal=profile&publicID=player-1&tab=games", + ); + + // History wasn't refetched on return — the cached list was reused. + expect(fetchPublicPlayerGames).toHaveBeenCalledOnce(); + }); + + it("reloads the games history when the route changes to a different player", async () => { + // beforeEach opened player-1's history. + expect(fetchPublicPlayerGames).toHaveBeenCalledWith( + "player-1", + expect.anything(), + ); + + // Editing the hash to a different player re-routes the already-open modal. + // Lit reuses the mounted history view, so it must load the new player's + // games rather than keep showing player-1's. + history.replaceState( + null, + "", + "/#modal=profile&publicID=player-2&tab=games", + ); + expect(modalRouter.routeFromHash()).toBe(true); + + await waitForProfile(modal, () => { + expect(fetchPublicPlayerGames).toHaveBeenCalledWith( + "player-2", + expect.anything(), + ); + }); + }); + + it("resets filters to the default view when the route changes players", async () => { + // Pick a non-default type filter on player-1 (translateText is mocked to + // echo the key, so the Private tab's text is its label key). + const privateTab = Array.from(modal.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "account_modal.games_type_private", + ); + expect(privateTab).toBeTruthy(); + privateTab!.click(); + await waitForProfile(modal, () => { + const call = vi + .mocked(fetchPublicPlayerGames) + .mock.calls.find( + (c) => c[0] === "player-1" && c[1]?.type === "private", + ); + expect(call).toBeTruthy(); + }); + + // Routing to a different player must fall back to the default (All) view + // rather than inheriting player-1's Private filter. + history.replaceState( + null, + "", + "/#modal=profile&publicID=player-2&tab=games", + ); + expect(modalRouter.routeFromHash()).toBe(true); + + await waitForProfile(modal, () => { + const player2Calls = vi + .mocked(fetchPublicPlayerGames) + .mock.calls.filter((c) => c[0] === "player-2"); + // Every player-2 request (there should only be the one reset reload) must + // drop player-1's filters — asserting across all of them catches a stale + // first request even if a later one happens to be clean. + expect(player2Calls.length).toBeGreaterThan(0); + for (const [, opts] of player2Calls) { + expect(opts?.type).toBeUndefined(); + expect(opts?.filter).toBeUndefined(); + } + }); + }); +});