Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/core/GameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof TribeSchema>;

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
Expand Down
3 changes: 3 additions & 0 deletions src/core/execution/ExecutionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -133,6 +135,7 @@ export class Executor {
.filter((c): c is NonNullable<typeof c> => c !== undefined);
return new TribeSpawner(this.mg, this.gameID, nationCells).spawnTribes(
numTribes,
this.purchasedTribeNames,
);
}

Expand Down
30 changes: 28 additions & 2 deletions src/core/execution/TribeSpawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<number, string>();
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;
}
Expand Down
49 changes: 49 additions & 0 deletions src/server/CustomTribes.ts
Original file line number Diff line number Diff line change
@@ -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<Tribe[]> {
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) }),
Comment on lines +30 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Send this request to the master endpoint, not the JWT issuer.

Line 28 derives the destination from jwtIssuer() while sending an internal API key. In this deployment, master requests must target http://localhost:3000; an issuer URL can be a different service, causing failed tribe retrieval or credential disclosure to the wrong internal/external endpoint. Centralize the master base URL in ServerEnv and use it here. Based on learnings, “inter-service HTTP calls to the master should target http://localhost:3000 … [as] the canonical address.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/CustomTribes.ts` around lines 28 - 35, Update the request in the
custom tribe retrieval flow to use a centralized master base URL from ServerEnv
instead of ServerEnv.jwtIssuer(). Add or reuse the appropriate ServerEnv master
URL configuration with http://localhost:3000 as its value, while preserving the
existing endpoint path, headers, timeout, and request body.

Source: Learnings

});
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;
}
36 changes: 36 additions & 0 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> = new Set();
private outOfSyncClients: Set<ClientID> = new Set();

Expand Down Expand Up @@ -1056,6 +1062,7 @@ export class GameServer {
return;
}
this._hasPrestarted = true;
this.fetchTribes();

const prestartMsg = ServerPrestartMessageSchema.safeParse({
type: "prestart",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
117 changes: 117 additions & 0 deletions tests/core/execution/TribeSpawner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof spawn>) =>
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,
Expand Down
Loading
Loading