Skip to content

Custom tribe names in public games - #4727

Merged
evanpelle merged 4 commits into
mainfrom
feat/custom-tribe-names-ingame
Jul 27, 2026
Merged

Custom tribe names in public games#4727
evanpelle merged 4 commits into
mainfrom
feat/custom-tribe-names-ingame

Conversation

@evanpelle

@evanpelle evanpelle commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What

Purchased bot tribe names now appear in public games, end to end:

  1. Game server (prestart): GameServer.prestart() fires a best-effort POST /custom_tribes with the lobby's logged-in players (clientId + account publicId; 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 as gameStartInfo.tribes, matching the loose { name } object shape infra parses at analytics ingest.
  2. Core (game start): 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.
  3. Analytics: tribes rides GameStartInfoGameEndInfo → 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. tribes is optional on GameStartInfoSchema, 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

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>
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Custom 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.

Changes

Custom bot tribe names

Layer / File(s) Summary
Tribe contract and API fetch
src/core/Schemas.ts, src/server/CustomTribes.ts, tests/server/CustomTribes.test.ts
Adds validated tribe-name data and a bounded /custom_tribes request with response and error handling tests.
Game server tribe acquisition
src/server/GameServer.ts, tests/server/GameServerTribes.test.ts
Fetches tribes during public-game prestart, limits them to the bot count, and includes them in game-start data with eligibility and failure-path coverage.
Purchased tribe spawning
src/core/GameRunner.ts, src/core/execution/ExecutionManager.ts, src/core/execution/TribeSpawner.ts, tests/core/execution/TribeSpawner.test.ts
Passes tribe names into execution and assigns them to available spawn slots deterministically while preserving organic spawning when none are supplied.

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
Loading

Possibly related PRs

Suggested labels: Gameplay, Feature

Poem

Names arrive on a little tide,
Checked by schemas side by side.
Bots claim slots with steady aim,
Replays follow the same game.
Tribes bloom when matches start.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: adding custom tribe names to public games.
Description check ✅ Passed The description is directly related to the pull request and explains the same feature and tests.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between edbeb69 and ecc9a07.

📒 Files selected for processing (9)
  • src/core/GameRunner.ts
  • src/core/Schemas.ts
  • src/core/execution/ExecutionManager.ts
  • src/core/execution/TribeSpawner.ts
  • src/server/CustomTribes.ts
  • src/server/GameServer.ts
  • tests/core/execution/TribeSpawner.test.ts
  • tests/server/CustomTribes.test.ts
  • tests/server/GameServerTribes.test.ts

Comment on lines +28 to +35
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) }),

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

Comment on lines +71 to +74
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
});

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.

🩺 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 || true

Repository: 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:


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.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Jul 26, 2026
evanpelle and others added 2 commits July 26, 2026 19:00
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>

@coderabbitai coderabbitai Bot left a comment

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.

♻️ Duplicate comments (1)
src/server/CustomTribes.ts (1)

27-49: 🔒 Security & Privacy | 🟠 Major

Send 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: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 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7d5a075 and 6e31d22.

📒 Files selected for processing (6)
  • src/core/GameRunner.ts
  • src/core/Schemas.ts
  • src/server/CustomTribes.ts
  • src/server/GameServer.ts
  • tests/server/CustomTribes.test.ts
  • tests/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>
@evanpelle evanpelle added this to the v33 milestone Jul 27, 2026
@evanpelle
evanpelle merged commit 4e6e8d2 into main Jul 27, 2026
12 of 14 checks passed
@evanpelle
evanpelle deleted the feat/custom-tribe-names-ingame branch July 27, 2026 02:25
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Jul 27, 2026
evanpelle added a commit that referenced this pull request Aug 3, 2026
…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>
evanpelle added a commit that referenced this pull request Aug 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant