Custom tribe names in public games - #4727
Conversation
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 <noreply@anthropic.com>
WalkthroughCustom bot tribe names are validated, fetched for eligible public games, included in game-start data, and passed through execution so spawned bots can use them deterministically. ChangesCustom bot tribe names
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant GameServer
participant CustomTribesAPI
participant GameStartInfo
participant GameRunner
participant TribeSpawner
GameServer->>CustomTribesAPI: Fetch custom tribes during prestart
CustomTribesAPI-->>GameServer: Return validated tribe names
GameServer->>GameStartInfo: Include selected tribes
GameStartInfo->>GameRunner: Provide tribe names
GameRunner->>TribeSpawner: Spawn bots with purchased names
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with 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.
Inline comments:
In `@src/server/CustomTribes.ts`:
- Around line 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.
In `@tests/server/GameServerTribes.test.ts`:
- Around line 71-74: Update the afterEach cleanup hook to call
vi.useRealTimers() after vi.clearAllTimers(), ensuring subsequent tests use real
timer and Date implementations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 31a22d69-6b3a-4cc0-9a99-851db34045d4
📒 Files selected for processing (9)
src/core/GameRunner.tssrc/core/Schemas.tssrc/core/execution/ExecutionManager.tssrc/core/execution/TribeSpawner.tssrc/server/CustomTribes.tssrc/server/GameServer.tstests/core/execution/TribeSpawner.test.tstests/server/CustomTribes.test.tstests/server/GameServerTribes.test.ts
| 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) }), |
There was a problem hiding this comment.
🔒 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
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| vi.clearAllTimers(); | ||
| }); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg '(^|/)tests/server/GameServerTribes\.test\.ts$|(^|/)tests/util/Setup\.ts$' || true
echo "== target file outline/section =="
if [ -f tests/server/GameServerTribes.test.ts ]; then
wc -l tests/server/GameServerTribes.test.ts
sed -n '1,140p' tests/server/GameServerTribes.test.ts | cat -n
fi
echo "== search for useRealTimers/useFakeTimers in tests =="
rg -n "use(real|fake)Timers|clearAllTimers|restoreAllMocks" tests || true
echo "== vitest config =="
rg -n "globals|fakeTimers|testTimeout|include" vitest.config.* package.json tsconfig*.json 2>/dev/null || trueRepository: openfrontio/OpenFrontIO
Length of output: 8456
🌐 Web query:
Vitest vi.useRealTimers vi.clearAllTimers behavior beforeAll beforeEach afterEach fake timers
💡 Result:
In Vitest, vi.useRealTimers and vi.clearAllTimers serve distinct purposes in managing fake timer states [1][2]. vi.useRealTimers Calling vi.useRealTimers restores the original implementation of global timer functions (such as setTimeout, setInterval, etc.) and disables fake timers [3][4]. Importantly, when this method is called, all timers that were previously scheduled while fake timers were active are discarded and will never run [1][5][4]. If system time was mocked using vi.setSystemTime, calling vi.useRealTimers also resets the date to the real system date [3]. vi.clearAllTimers vi.clearAllTimers removes all timers that are currently scheduled to run within the fake timers environment [1]. Unlike vi.useRealTimers, it does not disable fake timers; it simply clears the queue of pending tasks [1][3]. Lifecycle Hook Usage It is best practice to manage timer restoration in lifecycle hooks to prevent side effects between tests [2][6]. 1. beforeEach: Often used to call vi.useFakeTimers to ensure a fresh fake timer environment for each test [7][8]. 2. afterEach: Typically used to call vi.useRealTimers to restore the environment, ensuring subsequent tests do not inherit unexpected timer behavior [2][6]. Example of recommended usage: import { beforeEach, afterEach, vi } from 'vitest'; beforeEach( => { vi.useFakeTimers; }); afterEach( => { vi.useRealTimers; }); If you need to clear pending timers during a test without disabling fake timers, you can use vi.clearAllTimers [2]. However, ensure that your cleanup strategy consistently resets the global state after each test to maintain test isolation [6].
Citations:
- 1: https://github.com/vitest-dev/vitest/blob/206e8cff/docs/api/vi.md
- 2: https://brunosabot.dev/posts/2024/mastering-time-using-fake-timers-with-vitest/
- 3: https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/integrations/mock/timers.ts
- 4: https://vitest.dev/api/vi
- 5: https://github.com/vitest-dev/vitest/blob/v4.1.7/docs/api/vi.md
- 6: https://vitest.dev/guide/mocking
- 7: https://vitest.dev/guide/mocking/dates
- 8: https://vitest.dev/guide/mocking/timers
Restore real timers during cleanup.
vi.clearAllTimers() only clears scheduled fake timers; unlike vi.useRealTimers(), it leaves the fake setTimeout/setInterval implementations active. Add vi.useRealTimers() after vi.clearAllTimers() here so the mocked Date/timer implementation does not remain active in later tests.
🤖 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 `@tests/server/GameServerTribes.test.ts` around lines 71 - 74, Update the
afterEach cleanup hook to call vi.useRealTimers() after vi.clearAllTimers(),
ensuring subsequent tests use real timer and Date implementations.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/server/CustomTribes.ts (1)
27-49: 🔒 Security & Privacy | 🟠 MajorSend this request to the master endpoint, not the JWT issuer.
This still derives the destination from
ServerEnv.jwtIssuer()while sending an internal API key. Use a centralized master base URL instead, preserving the path, timeout, headers, and request body.This repeats the unresolved endpoint issue from the previous review. Based on learnings, inter-service HTTP calls to the master should target
http://localhost:3000as 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 27 - 49, Update fetchCustomTribes to construct the request URL from the canonical master base URL http://localhost:3000 instead of ServerEnv.jwtIssuer(), while preserving the /custom_tribes path, timeout, headers, and request body unchanged.Source: Learnings
🤖 Prompt for all review comments with 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.
Duplicate comments:
In `@src/server/CustomTribes.ts`:
- Around line 27-49: Update fetchCustomTribes to construct the request URL from
the canonical master base URL http://localhost:3000 instead of
ServerEnv.jwtIssuer(), while preserving the /custom_tribes path, timeout,
headers, and request body unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: b7d22661-99a3-4d46-8954-0c47e7c3293f
📒 Files selected for processing (6)
src/core/GameRunner.tssrc/core/Schemas.tssrc/server/CustomTribes.tssrc/server/GameServer.tstests/server/CustomTribes.test.tstests/server/GameServerTribes.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- tests/server/GameServerTribes.test.ts
- tests/server/CustomTribes.test.ts
- src/core/GameRunner.ts
- src/server/GameServer.ts
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 <noreply@anthropic.com>
…4854) ## Problem The tribes leaderboard (`GET /leaderboard/tribes`) is empty. The original diagnosis was that prod servers predated the custom-tribes integration (#4727) — but after today's v0.33.0 deploy, fresh game records stamped with the v0.33.0 commit (`7a7ca5be`) **still** have no `info.tribes` key (checked `3DM5jo9P`, `TKDVms7Y`, `6e6gvscX` via the public game API). ## Root cause `GameServer.fetchTribes()` fetches the purchased-name pool at prestart and `start()` embeds it in the game start info sent to clients — but `archiveGame()` never passed it to `createPartialGameRecord`, which builds the record `info` from an explicit field list. So the tribes were dropped from the analytics record, and infra's ingest (`maybeSaveTribeNameStats`) silently no-ops on the missing field. `custom_tribe_name_stats_daily` has never been written. This also affects replays: they rebuild `GameStartInfo` from `record.info` (`GameEndInfoSchema` extends `GameStartInfoSchema`, so `tribes` is already part of the record schema), meaning replays currently spawn organic bot names instead of the purchased ones the live game showed. ## Fix - `createPartialGameRecord` takes an optional `tribes` param and includes it in `info`. - `archiveGame()` passes `this.gameStartInfo.tribes`. - Client callers (singleplayer/local archive paths) are unchanged — those games never have tribes and are skipped by ingest anyway. ## Tests - New regression tests in `ArchivePlayerRecord.test.ts`: tribes survive archiving; absent tribes stay absent. - Full suite passes (31 files, 291 tests), `tsc --noEmit` clean. ## Post-deploy verification - `curl -s "https://api.openfront.io/public/game/<id>?turns=false" | jq '.info.tribes'` on a finished public game with bots should return the name array. - `custom_tribe_name_stats_daily` should start accruing rows; `/leaderboard/tribes` populates within the 1-hour cache window. Note: this needs to ride a v0.33.x hotfix release to reach prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…4854) ## Problem The tribes leaderboard (`GET /leaderboard/tribes`) is empty. The original diagnosis was that prod servers predated the custom-tribes integration (#4727) — but after today's v0.33.0 deploy, fresh game records stamped with the v0.33.0 commit (`7a7ca5be`) **still** have no `info.tribes` key (checked `3DM5jo9P`, `TKDVms7Y`, `6e6gvscX` via the public game API). ## Root cause `GameServer.fetchTribes()` fetches the purchased-name pool at prestart and `start()` embeds it in the game start info sent to clients — but `archiveGame()` never passed it to `createPartialGameRecord`, which builds the record `info` from an explicit field list. So the tribes were dropped from the analytics record, and infra's ingest (`maybeSaveTribeNameStats`) silently no-ops on the missing field. `custom_tribe_name_stats_daily` has never been written. This also affects replays: they rebuild `GameStartInfo` from `record.info` (`GameEndInfoSchema` extends `GameStartInfoSchema`, so `tribes` is already part of the record schema), meaning replays currently spawn organic bot names instead of the purchased ones the live game showed. ## Fix - `createPartialGameRecord` takes an optional `tribes` param and includes it in `info`. - `archiveGame()` passes `this.gameStartInfo.tribes`. - Client callers (singleplayer/local archive paths) are unchanged — those games never have tribes and are skipped by ingest anyway. ## Tests - New regression tests in `ArchivePlayerRecord.test.ts`: tribes survive archiving; absent tribes stay absent. - Full suite passes (31 files, 291 tests), `tsc --noEmit` clean. ## Post-deploy verification - `curl -s "https://api.openfront.io/public/game/<id>?turns=false" | jq '.info.tribes'` on a finished public game with bots should return the name array. - `custom_tribe_name_stats_daily` should start accruing rows; `/leaderboard/tribes` populates within the 1-hour cache window. Note: this needs to ride a v0.33.x hotfix release to reach prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
Purchased bot tribe names now appear in public games, end to end:
GameServer.prestart()fires a best-effortPOST /custom_tribeswith the lobby's logged-in players (clientId+ accountpublicId; guests omitted, capped at 500). The returned tribes (loose{ name }objects — extra per-tribe fields the API adds later pass through to the record unchanged) are trimmed to the bot count (dropping from the tail — the API's global-pool slice) and embedded asgameStartInfo.tribes, matching the loose{ name }object shape infra parses at analytics ingest.TribeSpawner.spawnTribes()assigns each purchased name to one randomly selected bot slot using the seeded PRNG, so every client deterministically picks the same bots. Map-positioned custom tribes (map lore) keep priority over purchased names.tribesridesGameStartInfo→GameEndInfo→ the existing analytics record, so owner appearance stats need no extra end-of-game reporting (active names are globally unique, so the name alone identifies the tribe).Never blocks a game
Nothing awaits the fetch: prestart fires it and returns, and
start()(2s later) snapshots whatever arrived. The request aborts itself at 1.5s, leaving 500ms headroom; on timeout / non-200 / malformed response the game starts on schedule with organic bot names and a warning in the logs.Replay / determinism safety
When a game has no purchased names (private games, singleplayer, API failure, all pre-feature replays), the spawner consumes the PRNG stream exactly as before — the slot shuffle only runs when names exist. A test pins this by comparing both names and generated player IDs against the no-argument path.
tribesis optional onGameStartInfoSchema, so old records parse unchanged.Tests
tests/server/CustomTribes.test.ts— request shape (players posted,x-api-key), 500-player cap, throws on non-200 / malformed / bad tribe / network error so the caller fails open.tests/server/GameServerTribes.test.ts— prestart wiring: guest exclusion, tail-trimming to bot count, public-only and bots>0 gates, fail-open on fetch error, empty pool omitted from start info.tests/core/execution/TribeSpawner.test.ts— one bot per name, overflow dropped from tail, positioned-tribe priority, same-seed determinism, PRNG-stream compatibility with the pre-feature path.Full suite (2358 + 287),
tsc --noEmit, eslint, prettier all green.🤖 Generated with Claude Code