Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/core/execution/ExecutionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export class Executor {
case "move_warship":
return new MoveWarshipExecution(player, intent.unitIds, intent.tile);
case "spawn":
return new SpawnExecution(this.gameID, player.info(), intent.tile);
return new SpawnExecution(
this.gameID,
player.info(),
intent.tile,
true,
);
Comment on lines +73 to +78

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for any existing test exercising Executor.createExec with a "spawn" intent.
rg -nP -C3 '\bcreateExec(s)?\s*\(' --type=ts -g '*.test.ts'
rg -nP -C3 '"spawn"' --type=ts -g '*.test.ts'

Repository: openfrontio/OpenFrontIO

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Relevant files\n'
git ls-files 'src/core/execution/*' 'tests/core/execution/*' 'tests/core/*' | sed -n '1,200p'

printf '\n## Find Executor.createExec and spawn handling\n'
rg -n -C 3 'createExec|spawn|fromIntent' src/core/execution tests/core/execution tests/core --type ts

printf '\n## Show ExecutionManager.ts around spawn branch\n'
sed -n '1,140p' src/core/execution/ExecutionManager.ts

printf '\n## Show SpawnExecution tests\n'
sed -n '1,220p' tests/core/execution/SpawnExecution.test.ts

Repository: openfrontio/OpenFrontIO

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- file list ---'
git ls-files 'src/core/execution/*' 'tests/core/execution/*' 'tests/core/*' | sed -n '1,200p'

echo
echo '--- createExec / spawn / fromIntent references ---'
rg -n -C 3 'createExec|spawn|fromIntent' src/core/execution tests/core/execution tests/core --type ts || true

echo
echo '--- ExecutionManager.ts ---'
sed -n '1,160p' src/core/execution/ExecutionManager.ts

echo
echo '--- SpawnExecution.test.ts ---'
sed -n '1,240p' tests/core/execution/SpawnExecution.test.ts

Repository: openfrontio/OpenFrontIO

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- tests mentioning ExecutionManager / StampedIntent / createExec ---'
rg -n -C 2 'ExecutionManager|StampedIntent|createExec|createExecs|fromIntent' tests src/core --type ts | sed -n '1,220p'

echo
echo '--- ExecutionManager.ts spawn branch only ---'
sed -n '45,82p' src/core/execution/ExecutionManager.ts

echo
echo '--- SpawnExecution.test.ts spawn-intent tests only ---'
sed -n '120,250p' tests/core/execution/SpawnExecution.test.ts

Repository: openfrontio/OpenFrontIO

Length of output: 11590


Add a small test for the "spawn" intent path.

tests/core/execution/SpawnExecution.test.ts covers fromIntent = true by building SpawnExecution directly, but it does not exercise ExecutionManager.createExec for a stamped "spawn" intent. Add one test there so this wiring cannot regress.

🤖 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/core/execution/ExecutionManager.ts` around lines 73 - 78, Add a focused
test for the stamped "spawn" intent branch in ExecutionManager.createExec,
verifying it returns a SpawnExecution with fromIntent set to true and passes the
expected game, player, and tile data. Keep the existing direct SpawnExecution
tests unchanged.

Source: Coding guidelines

case "boat":
return new TransportShipExecution(player, intent.dst, intent.troops);
case "allianceRequest":
Expand Down
26 changes: 17 additions & 9 deletions src/core/execution/SpawnExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export class SpawnExecution implements Execution {
gameID: GameID,
private playerInfo: PlayerInfo,
public tile?: TileRef,
// True when this spawn came from a client "spawn" intent (a player choosing
// where to spawn). Internal spawns — nations, bots, random-spawn placement —
// leave this false and are never gated by the spawn phase.
private fromIntent: boolean = false,
) {
this.random = new PseudoRandom(
simpleHash(playerInfo.id) + simpleHash(gameID),
Expand All @@ -40,22 +44,26 @@ export class SpawnExecution implements Execution {
tick(ticks: number) {
this.active = false;

// Security: a client-requested spawn is only valid during the spawn phase.
// Once the phase has ended, ignore the intent so a player can neither spawn
// for the first time nor re-spawn mid-game. This closes the "teleport"
// exploit, where a crafted spawn intent relinquished a player's territory
// and re-conquered it elsewhere. Internal spawns (nations, bots, random
// spawn placement) are queued during the spawn phase by trusted code and
// may land a tick later — they set fromIntent=false and are not gated here.
// inSpawnPhase() is deterministic game state, so the intent is an identical
// no-op on every client.
if (this.fromIntent && !this.mg.inSpawnPhase()) {
return;
}

let player: Player | null = null;
if (this.mg.hasPlayer(this.playerInfo.id)) {
player = this.mg.player(this.playerInfo.id);
} else {
player = this.mg.addPlayer(this.playerInfo);
}

// Security: a spawn intent may only place or relocate a player's starting
// territory during the spawn phase. Once the game is underway, an
// already-spawned player who sends a spawn intent is attempting to
// teleport — relinquishing their territory and re-conquering it elsewhere.
// Ignore it so the intent is a deterministic no-op on every client.
if (!this.mg.inSpawnPhase() && player.hasSpawned()) {
return;
}

// Security: If random spawn is enabled, prevent players from re-rolling their spawn location
if (this.mg.config().isRandomSpawn() && player.hasSpawned()) {
return;
Expand Down
37 changes: 37 additions & 0 deletions tests/core/execution/ExecutionManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Executor } from "../../../src/core/execution/ExecutionManager";
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
import { PlayerInfo, PlayerType } from "../../../src/core/game/Game";
import { StampedIntent } from "../../../src/core/Schemas";
import { setup } from "../../util/Setup";

describe("Executor.createExec", () => {
test("marks a client spawn intent with fromIntent so it is gated by the spawn phase", async () => {
const playerInfo = new PlayerInfo(
"player",
PlayerType.Human,
"client_id",
"player_id",
);
// setup() ends the spawn phase by default, so the game is already underway.
const game = await setup("half_land_half_ocean", {}, [playerInfo]);

const executor = new Executor(game, "game_id", "client_id");
const exec = executor.createExec({
type: "spawn",
tile: 20,
clientID: "client_id",
} as StampedIntent);

expect(exec).toBeInstanceOf(SpawnExecution);
expect((exec as SpawnExecution).tile).toBe(20);

// Because it originated from a client intent (fromIntent = true), executing
// it after the spawn phase must be a no-op — the player never spawns.
game.addExecution(exec);
game.executeNextTick();
game.executeNextTick();
expect(game.playerByClientID("client_id")?.hasSpawned() ?? false).toBe(
false,
);
Comment on lines +33 to +35

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not mask a missing player in this assertion.

?? false makes the test pass when playerByClientID("client_id") returns undefined, so it may not verify the intended player at all. Assert that the player exists, then check hasSpawned().

Suggested fix
-    expect(game.playerByClientID("client_id")?.hasSpawned() ?? false).toBe(
-      false,
-    );
+    const player = game.playerByClientID("client_id");
+    expect(player).toBeDefined();
+    expect(player!.hasSpawned()).toBe(false);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(game.playerByClientID("client_id")?.hasSpawned() ?? false).toBe(
false,
);
const player = game.playerByClientID("client_id");
expect(player).toBeDefined();
expect(player!.hasSpawned()).toBe(false);
🤖 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/core/execution/ExecutionManager.test.ts` around lines 33 - 35, Update
the assertion for playerByClientID("client_id") so it first verifies that the
returned player exists, then separately asserts that the existing player's
hasSpawned() value is false. Remove the nullish fallback to ensure a missing
player causes the test to fail.

});
});
104 changes: 95 additions & 9 deletions tests/core/execution/SpawnExecution.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import path from "path";
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
import { PlayerInfo, PlayerType } from "../../../src/core/game/Game";
import { GameConfig } from "../../../src/core/Schemas";
import { setup } from "../../util/Setup";
import { TestConfig } from "../../util/TestConfig";

// tests/util, so Setup resolves its map paths relative to it (../testdata/maps).
const UTIL_DIR = path.join(__dirname, "..", "..", "util");

// Keep the game in the spawn phase (setup() ends it by default), for asserting
// that a client-requested spawn is accepted while the phase is open.
function setupSpawnPhase(
mapName: string,
gameConfig: Partial<GameConfig>,
players: PlayerInfo[],
) {
return setup(mapName, gameConfig, players, UTIL_DIR, TestConfig, false);
}

describe("Spawn execution", () => {
// Manually calculated based on number of tiles in manifest of each map
Expand Down Expand Up @@ -94,16 +110,39 @@ describe("Spawn execution", () => {

const game = await setup("half_land_half_ocean", {}, [playerInfo]);

game.addExecution(new SpawnExecution("game_id", playerInfo, 10));
game.addExecution(new SpawnExecution("game_id", playerInfo, 20));
game.executeNextTick();
game.executeNextTick();

expect(game.playerByClientID("client_id")?.spawnTile()).toBe(20);
// Previous territory from first spawn should be relinquished
expect(game.owner(10).isPlayer()).toBe(false);
});

test("Client spawn intent is accepted during the spawn phase", async () => {
const playerInfo = new PlayerInfo(
`player`,
PlayerType.Human,
`client_id`,
`player_id`,
);

const game = await setupSpawnPhase("half_land_half_ocean", {}, [
playerInfo,
]);

// fromIntent = true simulates a client "spawn" intent.
game.addExecution(new SpawnExecution("game_id", playerInfo, 20, true));
game.executeNextTick();
game.executeNextTick();

const player = game.playerByClientID("client_id")!;
expect(player.spawnTile()).toBe(20);
expect(player.numTilesOwned()).toBeGreaterThan(0);
});

test("Spawn intent after the spawn phase cannot relocate territory (anti-teleport)", async () => {
test("Client spawn intent after the spawn phase is ignored (anti-teleport)", async () => {
const playerInfo = new PlayerInfo(
`player`,
PlayerType.Human,
Expand All @@ -114,7 +153,7 @@ describe("Spawn execution", () => {
// setup() ends the spawn phase by default, so the game is already underway.
const game = await setup("half_land_half_ocean", {}, [playerInfo]);

// Establish the player's territory with a legitimate first spawn.
// Establish the player's territory (an internal/trusted spawn).
game.addExecution(new SpawnExecution("game_id", playerInfo, 20));
game.executeNextTick();
game.executeNextTick();
Expand All @@ -124,17 +163,60 @@ describe("Spawn execution", () => {
const tilesBefore = player.numTilesOwned();
expect(tilesBefore).toBeGreaterThan(0);

// Malicious "teleport": a spawn intent to a new tile after the game has
// started must be a deterministic no-op — the player keeps their original
// Malicious "teleport": a client spawn intent to a new tile after the phase
// ended must be a deterministic no-op — the player keeps their original
// spawn location and territory rather than relinquishing and re-conquering.
game.addExecution(new SpawnExecution("game_id", playerInfo, 10));
game.addExecution(new SpawnExecution("game_id", playerInfo, 10, true));
game.executeNextTick();
game.executeNextTick();

expect(player.spawnTile()).toBe(20);
expect(player.numTilesOwned()).toBe(tilesBefore);
});

test("Client spawn intent after the spawn phase cannot spawn a never-spawned player", async () => {
const playerInfo = new PlayerInfo(
`player`,
PlayerType.Human,
`client_id`,
`player_id`,
);

const game = await setup("half_land_half_ocean", {}, [playerInfo]);

// A client "spawn" intent once the phase has ended is ignored entirely.
game.addExecution(new SpawnExecution("game_id", playerInfo, 20, true));
game.executeNextTick();
game.executeNextTick();

const player = game.playerByClientID("client_id");
expect(player?.hasSpawned() ?? false).toBe(false);
expect(game.owner(20).isPlayer()).toBe(false);
});

test("Internal spawns are not gated by the spawn phase", async () => {
// Nations, bots and random-spawn placement queue a SpawnExecution during
// the spawn phase, but it may land a tick after the phase ends (e.g. a
// singleplayer human's spawn ends the phase early). These trusted spawns
// (fromIntent = false) must still succeed.
const playerInfo = new PlayerInfo(
`player`,
PlayerType.Human,
`client_id`,
`player_id`,
);

const game = await setup("half_land_half_ocean", {}, [playerInfo]);

game.addExecution(new SpawnExecution("game_id", playerInfo, 20));
game.executeNextTick();
game.executeNextTick();

const player = game.playerByClientID("client_id")!;
expect(player.spawnTile()).toBe(20);
expect(player.numTilesOwned()).toBeGreaterThan(0);
});

test("Random spawn ignores client-specified tile", async () => {
const playerInfo = new PlayerInfo(
`player`,
Expand All @@ -143,13 +225,17 @@ describe("Spawn execution", () => {
`player_id`,
);

const game = await setup("half_land_half_ocean", { randomSpawn: true }, [
playerInfo,
]);
const game = await setupSpawnPhase(
"half_land_half_ocean",
{ randomSpawn: true },
[playerInfo],
);

// Simulate a malicious client sending a spawn intent with a specific tile
const maliciousTile = 10;
game.addExecution(new SpawnExecution("game_id", playerInfo, maliciousTile));
game.addExecution(
new SpawnExecution("game_id", playerInfo, maliciousTile, true),
);
game.executeNextTick();
game.executeNextTick();

Expand Down
Loading