From 33cd62b2940c90d59e3c2e9965458dad0d753de7 Mon Sep 17 00:00:00 2001 From: Foulk plb | Morpho <71005796+Foulks-Plb@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:32:06 +0200 Subject: [PATCH] fix(core): reject spawn intents after the spawn phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A modified client could relay a `{ type: "spawn", tile }` intent after the spawn phase ended and materialize a base on the running map (or teleport an existing one by relinquishing and re-conquering elsewhere). The client-side guard in ClientGameRunner is UX only — a modified client skips it — so the simulation accepted the intent on every client. Gate the spawn intent where it becomes an execution: Executor.createExec now drops a spawn intent to a NoOpExecution once inSpawnPhase() is false. This is the single authoritative choke point for untrusted client input and runs identically on every client, so the drop is deterministic. Internal spawns (nations, bots, random spawn) construct SpawnExecution directly and are unaffected, as is a human's last-tick click, whose intent is still created while the phase is active. Adds tests/SpawnAfterPhase.test.ts asserting a post-phase spawn intent is dropped instead of placing territory. Co-Authored-By: Claude Fable 5 --- src/core/execution/ExecutionManager.ts | 3 ++ tests/SpawnAfterPhase.test.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/SpawnAfterPhase.test.ts diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 6ecc0498c4..8acce582fe 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -72,6 +72,9 @@ export class Executor { case "move_warship": return new MoveWarshipExecution(player, intent.unitIds, intent.tile); case "spawn": + if (!this.mg.inSpawnPhase()) { + return new NoOpExecution(); + } return new SpawnExecution(this.gameID, player.info(), intent.tile); case "boat": return new TransportShipExecution(player, intent.dst, intent.troops); diff --git a/tests/SpawnAfterPhase.test.ts b/tests/SpawnAfterPhase.test.ts new file mode 100644 index 0000000000..c997f7e879 --- /dev/null +++ b/tests/SpawnAfterPhase.test.ts @@ -0,0 +1,43 @@ +import { Executor } from "../src/core/execution/ExecutionManager"; +import { NoOpExecution } from "../src/core/execution/NoOpExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { ClientID, GameID, StampedIntent } from "../src/core/Schemas"; +import { setup } from "./util/Setup"; + +const gameID: GameID = "game_id"; + +describe("Spawn intent after the spawn phase", () => { + it("is dropped instead of placing territory", async () => { + const game = await setup("plains", { infiniteGold: true }); + const info = new PlayerInfo( + "latecomer", + PlayerType.Human, + "late_client", + "late_id", + ); + game.addPlayer(info); + const latecomer = game.player(info.id); + expect(game.inSpawnPhase()).toBe(false); + + const intent = { + type: "spawn", + tile: game.ref(40, 40), + clientID: "late_client", + } as StampedIntent; + const exec = new Executor( + game, + gameID, + "late_client" as ClientID, + ).createExec(intent); + + expect(exec).toBeInstanceOf(NoOpExecution); + expect(exec).not.toBeInstanceOf(SpawnExecution); + + game.addExecution(exec); + game.executeNextTick(); + game.executeNextTick(); + expect(latecomer.hasSpawned()).toBe(false); + expect(latecomer.numTilesOwned()).toBe(0); + }); +});