Files
OpenFrontIO/tests/PlayerImpl.test.ts
T
bf70ef6f45 Return the live TileSet from Player.tiles() instead of cloning (#4600)
Fixes #4598

## Problem

`PlayerImpl.tiles()` copied the entire owned-tile set into a fresh
native `Set` on every call — an O(owned tiles) allocation that grows
with player success (100k+ entries for a large late-game player). The
issue reporter measured 71.8% of sim CPU inside this clone in a bot
harness that samples tiles on large players.

## Why the copy existed, and why it's now safe to drop

The snapshot dates to ed4201ab8 (Aug 2024), which added a dead-defender
loop that conquers tiles out of the set while iterating it — the copy
guarded against mutation during iteration, back when `_tiles` was a
native collection.

`TileSet` has since made that guard obsolete: it guarantees
Set-identical iteration semantics under mutation (entries deleted during
iteration are skipped, compaction is deferred while any iterator is
live, so positions never shift under an iterator). All three call sites
that mutate while iterating (`AttackExecution.handleDeadDefender`,
`SpawnExecution`'s relinquish loop, the existing `PlayerImpl.test.ts`
conquer loop) only ever delete the entry currently being visited, which
is safe under Set semantics — so the live set is behavior-identical to
the snapshot.

## Change

- `Player.tiles()` now returns `ReadonlyTileSet` (matching
`borderTiles()`) and `PlayerImpl` returns `this._tiles` directly —
zero-copy, mirroring the approach in #4230.
- `ReadonlyTileSet` mirrors the `ReadonlySet` read surface (`size`,
`has`, `forEach`, `values`, iteration); every existing caller typechecks
unchanged.
- Added regression tests pinning the new live-view semantics and the
relinquish-during-iteration pattern `SpawnExecution` relies on.

## Verification

- `npx tsc --noEmit` clean
- Full suite: 21 files, 166 tests pass
- `npm run lint` + prettier clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:27:49 -07:00

173 lines
5.4 KiB
TypeScript

import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { setup } from "./util/Setup";
let game: Game;
let player: Player;
let other: Player;
describe("PlayerImpl", () => {
beforeEach(async () => {
game = await setup("plains", { instantBuild: true }, [
new PlayerInfo("player", PlayerType.Human, null, "player_id"),
new PlayerInfo("other", PlayerType.Human, null, "other_id"),
]);
player = game.player("player_id");
other = game.player("other_id");
player.conquer(game.ref(0, 0));
other.conquer(game.ref(50, 50));
player.addGold(BigInt(1000000));
game.config().structureMinDist = () => 10;
});
test("City can be upgraded", () => {
const city = player.buildUnit(UnitType.City, game.ref(0, 0), {});
const buCity = player
.buildableUnits(game.ref(0, 0))
.find((bu) => bu.type === UnitType.City);
expect(buCity).toBeDefined();
expect(buCity!.canUpgrade).toBe(city.id());
});
test("DefensePost cannot be upgraded", () => {
player.buildUnit(UnitType.DefensePost, game.ref(0, 0), {});
const buDefensePost = player
.buildableUnits(game.ref(0, 0))
.find((bu) => bu.type === UnitType.DefensePost);
expect(buDefensePost).toBeDefined();
expect(buDefensePost!.canUpgrade).toBeFalsy();
});
test("City can be upgraded from another city", () => {
const city = player.buildUnit(UnitType.City, game.ref(0, 0), {});
const cityToUpgrade = player.findUnitToUpgrade(
UnitType.City,
game.ref(0, 1),
);
expect(cityToUpgrade).toBeTruthy();
if (cityToUpgrade === false) {
return;
}
expect(cityToUpgrade.id()).toBe(city.id());
});
test("City cannot be upgraded when too far away", () => {
player.buildUnit(UnitType.City, game.ref(0, 0), {});
const cityToUpgrade = player.findUnitToUpgrade(
UnitType.City,
game.ref(50, 50),
);
expect(cityToUpgrade).toBe(false);
});
test("Unit cannot be upgraded when not enough gold", () => {
player.buildUnit(UnitType.City, game.ref(0, 0), {});
player.removeGold(BigInt(1000000));
const cityToUpgrade = player.findUnitToUpgrade(
UnitType.City,
game.ref(0, 1),
);
expect(cityToUpgrade).toBe(false);
});
describe("units() type filtering", () => {
beforeEach(() => {
player.buildUnit(UnitType.City, game.ref(0, 0), {});
player.buildUnit(UnitType.DefensePost, game.ref(11, 0), {});
player.buildUnit(UnitType.City, game.ref(0, 11), {});
player.buildUnit(UnitType.MissileSilo, game.ref(11, 11), {});
});
// Reference implementation: filter _units preserving insertion order.
function expected(...types: UnitType[]) {
const ts = new Set(types);
return player.units().filter((u) => ts.has(u.type()));
}
test("single type returns matching units in insertion order", () => {
expect(player.units(UnitType.City)).toEqual(expected(UnitType.City));
expect(player.units(UnitType.City)).toHaveLength(2);
});
test("returns a fresh array, not the internal or shared buffer", () => {
const a = player.units(UnitType.City);
const b = player.units(UnitType.City);
expect(a).not.toBe(b);
expect(a).not.toBe(player.units());
// Mutating one result must not affect a later query.
a.length = 0;
expect(player.units(UnitType.City)).toHaveLength(2);
});
test("two and three types return the union in insertion order", () => {
expect(player.units(UnitType.City, UnitType.MissileSilo)).toEqual(
expected(UnitType.City, UnitType.MissileSilo),
);
expect(
player.units(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
).toEqual(
expected(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
);
// Duplicate types don't duplicate results.
expect(player.units(UnitType.City, UnitType.City)).toEqual(
expected(UnitType.City),
);
});
test("array of types (Set path) and no match", () => {
expect(
player.units([
UnitType.City,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.Port,
]),
).toEqual(
expected(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
);
expect(player.units(UnitType.Port)).toEqual([]);
});
});
test("Can't send alliance requests when dead", () => {
// conquer other
const otherTiles = other.tiles();
for (const tile of otherTiles) {
player.conquer(tile);
}
expect(other.canSendAllianceRequest(player)).toBe(false);
});
describe("tiles()", () => {
test("returns a live view that reflects later ownership changes", () => {
const tiles = player.tiles();
const sizeBefore = tiles.size;
const tile = game.ref(5, 5);
player.conquer(tile);
expect(tiles.has(tile)).toBe(true);
expect(tiles.size).toBe(sizeBefore + 1);
});
test("every tile is visited when relinquishing during iteration", () => {
player.conquer(game.ref(1, 0));
player.conquer(game.ref(2, 0));
const owned = player.numTilesOwned();
expect(owned).toBeGreaterThan(1);
// SpawnExecution relinquishes all tiles while iterating tiles().
let visited = 0;
player.tiles().forEach((t) => {
visited++;
player.relinquish(t);
});
expect(visited).toBe(owned);
expect(player.numTilesOwned()).toBe(0);
});
});
});