From ecc9a07ed6701ac86e357eeb0ab1cd71d37d218b Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 25 Jul 2026 19:11:36 -0700 Subject: [PATCH 1/4] Custom tribe names in public games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At prestart, the game server fetches the boost-weighted pool of purchased bot tribe names from the API (public games only, logged-in players posted, guests omitted) and embeds the result in the game start info. The fetch is strictly best-effort: fire-and-forget with a 1.5s timeout inside the 2s prestart->start gap, so a slow or failing API can never delay a game — it just starts with organic bot names. In core, TribeSpawner assigns each purchased name to one randomly selected bot slot using the seeded PRNG, so every client picks the same bots deterministically. Map-positioned custom tribes keep priority; overflow drops from the tail (the API's global-pool slice). When no purchased names are present the PRNG stream is consumed exactly as before, keeping pre-feature replays bit-identical. The tribes array rides GameStartInfo into the existing analytics record, so owner appearance stats need no extra end-of-game reporting. Co-Authored-By: Claude Fable 5 --- src/core/GameRunner.ts | 7 +- src/core/Schemas.ts | 14 ++ src/core/execution/ExecutionManager.ts | 3 + src/core/execution/TribeSpawner.ts | 24 +++- src/server/CustomTribes.ts | 47 +++++++ src/server/GameServer.ts | 36 +++++ tests/core/execution/TribeSpawner.test.ts | 117 ++++++++++++++++ tests/server/CustomTribes.test.ts | 96 +++++++++++++ tests/server/GameServerTribes.test.ts | 159 ++++++++++++++++++++++ 9 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 src/server/CustomTribes.ts create mode 100644 tests/server/CustomTribes.test.ts create mode 100644 tests/server/GameServerTribes.test.ts diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 8c3c153bbd..ac54604b78 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -78,7 +78,12 @@ export async function createGameRunner( const gr = new GameRunner( game, - new Executor(game, gameStart.gameID, clientID), + new Executor( + game, + gameStart.gameID, + clientID, + gameStart.tribes?.map((t) => t.name), + ), callBack, ); gr.init(); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f47dfa1961..c60d8aa36f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -718,12 +718,26 @@ export const PlayerSchema = z.object({ teamIndex: z.number().int().nonnegative().optional(), }); +// A purchased bot tribe name drawn for this game by the API. publicId is the +// tribe name's stable id (never a player id); ownerClientId matches +// players[].clientID in the same start info, null when the owner is not in +// this game. Mirrors infra's TribeSchema — embed API objects verbatim. +export const TribeSchema = z.object({ + name: SafeString.min(1).max(64), + publicId: z.string().min(1).max(64), + ownerClientId: ID.nullable(), +}); +export type Tribe = z.infer; + export const GameStartInfoSchema = z.object({ gameID: ID, lobbyCreatedAt: z.number(), visibleAt: z.number().optional(), config: GameConfigSchema, players: PlayerSchema.array(), + // Custom bot tribe names in use this game (public games only). Rides the + // analytics record to infra at game end for owner appearance stats. + tribes: z.array(TribeSchema).max(100).optional(), }); export const WinnerSchema = z diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 74d011ef71..6ecc0498c4 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -37,6 +37,8 @@ export class Executor { private mg: Game, private gameID: GameID, private clientID: ClientID | undefined, + // Purchased bot tribe names drawn for this game (GameStartInfo.tribes). + private purchasedTribeNames: string[] = [], ) { // Add one to avoid id collisions with tribes. this.random = new PseudoRandom(simpleHash(gameID) + 1); @@ -133,6 +135,7 @@ export class Executor { .filter((c): c is NonNullable => c !== undefined); return new TribeSpawner(this.mg, this.gameID, nationCells).spawnTribes( numTribes, + this.purchasedTribeNames, ); } diff --git a/src/core/execution/TribeSpawner.ts b/src/core/execution/TribeSpawner.ts index ff2f45941b..395aa4eb8d 100644 --- a/src/core/execution/TribeSpawner.ts +++ b/src/core/execution/TribeSpawner.ts @@ -25,7 +25,10 @@ export class TribeSpawner { this.nationTiles = new Set(nationCells.map((c) => gs.ref(c.x, c.y))); } - spawnTribes(numTribes: number): SpawnExecution[] { + spawnTribes( + numTribes: number, + purchasedNames: string[] = [], + ): SpawnExecution[] { const tribes: SpawnExecution[] = []; const { customTribes } = this.tribeNameData; @@ -42,9 +45,26 @@ export class TribeSpawner { } } + // Purchased tribe names (GameStartInfo.tribes) each go to one randomly + // selected remaining slot; with fewer slots than names, drop from the + // tail (the API's global-pool slice). Guarded so games without purchased + // names consume the PRNG exactly as before — old replays must not shift. + const remaining = numTribes - tribes.length; + let purchasedBySlot = new Map(); + if (purchasedNames.length > 0 && remaining > 0) { + const used = purchasedNames.slice(0, remaining); + const slots = this.random + .shuffleArray([...Array(remaining).keys()]) + .slice(0, used.length); + purchasedBySlot = new Map(slots.map((slot, i) => [slot, used[i]])); + } + // Fill remaining slots with random-spawn tribes. + let slot = 0; while (tribes.length < numTribes) { - tribes.push(this.spawnTribe(this.randomTribeName())); + const purchased = purchasedBySlot.get(slot); + tribes.push(this.spawnTribe(purchased ?? this.randomTribeName())); + slot++; } return tribes; } diff --git a/src/server/CustomTribes.ts b/src/server/CustomTribes.ts new file mode 100644 index 0000000000..a0dfe3cabb --- /dev/null +++ b/src/server/CustomTribes.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { Tribe, TribeSchema } from "../core/Schemas"; +import { ServerEnv } from "./ServerEnv"; + +const CustomTribesResponseSchema = z.object({ + tribes: TribeSchema.array().max(100), +}); + +// A logged-in human in the lobby: in-game client id + account public id. +// Guests can't own tribe names and must be omitted. +export interface TribePoolPlayer { + clientId: string; + publicId: string; +} + +/** + * Fetch the boost-weighted pool of purchased bot tribe names for a game: + * up to 10 owned by lobby players, then up to 10 from the global pool + * (array order carries the slicing, so callers drop from the tail). + * + * Throws on timeout/non-200/malformed response — callers fail open and + * start the game with organic bot names. The timeout must fit inside the + * 2s prestart->start window (see GameManager.tick). + */ +export async function fetchCustomTribes( + players: TribePoolPlayer[], +): Promise { + const response = await fetch(`${ServerEnv.jwtIssuer()}/custom_tribes`, { + method: "POST", + signal: AbortSignal.timeout(1500), + headers: { + "Content-Type": "application/json", + "x-api-key": ServerEnv.apiKey(), + }, + body: JSON.stringify({ players: players.slice(0, 500) }), + }); + if (!response.ok) { + throw new Error(`custom_tribes returned ${response.status}`); + } + const parsed = CustomTribesResponseSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new Error( + `custom_tribes returned malformed response: ${parsed.error.message}`, + ); + } + return parsed.data.tribes; +} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 5e8c6c365e..3822edccc7 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -31,12 +31,14 @@ import { ServerStartGameMessage, ServerTurnMessage, StampedIntent, + Tribe, Turn, } from "../core/Schemas"; import { createPartialGameRecord, simpleHash } from "../core/Util"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter"; +import { fetchCustomTribes } from "./CustomTribes"; import { ServerEnv } from "./ServerEnv"; import { noopMatchTelemetryEmitter, @@ -135,6 +137,10 @@ export class GameServer { private _hasPrestarted = false; + // Purchased bot tribe names drawn for this game, set when the prestart + // fetch lands (undefined until then / on fetch failure / non-public games). + private tribes?: Tribe[]; + private kickedPersistentIds: Set = new Set(); private outOfSyncClients: Set = new Set(); @@ -1056,6 +1062,7 @@ export class GameServer { return; } this._hasPrestarted = true; + this.fetchTribes(); const prestartMsg = ServerPrestartMessageSchema.safeParse({ type: "prestart", @@ -1085,6 +1092,34 @@ export class GameServer { }); } + // Public games draw purchased bot tribe names from the API at prestart — + // its 1.5s timeout fits the 2s prestart->start gap, so the pool is + // normally in hand when start() builds the game start info. Best effort: + // on timeout/error the game starts with organic bot names. + private fetchTribes(): void { + if (!this.isPublic() || this.gameConfig.bots === 0) { + return; + } + // Logged-in humans only — guests can't own tribe names. + const players = this.activeClients.flatMap((c) => + c.publicId !== undefined + ? [{ clientId: c.clientID, publicId: c.publicId }] + : [], + ); + fetchCustomTribes(players) + .then((tribes) => { + // One tribe per bot: with fewer bots than tribes, drop from the + // tail (the global-pool slice). + const used = tribes.slice(0, this.gameConfig.bots); + if (used.length > 0) { + this.tribes = used; + } + }) + .catch((error) => { + this.log.warn(`failed to fetch custom tribes: ${error}`); + }); + } + private startLobbyInfoBroadcast() { if (this._hasStarted || this._hasEnded) { return; @@ -1184,6 +1219,7 @@ export class GameServer { friends: friendsFor(c), teamIndex: this.matchmakingTeamIndex(c), })), + tribes: this.tribes, }); if (!result.success) { const error = z.prettifyError(result.error); diff --git a/tests/core/execution/TribeSpawner.test.ts b/tests/core/execution/TribeSpawner.test.ts index 399293aebb..4bef2f1a87 100644 --- a/tests/core/execution/TribeSpawner.test.ts +++ b/tests/core/execution/TribeSpawner.test.ts @@ -217,6 +217,123 @@ describe("TribeSpawner", () => { } }); + test("purchased names each go to exactly one tribe", async () => { + const game = await setup("plains", { bots: 5, gameMap: GameMapType.Asia }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Alpha"], + suffixes: ["Tribe"], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(5, ["Dragon Riders", "Night Wolves"]); + + expect(execs).toHaveLength(5); + const names = execs.map( + (e) => (e as unknown as { playerInfo: { name: string } }).playerInfo.name, + ); + expect(names.filter((n) => n === "Dragon Riders")).toHaveLength(1); + expect(names.filter((n) => n === "Night Wolves")).toHaveLength(1); + expect(names.filter((n) => n === "Alpha Tribe")).toHaveLength(3); + }); + + test("purchased names beyond the open slots are dropped from the tail", async () => { + const game = await setup("plains", { bots: 2, gameMap: GameMapType.Asia }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Alpha"], + suffixes: ["Tribe"], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(2, [ + "First Name", + "Second Name", + "Third Name", + ]); + + const names = execs.map( + (e) => (e as unknown as { playerInfo: { name: string } }).playerInfo.name, + ); + expect(names).toContain("First Name"); + expect(names).toContain("Second Name"); + expect(names).not.toContain("Third Name"); + }); + + test("positioned map tribes keep their slots ahead of purchased names", async () => { + const game = await setup("plains", { bots: 2, gameMap: GameMapType.Asia }); + const tile = findLandTile(game); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Alpha"], + suffixes: ["Tribe"], + customTribes: [ + { name: "Positioned", coordinates: [game.x(tile), game.y(tile)] }, + ], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(2, ["Bought One", "Bought Two"]); + + const names = execs.map( + (e) => (e as unknown as { playerInfo: { name: string } }).playerInfo.name, + ); + expect(names).toContain("Positioned"); + expect(names).toContain("Bought One"); + expect(names).not.toContain("Bought Two"); + }); + + test("purchased assignment is identical for the same game id", async () => { + const game = await setup("plains", { bots: 6, gameMap: GameMapType.Asia }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Alpha", "Beta", "Gamma"], + suffixes: ["Tribe", "Clan"], + }); + + const purchased = ["Dragon Riders", "Night Wolves"]; + const spawn = () => + new TribeSpawner(game, GAME_ID) + .spawnTribes(6, purchased) + .map( + (e) => + (e as unknown as { playerInfo: { name: string; id: string } }) + .playerInfo, + ); + + const first = spawn(); + const second = spawn(); + expect(second.map((p) => p.name)).toEqual(first.map((p) => p.name)); + expect(second.map((p) => p.id)).toEqual(first.map((p) => p.id)); + }); + + test("no purchased names leaves the organic sequence unchanged", async () => { + const game = await setup("plains", { bots: 4, gameMap: GameMapType.Asia }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Alpha", "Beta", "Gamma"], + suffixes: ["Tribe", "Clan"], + }); + + const spawn = (purchased?: string[]) => + purchased === undefined + ? new TribeSpawner(game, GAME_ID).spawnTribes(4) + : new TribeSpawner(game, GAME_ID).spawnTribes(4, purchased); + const infos = (execs: ReturnType) => + execs.map( + (e) => + (e as unknown as { playerInfo: { name: string; id: string } }) + .playerInfo, + ); + + // An empty purchased list must not shift the PRNG stream — the names + // AND ids must match the no-argument (replay-compatible) path. + const withoutArg = infos(spawn()); + const withEmpty = infos(spawn([])); + expect(withEmpty.map((p) => p.name)).toEqual(withoutArg.map((p) => p.name)); + expect(withEmpty.map((p) => p.id)).toEqual(withoutArg.map((p) => p.id)); + }); + test("all players spawn on valid land tiles", async () => { const game = await setup("plains", { bots: 0, diff --git a/tests/server/CustomTribes.test.ts b/tests/server/CustomTribes.test.ts new file mode 100644 index 0000000000..7329f96eab --- /dev/null +++ b/tests/server/CustomTribes.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchCustomTribes } from "../../src/server/CustomTribes"; + +// fetchCustomTribes resolves its endpoint from ServerEnv.jwtIssuer(), which +// throws if DOMAIN is unset. +process.env.DOMAIN ??= "localhost"; + +function jsonResponse(body: unknown, status = 200) { + return { ok: status < 300, status, json: async () => body }; +} + +const dragons = { + name: "Dragon Riders", + publicId: "AbC123xYz9AbC123xYz9Ab", + ownerClientId: "abcd1234", +}; +const wolves = { + name: "Night Wolves", + publicId: "Zz9876543210Zz98765432", + ownerClientId: null, +}; + +describe("fetchCustomTribes", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts the lobby players and returns the parsed tribes", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ tribes: [dragons, wolves] })); + vi.stubGlobal("fetch", fetchMock); + + const players = [{ clientId: "abcd1234", publicId: "pub-1" }]; + expect(await fetchCustomTribes(players)).toEqual([dragons, wolves]); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain("/custom_tribes"); + expect(init.headers["x-api-key"]).toBeDefined(); + expect(JSON.parse(init.body)).toEqual({ players }); + }); + + it("returns an empty pool for an empty lobby", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ tribes: [] })), + ); + expect(await fetchCustomTribes([])).toEqual([]); + }); + + it("caps the posted players at 500", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ tribes: [] })); + vi.stubGlobal("fetch", fetchMock); + + const players = Array.from({ length: 501 }, (_, i) => ({ + clientId: `client_${i}`, + publicId: `pub_${i}`, + })); + await fetchCustomTribes(players); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body).players).toHaveLength(500); + }); + + it("throws on a non-200 so the caller fails open", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 500))); + await expect(fetchCustomTribes([])).rejects.toThrow( + "custom_tribes returned 500", + ); + }); + + it("throws on a malformed response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ tribes: "nope" })), + ); + await expect(fetchCustomTribes([])).rejects.toThrow("malformed"); + }); + + it("throws on a tribe that fails validation", async () => { + const badTribe = { name: "X", publicId: "", ownerClientId: null }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ tribes: [badTribe] })), + ); + await expect(fetchCustomTribes([])).rejects.toThrow("malformed"); + }); + + it("propagates network errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")), + ); + await expect(fetchCustomTribes([])).rejects.toThrow("ECONNREFUSED"); + }); +}); diff --git a/tests/server/GameServerTribes.test.ts b/tests/server/GameServerTribes.test.ts new file mode 100644 index 0000000000..86b3f0e5f4 --- /dev/null +++ b/tests/server/GameServerTribes.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/core/Schemas", async () => { + const actual = (await vi.importActual("../../src/core/Schemas")) as any; + return { + ...actual, + GameStartInfoSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + ServerPrestartMessageSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + }; +}); + +vi.mock("../../src/server/CustomTribes", () => ({ + fetchCustomTribes: vi.fn(), +})); + +import { GameType } from "../../src/core/game/Game"; +import { fetchCustomTribes } from "../../src/server/CustomTribes"; +import { GameServer } from "../../src/server/GameServer"; + +const dragons = { + name: "Dragon Riders", + publicId: "AbC123xYz9AbC123xYz9Ab", + ownerClientId: "abcd1234", +}; +const wolves = { + name: "Night Wolves", + publicId: "Zz9876543210Zz98765432", + ownerClientId: null, +}; + +function fakeClient(clientID: string, publicId?: string) { + return { + clientID, + persistentID: `persist-${clientID}`, + publicId, + friends: [], + username: clientID, + clanTag: null, + role: null, + cosmetics: undefined, + ws: { readyState: 3, send: vi.fn() }, + } as any; +} + +// Lets the fetchCustomTribes .then/.catch chain in fetchTribes() settle. +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("GameServer custom tribes", () => { + let mockLogger: any; + + beforeEach(() => { + // restoreAllMocks doesn't touch vi.mock module mocks — reset the fetch + // mock's implementation and call history between tests explicitly. + vi.mocked(fetchCustomTribes).mockReset(); + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + }); + + function makeGame(config: Record = {}) { + return new GameServer("testgame", mockLogger, Date.now(), { + gameType: GameType.Public, + gameMap: "plains", + gameMapSize: 100, + bots: 400, + ...config, + } as any); + } + + it("fetches the pool at prestart and embeds the tribes in the start info", async () => { + vi.mocked(fetchCustomTribes).mockResolvedValue([dragons, wolves]); + const game = makeGame(); + game.activeClients.push( + fakeClient("abcd1234", "pub-1"), + fakeClient("efgh5678"), // guest — no account, must be omitted + ); + + game.prestart(); + await flushMicrotasks(); + game.start(); + + expect(fetchCustomTribes).toHaveBeenCalledWith([ + { clientId: "abcd1234", publicId: "pub-1" }, + ]); + expect((game as any).gameStartInfo.tribes).toEqual([dragons, wolves]); + }); + + it("drops tribes from the tail when there are fewer bots", async () => { + vi.mocked(fetchCustomTribes).mockResolvedValue([dragons, wolves]); + const game = makeGame({ bots: 1 }); + + game.prestart(); + await flushMicrotasks(); + game.start(); + + expect((game as any).gameStartInfo.tribes).toEqual([dragons]); + }); + + it("skips the fetch for non-public games", async () => { + const game = makeGame({ gameType: GameType.Private }); + + game.prestart(); + await flushMicrotasks(); + game.start(); + + expect(fetchCustomTribes).not.toHaveBeenCalled(); + expect((game as any).gameStartInfo.tribes).toBeUndefined(); + }); + + it("skips the fetch when bots are disabled", async () => { + const game = makeGame({ bots: 0 }); + + game.prestart(); + await flushMicrotasks(); + + expect(fetchCustomTribes).not.toHaveBeenCalled(); + }); + + it("starts without tribes when the fetch fails", async () => { + vi.mocked(fetchCustomTribes).mockRejectedValue(new Error("timeout")); + const game = makeGame(); + + game.prestart(); + await flushMicrotasks(); + game.start(); + + expect((game as any).gameStartInfo.tribes).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("failed to fetch custom tribes"), + ); + }); + + it("omits tribes from the start info when the pool is empty", async () => { + vi.mocked(fetchCustomTribes).mockResolvedValue([]); + const game = makeGame(); + + game.prestart(); + await flushMicrotasks(); + game.start(); + + expect((game as any).gameStartInfo.tribes).toBeUndefined(); + }); +}); From 7d5a07508a1ebd25cf02a40f527259d596d49582 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 26 Jul 2026 19:00:59 -0700 Subject: [PATCH 2/4] Simplify tribes to plain names The API now returns only tribe names, so drop publicId and ownerClientId: GameStartInfo.tribes becomes a plain string array (TribeNameSchema), and the fetch parses { tribes: [{ name }] }, stripping any extra per-tribe fields the API sends. Co-Authored-By: Claude Fable 5 --- src/core/GameRunner.ts | 7 +---- src/core/Schemas.ts | 19 ++++--------- src/server/CustomTribes.ts | 10 ++++--- src/server/GameServer.ts | 3 +- tests/server/CustomTribes.test.ts | 40 +++++++++++++++------------ tests/server/GameServerTribes.test.ts | 28 +++++++++---------- 6 files changed, 50 insertions(+), 57 deletions(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index ac54604b78..1539198793 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -78,12 +78,7 @@ export async function createGameRunner( const gr = new GameRunner( game, - new Executor( - game, - gameStart.gameID, - clientID, - gameStart.tribes?.map((t) => t.name), - ), + new Executor(game, gameStart.gameID, clientID, gameStart.tribes), callBack, ); gr.init(); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c60d8aa36f..11472f030d 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -718,16 +718,9 @@ export const PlayerSchema = z.object({ teamIndex: z.number().int().nonnegative().optional(), }); -// A purchased bot tribe name drawn for this game by the API. publicId is the -// tribe name's stable id (never a player id); ownerClientId matches -// players[].clientID in the same start info, null when the owner is not in -// this game. Mirrors infra's TribeSchema — embed API objects verbatim. -export const TribeSchema = z.object({ - name: SafeString.min(1).max(64), - publicId: z.string().min(1).max(64), - ownerClientId: ID.nullable(), -}); -export type Tribe = z.infer; +// A purchased bot tribe name drawn for this game by the API (active names +// are globally unique, so the name alone identifies the tribe). +export const TribeNameSchema = SafeString.min(1).max(64); export const GameStartInfoSchema = z.object({ gameID: ID, @@ -735,9 +728,9 @@ export const GameStartInfoSchema = z.object({ visibleAt: z.number().optional(), config: GameConfigSchema, players: PlayerSchema.array(), - // Custom bot tribe names in use this game (public games only). Rides the - // analytics record to infra at game end for owner appearance stats. - tribes: z.array(TribeSchema).max(100).optional(), + // Purchased bot tribe names in use this game (public games only). Rides + // the analytics record to infra at game end for owner appearance stats. + tribes: z.array(TribeNameSchema).max(100).optional(), }); export const WinnerSchema = z diff --git a/src/server/CustomTribes.ts b/src/server/CustomTribes.ts index a0dfe3cabb..78f35a91c8 100644 --- a/src/server/CustomTribes.ts +++ b/src/server/CustomTribes.ts @@ -1,9 +1,11 @@ import { z } from "zod"; -import { Tribe, TribeSchema } from "../core/Schemas"; +import { TribeNameSchema } from "../core/Schemas"; import { ServerEnv } from "./ServerEnv"; +// Any extra per-tribe fields the API sends are stripped — only the name is +// used in games. const CustomTribesResponseSchema = z.object({ - tribes: TribeSchema.array().max(100), + tribes: z.object({ name: TribeNameSchema }).array().max(100), }); // A logged-in human in the lobby: in-game client id + account public id. @@ -24,7 +26,7 @@ export interface TribePoolPlayer { */ export async function fetchCustomTribes( players: TribePoolPlayer[], -): Promise { +): Promise { const response = await fetch(`${ServerEnv.jwtIssuer()}/custom_tribes`, { method: "POST", signal: AbortSignal.timeout(1500), @@ -43,5 +45,5 @@ export async function fetchCustomTribes( `custom_tribes returned malformed response: ${parsed.error.message}`, ); } - return parsed.data.tribes; + return parsed.data.tribes.map((t) => t.name); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 3822edccc7..f42a465f7e 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -31,7 +31,6 @@ import { ServerStartGameMessage, ServerTurnMessage, StampedIntent, - Tribe, Turn, } from "../core/Schemas"; import { createPartialGameRecord, simpleHash } from "../core/Util"; @@ -139,7 +138,7 @@ export class GameServer { // Purchased bot tribe names drawn for this game, set when the prestart // fetch lands (undefined until then / on fetch failure / non-public games). - private tribes?: Tribe[]; + private tribes?: string[]; private kickedPersistentIds: Set = new Set(); private outOfSyncClients: Set = new Set(); diff --git a/tests/server/CustomTribes.test.ts b/tests/server/CustomTribes.test.ts index 7329f96eab..73fed9753e 100644 --- a/tests/server/CustomTribes.test.ts +++ b/tests/server/CustomTribes.test.ts @@ -9,30 +9,24 @@ function jsonResponse(body: unknown, status = 200) { return { ok: status < 300, status, json: async () => body }; } -const dragons = { - name: "Dragon Riders", - publicId: "AbC123xYz9AbC123xYz9Ab", - ownerClientId: "abcd1234", -}; -const wolves = { - name: "Night Wolves", - publicId: "Zz9876543210Zz98765432", - ownerClientId: null, -}; - describe("fetchCustomTribes", () => { afterEach(() => { vi.unstubAllGlobals(); }); - it("posts the lobby players and returns the parsed tribes", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(jsonResponse({ tribes: [dragons, wolves] })); + it("posts the lobby players and returns the tribe names", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + tribes: [{ name: "Dragon Riders" }, { name: "Night Wolves" }], + }), + ); vi.stubGlobal("fetch", fetchMock); const players = [{ clientId: "abcd1234", publicId: "pub-1" }]; - expect(await fetchCustomTribes(players)).toEqual([dragons, wolves]); + expect(await fetchCustomTribes(players)).toEqual([ + "Dragon Riders", + "Night Wolves", + ]); const [url, init] = fetchMock.mock.calls[0]; expect(url).toContain("/custom_tribes"); @@ -40,6 +34,18 @@ describe("fetchCustomTribes", () => { expect(JSON.parse(init.body)).toEqual({ players }); }); + it("strips extra per-tribe fields the API sends", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + tribes: [{ name: "Dragon Riders", futureField: "ignored" }], + }), + ), + ); + expect(await fetchCustomTribes([])).toEqual(["Dragon Riders"]); + }); + it("returns an empty pool for an empty lobby", async () => { vi.stubGlobal( "fetch", @@ -78,7 +84,7 @@ describe("fetchCustomTribes", () => { }); it("throws on a tribe that fails validation", async () => { - const badTribe = { name: "X", publicId: "", ownerClientId: null }; + const badTribe = { name: "" }; vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(jsonResponse({ tribes: [badTribe] })), diff --git a/tests/server/GameServerTribes.test.ts b/tests/server/GameServerTribes.test.ts index 86b3f0e5f4..ff478df354 100644 --- a/tests/server/GameServerTribes.test.ts +++ b/tests/server/GameServerTribes.test.ts @@ -21,17 +21,6 @@ import { GameType } from "../../src/core/game/Game"; import { fetchCustomTribes } from "../../src/server/CustomTribes"; import { GameServer } from "../../src/server/GameServer"; -const dragons = { - name: "Dragon Riders", - publicId: "AbC123xYz9AbC123xYz9Ab", - ownerClientId: "abcd1234", -}; -const wolves = { - name: "Night Wolves", - publicId: "Zz9876543210Zz98765432", - ownerClientId: null, -}; - function fakeClient(clientID: string, publicId?: string) { return { clientID, @@ -84,7 +73,10 @@ describe("GameServer custom tribes", () => { } it("fetches the pool at prestart and embeds the tribes in the start info", async () => { - vi.mocked(fetchCustomTribes).mockResolvedValue([dragons, wolves]); + vi.mocked(fetchCustomTribes).mockResolvedValue([ + "Dragon Riders", + "Night Wolves", + ]); const game = makeGame(); game.activeClients.push( fakeClient("abcd1234", "pub-1"), @@ -98,18 +90,24 @@ describe("GameServer custom tribes", () => { expect(fetchCustomTribes).toHaveBeenCalledWith([ { clientId: "abcd1234", publicId: "pub-1" }, ]); - expect((game as any).gameStartInfo.tribes).toEqual([dragons, wolves]); + expect((game as any).gameStartInfo.tribes).toEqual([ + "Dragon Riders", + "Night Wolves", + ]); }); it("drops tribes from the tail when there are fewer bots", async () => { - vi.mocked(fetchCustomTribes).mockResolvedValue([dragons, wolves]); + vi.mocked(fetchCustomTribes).mockResolvedValue([ + "Dragon Riders", + "Night Wolves", + ]); const game = makeGame({ bots: 1 }); game.prestart(); await flushMicrotasks(); game.start(); - expect((game as any).gameStartInfo.tribes).toEqual([dragons]); + expect((game as any).gameStartInfo.tribes).toEqual(["Dragon Riders"]); }); it("skips the fetch for non-public games", async () => { From 6e31d22c7a7da868295b4cacdbb92fafc6964c97 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 26 Jul 2026 19:11:30 -0700 Subject: [PATCH 3/4] Embed tribes as {name} objects to match the analytics contract Infra parses gameStartInfo.tribes as an array of loose {name} objects at ingest, so a plain string array would break appearance counting. Keep the object shape on the wire (extra API fields still stripped); core continues to consume just the names. Co-Authored-By: Claude Fable 5 --- src/core/GameRunner.ts | 7 ++++++- src/core/Schemas.ts | 13 +++++++++---- src/server/CustomTribes.ts | 8 ++++---- src/server/GameServer.ts | 3 ++- tests/server/CustomTribes.test.ts | 8 ++++---- tests/server/GameServerTribes.test.ts | 16 +++++++++------- 6 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 1539198793..ac54604b78 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -78,7 +78,12 @@ export async function createGameRunner( const gr = new GameRunner( game, - new Executor(game, gameStart.gameID, clientID, gameStart.tribes), + new Executor( + game, + gameStart.gameID, + clientID, + gameStart.tribes?.map((t) => t.name), + ), callBack, ); gr.init(); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 11472f030d..a6d7834106 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -718,9 +718,14 @@ export const PlayerSchema = z.object({ teamIndex: z.number().int().nonnegative().optional(), }); -// A purchased bot tribe name drawn for this game by the API (active names -// are globally unique, so the name alone identifies the tribe). -export const TribeNameSchema = SafeString.min(1).max(64); +// A purchased bot tribe name in use this game (active names are globally +// unique, so the name alone identifies the tribe). Infra parses these +// objects .loose() at analytics ingest — keep the object shape, and fields +// may be added later without breaking record parsing. +export const TribeSchema = z.object({ + name: SafeString.min(1).max(64), +}); +export type Tribe = z.infer; export const GameStartInfoSchema = z.object({ gameID: ID, @@ -730,7 +735,7 @@ export const GameStartInfoSchema = z.object({ players: PlayerSchema.array(), // Purchased bot tribe names in use this game (public games only). Rides // the analytics record to infra at game end for owner appearance stats. - tribes: z.array(TribeNameSchema).max(100).optional(), + tribes: z.array(TribeSchema).max(100).optional(), }); export const WinnerSchema = z diff --git a/src/server/CustomTribes.ts b/src/server/CustomTribes.ts index 78f35a91c8..6ec44a47cf 100644 --- a/src/server/CustomTribes.ts +++ b/src/server/CustomTribes.ts @@ -1,11 +1,11 @@ import { z } from "zod"; -import { TribeNameSchema } from "../core/Schemas"; +import { Tribe, TribeSchema } from "../core/Schemas"; import { ServerEnv } from "./ServerEnv"; // Any extra per-tribe fields the API sends are stripped — only the name is // used in games. const CustomTribesResponseSchema = z.object({ - tribes: z.object({ name: TribeNameSchema }).array().max(100), + tribes: TribeSchema.array().max(100), }); // A logged-in human in the lobby: in-game client id + account public id. @@ -26,7 +26,7 @@ export interface TribePoolPlayer { */ export async function fetchCustomTribes( players: TribePoolPlayer[], -): Promise { +): Promise { const response = await fetch(`${ServerEnv.jwtIssuer()}/custom_tribes`, { method: "POST", signal: AbortSignal.timeout(1500), @@ -45,5 +45,5 @@ export async function fetchCustomTribes( `custom_tribes returned malformed response: ${parsed.error.message}`, ); } - return parsed.data.tribes.map((t) => t.name); + return parsed.data.tribes; } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index f42a465f7e..3822edccc7 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -31,6 +31,7 @@ import { ServerStartGameMessage, ServerTurnMessage, StampedIntent, + Tribe, Turn, } from "../core/Schemas"; import { createPartialGameRecord, simpleHash } from "../core/Util"; @@ -138,7 +139,7 @@ export class GameServer { // Purchased bot tribe names drawn for this game, set when the prestart // fetch lands (undefined until then / on fetch failure / non-public games). - private tribes?: string[]; + private tribes?: Tribe[]; private kickedPersistentIds: Set = new Set(); private outOfSyncClients: Set = new Set(); diff --git a/tests/server/CustomTribes.test.ts b/tests/server/CustomTribes.test.ts index 73fed9753e..e7916a4a3a 100644 --- a/tests/server/CustomTribes.test.ts +++ b/tests/server/CustomTribes.test.ts @@ -14,7 +14,7 @@ describe("fetchCustomTribes", () => { vi.unstubAllGlobals(); }); - it("posts the lobby players and returns the tribe names", async () => { + it("posts the lobby players and returns the tribes", async () => { const fetchMock = vi.fn().mockResolvedValue( jsonResponse({ tribes: [{ name: "Dragon Riders" }, { name: "Night Wolves" }], @@ -24,8 +24,8 @@ describe("fetchCustomTribes", () => { const players = [{ clientId: "abcd1234", publicId: "pub-1" }]; expect(await fetchCustomTribes(players)).toEqual([ - "Dragon Riders", - "Night Wolves", + { name: "Dragon Riders" }, + { name: "Night Wolves" }, ]); const [url, init] = fetchMock.mock.calls[0]; @@ -43,7 +43,7 @@ describe("fetchCustomTribes", () => { }), ), ); - expect(await fetchCustomTribes([])).toEqual(["Dragon Riders"]); + expect(await fetchCustomTribes([])).toEqual([{ name: "Dragon Riders" }]); }); it("returns an empty pool for an empty lobby", async () => { diff --git a/tests/server/GameServerTribes.test.ts b/tests/server/GameServerTribes.test.ts index ff478df354..09cfb823d5 100644 --- a/tests/server/GameServerTribes.test.ts +++ b/tests/server/GameServerTribes.test.ts @@ -74,8 +74,8 @@ describe("GameServer custom tribes", () => { it("fetches the pool at prestart and embeds the tribes in the start info", async () => { vi.mocked(fetchCustomTribes).mockResolvedValue([ - "Dragon Riders", - "Night Wolves", + { name: "Dragon Riders" }, + { name: "Night Wolves" }, ]); const game = makeGame(); game.activeClients.push( @@ -91,15 +91,15 @@ describe("GameServer custom tribes", () => { { clientId: "abcd1234", publicId: "pub-1" }, ]); expect((game as any).gameStartInfo.tribes).toEqual([ - "Dragon Riders", - "Night Wolves", + { name: "Dragon Riders" }, + { name: "Night Wolves" }, ]); }); it("drops tribes from the tail when there are fewer bots", async () => { vi.mocked(fetchCustomTribes).mockResolvedValue([ - "Dragon Riders", - "Night Wolves", + { name: "Dragon Riders" }, + { name: "Night Wolves" }, ]); const game = makeGame({ bots: 1 }); @@ -107,7 +107,9 @@ describe("GameServer custom tribes", () => { await flushMicrotasks(); game.start(); - expect((game as any).gameStartInfo.tribes).toEqual(["Dragon Riders"]); + expect((game as any).gameStartInfo.tribes).toEqual([ + { name: "Dragon Riders" }, + ]); }); it("skips the fetch for non-public games", async () => { From 2f3bd06b26cfa689f43d5da7d715863ff2a27ef8 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 26 Jul 2026 19:20:23 -0700 Subject: [PATCH 4/4] Make TribeSchema loose + document the positioned-tribe over-count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loose mirrors infra's analytics-ingest schema: a per-tribe field the API adds later now flows through to the record without a game-side change instead of being silently stripped. Also document on spawnTribes that the record assumes every embedded name spawns — positioned map customTribes would shrink the actual slots and over-claim appearances (dormant: no map ships positioned tribes today). Co-Authored-By: Claude Fable 5 --- src/core/Schemas.ts | 14 ++++++++------ src/core/execution/TribeSpawner.ts | 6 ++++++ src/server/CustomTribes.ts | 4 ++-- tests/server/CustomTribes.test.ts | 8 +++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index a6d7834106..f5b2cfba70 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -719,12 +719,14 @@ export const PlayerSchema = z.object({ }); // A purchased bot tribe name in use this game (active names are globally -// unique, so the name alone identifies the tribe). Infra parses these -// objects .loose() at analytics ingest — keep the object shape, and fields -// may be added later without breaking record parsing. -export const TribeSchema = z.object({ - name: SafeString.min(1).max(64), -}); +// unique, so the name alone identifies the tribe). Loose to mirror infra's +// analytics-ingest schema — a field the API adds later flows through to the +// record without a game-side change, instead of being silently stripped. +export const TribeSchema = z + .object({ + name: SafeString.min(1).max(64), + }) + .loose(); export type Tribe = z.infer; export const GameStartInfoSchema = z.object({ diff --git a/src/core/execution/TribeSpawner.ts b/src/core/execution/TribeSpawner.ts index 395aa4eb8d..78bd32abe7 100644 --- a/src/core/execution/TribeSpawner.ts +++ b/src/core/execution/TribeSpawner.ts @@ -49,6 +49,12 @@ export class TribeSpawner { // selected remaining slot; with fewer slots than names, drop from the // tail (the API's global-pool slice). Guarded so games without purchased // names consume the PRNG exactly as before — old replays must not shift. + // + // The analytics record assumes every passed name spawns: the server + // embeds the list capped only at the bot count, while positioned tribes + // shrink `remaining` here. No map ships positioned customTribes today, + // but one that does (with a low bot count) would make the record + // over-claim appearances for tail names that never spawned. const remaining = numTribes - tribes.length; let purchasedBySlot = new Map(); if (purchasedNames.length > 0 && remaining > 0) { diff --git a/src/server/CustomTribes.ts b/src/server/CustomTribes.ts index 6ec44a47cf..e2e9250266 100644 --- a/src/server/CustomTribes.ts +++ b/src/server/CustomTribes.ts @@ -2,8 +2,8 @@ import { z } from "zod"; import { Tribe, TribeSchema } from "../core/Schemas"; import { ServerEnv } from "./ServerEnv"; -// Any extra per-tribe fields the API sends are stripped — only the name is -// used in games. +// TribeSchema is loose: extra per-tribe fields the API sends pass through +// into the game start info (and thus the analytics record) unchanged. const CustomTribesResponseSchema = z.object({ tribes: TribeSchema.array().max(100), }); diff --git a/tests/server/CustomTribes.test.ts b/tests/server/CustomTribes.test.ts index e7916a4a3a..57e45cf162 100644 --- a/tests/server/CustomTribes.test.ts +++ b/tests/server/CustomTribes.test.ts @@ -34,16 +34,18 @@ describe("fetchCustomTribes", () => { expect(JSON.parse(init.body)).toEqual({ players }); }); - it("strips extra per-tribe fields the API sends", async () => { + it("passes extra per-tribe fields through for the analytics record", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( jsonResponse({ - tribes: [{ name: "Dragon Riders", futureField: "ignored" }], + tribes: [{ name: "Dragon Riders", futureField: "kept" }], }), ), ); - expect(await fetchCustomTribes([])).toEqual([{ name: "Dragon Riders" }]); + expect(await fetchCustomTribes([])).toEqual([ + { name: "Dragon Riders", futureField: "kept" }, + ]); }); it("returns an empty pool for an empty lobby", async () => {