From 2ee7bf6597b8f8a4be4289cb94f9feb885c56e2f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 23 Oct 2026 14:49:45 -0400 Subject: [PATCH] bug: each tab asks about an online player once instead of on every roster broadcast --- stores/MatchmakingStore.ts | 59 +++++++----- tests/stores/onlineRosterAsksOnce.spec.ts | 108 ++++++++++++++++++++++ 2 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 tests/stores/onlineRosterAsksOnce.spec.ts diff --git a/stores/MatchmakingStore.ts b/stores/MatchmakingStore.ts index a0f78f950..b6b24b7a8 100644 --- a/stores/MatchmakingStore.ts +++ b/stores/MatchmakingStore.ts @@ -73,6 +73,11 @@ export const useMatchmakingStore = defineStore("matchmaking", () => { // change while its player sits in the list, so keep the ones already // fetched and only ask the API for steam ids never seen before. const profiles = new Map(); + // Every steam id already asked for, found or not: the api broadcasts the + // roster to every tab whenever anyone connects, so asking again for ids the + // api didn't return re-ran these queries in every tab on every broadcast. + const askedSteamIds = new Set(); + const MAX_ASKED_STEAM_IDS = 5000; const rebuildPlayersOnline = () => { playersOnline.value = onlinePlayerSteamIds.value @@ -82,18 +87,14 @@ export const useMatchmakingStore = defineStore("matchmaking", () => { const queryPlayers = async () => { const steamIds = onlinePlayerSteamIds.value; - const online = new Set(steamIds.map(String)); - // Bound the cache to who is actually online; otherwise a long-lived tab - // accumulates every player who has ever connected during its session. - for (const steamId of profiles.keys()) { - if (!online.has(steamId)) { - profiles.delete(steamId); - } + if (askedSteamIds.size > MAX_ASKED_STEAM_IDS) { + askedSteamIds.clear(); + profiles.clear(); } const missing = steamIds.filter( - (steamId) => !profiles.has(String(steamId)), + (steamId) => !askedSteamIds.has(String(steamId)), ); // Players only left, or they are all already known -- no round trip, but @@ -103,23 +104,35 @@ export const useMatchmakingStore = defineStore("matchmaking", () => { return; } - const { data } = await getGraphqlClient().query({ - query: generateQuery({ - players: [ - { - where: { - steam_id: { - _in: $("steam_ids", "[bigint]!"), + for (const steamId of missing) { + askedSteamIds.add(String(steamId)); + } + + let data: any; + try { + ({ data } = await getGraphqlClient().query({ + query: generateQuery({ + players: [ + { + where: { + steam_id: { + _in: $("steam_ids", "[bigint]!"), + }, }, }, - }, - playerFields, - ], - }), - variables: { - steam_ids: missing, - }, - }); + playerFields, + ], + }), + variables: { + steam_ids: missing, + }, + })); + } catch (error) { + for (const steamId of missing) { + askedSteamIds.delete(String(steamId)); + } + throw error; + } for (const player of data.players) { profiles.set(String(player.steam_id), player); diff --git a/tests/stores/onlineRosterAsksOnce.spec.ts b/tests/stores/onlineRosterAsksOnce.spec.ts new file mode 100644 index 000000000..a97ead62d --- /dev/null +++ b/tests/stores/onlineRosterAsksOnce.spec.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { print } from "graphql"; +import { flushPromises } from "@vue/test-utils"; +import { useMatchmakingStore } from "~/stores/MatchmakingStore"; + +vi.mock("~/web-sockets/Socket", async (importOriginal) => ({ + ...(await importOriginal()), + default: { + connect() {}, + rejoinAll() {}, + listen: () => ({ stop() {} }), + joinLobby: () => ({ messages: [], on() {}, leave() {} }), + markLobbyRead() {}, + }, +})); + +const ALPHA = "76561198000000101"; +const BRAVO = "76561198000000102"; +const CHARLIE = "76561198000000103"; +const UNKNOWN = "76561198000000199"; +const DELTA = "76561198000000104"; +const ECHO = "76561198000000105"; +const KNOWN = new Set([ALPHA, BRAVO, CHARLIE, DELTA, ECHO]); +let failNextPlayers = false; + +let asked: Array<{ kind: "players" | "message_requests"; ids: string[] }> = []; + +beforeEach(() => { + const client = useApollo().clients!.default; + vi.spyOn(client, "query").mockImplementation((async (options: any) => { + const document = print(options.query); + if (/allow_message_requests/.test(document)) { + asked.push({ + kind: "message_requests", + ids: options.variables.steamIds.map(String), + }); + return { data: { players: [] } }; + } + if (options.variables?.steam_ids) { + const ids = options.variables.steam_ids.map(String); + if (failNextPlayers) { + failNextPlayers = false; + throw new Error("offline"); + } + asked.push({ kind: "players", ids }); + return { + data: { + players: ids + .filter((id: string) => KNOWN.has(id)) + .map((steam_id: string) => ({ steam_id, name: steam_id })), + }, + }; + } + return { data: { __type: { fields: [] } } }; + }) as any); +}); + +afterEach(() => { + asked = []; + vi.restoreAllMocks(); +}); + +async function roster( + store: ReturnType, + ids: string[], +) { + store.onlinePlayerSteamIds = ids; + await new Promise((resolve) => setTimeout(resolve, 300)); + await flushPromises(); +} + +describe("the online roster", () => { + it("asks about each player once, however often the roster is broadcast", async () => { + const store = useMatchmakingStore(); + + await roster(store, [ALPHA, BRAVO, UNKNOWN]); + await roster(store, [UNKNOWN, BRAVO, ALPHA]); + await roster(store, [ALPHA, UNKNOWN]); + await roster(store, [ALPHA, BRAVO, UNKNOWN]); + await roster(store, [ALPHA, BRAVO, UNKNOWN, CHARLIE]); + + expect(asked.filter((a) => a.kind === "players")).toEqual([ + { kind: "players", ids: [ALPHA, BRAVO, UNKNOWN] }, + { kind: "players", ids: [CHARLIE] }, + ]); + expect(asked.filter((a) => a.kind === "message_requests")).toHaveLength(2); + expect(store.playersOnline.map((p: any) => p.steam_id)).toEqual([ + ALPHA, + BRAVO, + CHARLIE, + ]); + }); + + it("asks again for players whose lookup failed", async () => { + const store = useMatchmakingStore(); + failNextPlayers = true; + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + + await roster(store, [DELTA]); + await roster(store, [DELTA, ECHO]); + process.off("unhandledRejection", unhandled); + + expect(asked.filter((a) => a.kind === "players")).toEqual([ + { kind: "players", ids: [DELTA, ECHO] }, + ]); + }); +});