Fix anonymize-names desync: seed cluster-recalc offset from id() not name()#4426
Conversation
…name() PlayerExecution.init() seeded the per-player phase offset that schedules removeClusters() from simpleHash(player.name()). removeClusters() both sets largestClusterBoundingBox (read by AI targeting) and removes disconnected/ surrounded territory, mutating tile ownership. The anonymize-names option (#4318) sends each client a different username for the same player, so simpleHash(name()) % 20 differed per client -> clusters were removed on different ticks -> numTilesOwned() diverged -> the every-10-tick state hash diverged -> clients desynced. Revealed viewers (host/admins/casters) saw real names and stayed in sync, which is why only the anonymized half of an FFA lobby desynced. name() was the only per-client-varying value reaching the deterministic core, and this was its only state-affecting read. id() is assigned from a gameID-seeded PRNG by spawn order and is identical on every client, so it preserves the load-spreading stagger while being deterministic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
💤 Files with no reviewable changes (1)
WalkthroughIn ChangesCluster-recalc determinism fix
Estimated code review effort🎯 1 (Trivial) | ⏱️ ~2 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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 |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…name() (#4426) ## Description Fixes a hard desync that hit anonymous-names lobbies (e.g. the OFM tournament): in an anonymized FFA, roughly half the clients desynced. ### Root cause `PlayerExecution.init()` seeded the per-player phase offset that schedules `removeClusters()` from the player's **username**: ```ts this.lastCalc = ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc); ``` `removeClusters()` is not display-only — it both sets `largestClusterBoundingBox` (read by `AiAttackBehavior` for targeting) **and removes disconnected/surrounded territory, mutating tile ownership**. The anonymize-names option (#4318) sends each client a *different* username for the same player (`anonName(target + viewer)`). So `simpleHash(name()) % 20` differs per client → `removeClusters()` runs on different ticks per client → `numTilesOwned()` diverges → the every-10-tick state hash (`simpleHash(id) * (troops + numTilesOwned) + units`) diverges → **desync**. **Why only half the lobby:** clients granted real-name reveal (host / admins / casters via `nameReveals` / `nameRevealPublicIds`) all see real names, compute identical offsets, and stay in sync with each other and the server record. Every non-granted (anonymized) client sees a unique random name per player and diverges. Hence the clean split. This line has been latent and harmless since 2024 (`f01949f0`) because `name()` used to be identical on every client; #4318 was the first feature to feed a per-client value into the core. The PR comment in `GameServer.ts` even states the assumption — *"username … neither of which the simulation reads"* — which turned out to be false. ### Fix Seed the offset from `id()` instead of `name()`. Player `id()` is assigned from a `gameID`-seeded PRNG by spawn order, so it is identical on every client while still spreading cluster recalculation across players (the line's original load-balancing intent). One-line sim change; no schema/wire change. ### Verification / scope - Audited every `name()`/`username` read in `src/core/`: this was the **only** state-affecting one (all others are `console.log` / error strings / display-only update fields). So this closes the whole desync class. - Confirmed player order, ids, config, `clanTag`/`friends` and cosmetics are all client-identical under anonymize-names — `username` was the sole per-client field reaching the sim. - Swept the other recent core commits (impassable terrain, rail network, nations AI, inline sfc32 PRNG, troop/economy changes) for independent determinism regressions — none found. ### Follow-up `name()` should be removed from the core `Player` surface entirely so a per-client username can never re-enter the sim again (the remaining reads are logging + the display payload the renderer needs). Tracked separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Description
Fixes a hard desync that hit anonymous-names lobbies (e.g. the OFM tournament): in an anonymized FFA, roughly half the clients desynced.
Root cause
PlayerExecution.init()seeded the per-player phase offset that schedulesremoveClusters()from the player's username:removeClusters()is not display-only — it both setslargestClusterBoundingBox(read byAiAttackBehaviorfor targeting) and removes disconnected/surrounded territory, mutating tile ownership.The anonymize-names option (#4318) sends each client a different username for the same player (
anonName(target + viewer)). SosimpleHash(name()) % 20differs per client →removeClusters()runs on different ticks per client →numTilesOwned()diverges → the every-10-tick state hash (simpleHash(id) * (troops + numTilesOwned) + units) diverges → desync.Why only half the lobby: clients granted real-name reveal (host / admins / casters via
nameReveals/nameRevealPublicIds) all see real names, compute identical offsets, and stay in sync with each other and the server record. Every non-granted (anonymized) client sees a unique random name per player and diverges. Hence the clean split.This line has been latent and harmless since 2024 (
f01949f0) becausename()used to be identical on every client; #4318 was the first feature to feed a per-client value into the core. The PR comment inGameServer.tseven states the assumption — "username … neither of which the simulation reads" — which turned out to be false.Fix
Seed the offset from
id()instead ofname(). Playerid()is assigned from agameID-seeded PRNG by spawn order, so it is identical on every client while still spreading cluster recalculation across players (the line's original load-balancing intent). One-line sim change; no schema/wire change.Verification / scope
name()/usernameread insrc/core/: this was the only state-affecting one (all others areconsole.log/ error strings / display-only update fields). So this closes the whole desync class.clanTag/friendsand cosmetics are all client-identical under anonymize-names —usernamewas the sole per-client field reaching the sim.Follow-up
name()should be removed from the corePlayersurface entirely so a per-client username can never re-enter the sim again (the remaining reads are logging + the display payload the renderer needs). Tracked separately.🤖 Generated with Claude Code