fix(core): gate spawn intents on when they were issued, not when they land - #4867
Conversation
… land #4657 rejected a spawn intent when the game was past the spawn phase *and* the player had already spawned. A player who never picked a spawn fails the second condition, so the guard never fired for them: they could sit out the spawn phase and later drop onto any unowned tile they liked, with the whole map visible. Nothing auto-spawns them, because spawnPlayers() only runs under isRandomSpawn(), and every lobby human is in game state from tick 0, so the intent reaches a live SpawnExecution. Checking the phase at tick time instead would be wrong in the other direction. New executions are initialised at the end of the tick they are queued in and first tick on the next one, so an intent sent on the final spawn-phase tick runs once the phase has already ended — a player who picked just in time would silently never spawn. Capture the phase in init() instead, which still sees it for a last-tick pick but not for anything sent afterwards, and apply the gate only to executions built from a client intent. Internal callers (PlayerSpawner, NationExecution) place players deliberately and legitimately land a queued spawn just past the phase boundary; a client may not. init() runs identically in every client's simulation, so a rejected intent stays a deterministic no-op rather than a desync. This subsumes the hasSpawned() check from #4657, which is now removed: an already-spawned player's mid-game intent is rejected because it was issued out of phase, not because of what they own. The random-spawn re-roll guard is unchanged. The anti-teleport test now goes through the intent path, which is what it was always meant to model. Co-Authored-By: Claude Opus 5 (1M context) <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 Plus Run ID: 📒 Files selected for processing (3)
WalkthroughClient-originated spawn intents now carry provenance into ChangesSpawn phase gating
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
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 |
Follow-up to #4657, which a player reported was still exploitable. They were right about the hole, though not about the impact.
The hole
A player who never spawned fails the second condition, so the guard never fires for them. They can sit out the spawn phase, then send a spawn intent at any later point and land on any unowned tile, with the whole map visible.
I confirmed the chain rather than just the execution:
GameImpl's constructor callsaddPlayers(), so every lobby human is in game state from tick 0 —playerByClientIDresolves and a realSpawnExecutionis built.ExecutionManager.createExecgates only on the player existing.spawnPlayers()runs only underisRandomSpawn(), and atinit— so in a normal game nothing ever auto-spawns someone who didn't pick. They stay unspawned indefinitely.Random-spawn games were unaffected: everyone spawns at init, so
hasSpawned()is true and the old guard held.The "multiple times" part of the report does not reproduce. Firing four intents in a single tick lands only the first —
setSpawnTile()runs at the end oftick(), so the rest hit the guard. It's one late drop, not repeated teleporting. Severity is lower than reported, but the hole is real. Also worth notinggetSpawnTilesfilters out owned tiles, so a late spawner claims only unowned land — no territory theft.Why not just gate on the phase
Checking
inSpawnPhase()at tick time breaks legitimate spawns. New executions are initialised at the end of the tick they're queued in and first tick on the next one. I probed the boundary:So an intent sent on the final spawn-phase tick executes once the phase has ended. A blanket gate would silently drop it and the player would never spawn — which is exactly the state that enables this exploit. It would also break
NationExecution, which queues in-phase and explicitly waits for a spawn that lands after the boundary (NationExecution.ts:165).The fix
Capture the phase in
init(), which still sees it for a last-tick pick but not for anything sent afterwards, and apply the gate only to executions built from a client intent:Internal callers (
PlayerSpawner,NationExecution) place players deliberately and legitimately land a queued spawn just past the boundary; a client may not.init()runs identically in every client's simulation, so a rejected intent stays a deterministic no-op rather than a desync — the same property #4657 relied on.This subsumes the
hasSpawned()check, which is removed: an already-spawned player's mid-game intent is now rejected because it was issued out of phase, not because of what they own. The random-spawn re-roll guard is unchanged.Why intent-scoped rather than all human spawns
Gating every human
SpawnExecutionon init-time phase also works, but breaks 11 test files / 35 tests that useSpawnExecutionsimply to place a player aftersetup()has ended the phase. The vulnerability is specifically untrusted client input, andcreateExecis the only place an intent becomes aSpawnExecution, so scoping the gate to that trust boundary is both tighter and non-destructive. The trade-off is that a future call site forwarding an intent must set the flag — there's a test locking the current wiring.Tests
Four added:
Executormarks spawn intents as client-sourced (locks the wiring)The existing anti-teleport test now goes through the intent path, which is what it always meant to model.
I verified the tests aren't vacuous by stashing the source fix: three of the four fail against unfixed
main. The last-tick test passes either way by design — it's a regression guard for the new gate, not a bug-catcher.Full suite: 225 files, 2598 tests, all passing.
tsc --noEmit, eslint and prettier clean.To be cherry-picked into
v33.🤖 Generated with Claude Code