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..f5b2cfba70 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 in use this game (active names are globally +// 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({ gameID: ID, lobbyCreatedAt: z.number(), visibleAt: z.number().optional(), config: GameConfigSchema, 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(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..78bd32abe7 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,32 @@ 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. + // + // 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) { + 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..e2e9250266 --- /dev/null +++ b/src/server/CustomTribes.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { Tribe, TribeSchema } from "../core/Schemas"; +import { ServerEnv } from "./ServerEnv"; + +// 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), +}); + +// 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..57e45cf162 --- /dev/null +++ b/tests/server/CustomTribes.test.ts @@ -0,0 +1,104 @@ +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 }; +} + +describe("fetchCustomTribes", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts the lobby players and returns the tribes", 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([ + { name: "Dragon Riders" }, + { name: "Night 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("passes extra per-tribe fields through for the analytics record", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + tribes: [{ name: "Dragon Riders", futureField: "kept" }], + }), + ), + ); + expect(await fetchCustomTribes([])).toEqual([ + { name: "Dragon Riders", futureField: "kept" }, + ]); + }); + + 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: "" }; + 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..09cfb823d5 --- /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"; + +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([ + { name: "Dragon Riders" }, + { name: "Night 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([ + { name: "Dragon Riders" }, + { name: "Night Wolves" }, + ]); + }); + + it("drops tribes from the tail when there are fewer bots", async () => { + vi.mocked(fetchCustomTribes).mockResolvedValue([ + { name: "Dragon Riders" }, + { name: "Night Wolves" }, + ]); + const game = makeGame({ bots: 1 }); + + game.prestart(); + await flushMicrotasks(); + game.start(); + + expect((game as any).gameStartInfo.tribes).toEqual([ + { name: "Dragon Riders" }, + ]); + }); + + 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(); + }); +});