From 918ba257674a1ac81bcc257fc0c00836a937a0f8 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Mon, 23 Feb 2026 22:35:55 +0100 Subject: [PATCH 1/2] feat: optimize AttackExecution methods and add performance benchmarks --- src/core/execution/AttackExecution.ts | 210 +++-- .../execution/AttackExecution.perf.test.ts | 738 ++++++++++++++++++ 2 files changed, 838 insertions(+), 110 deletions(-) create mode 100644 tests/core/execution/AttackExecution.perf.test.ts diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 8aab6018f..97e7fcafd 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -7,7 +7,6 @@ import { Player, PlayerID, PlayerType, - TerrainType, TerraNullius, UnitType, } from "../game/Game"; @@ -178,38 +177,28 @@ export class AttackExecution implements Execution { } private initializeConquestFromLandingTile(tile: TileRef) { - if (this.attack === null) { - throw new Error("Attack not initialized"); - } + const attack = this.attack!; this.toConquer.clear(); - this.attack.clearBorder(); - - // Add the source tile itself to be conquered first - this.toConquer.enqueue(tile, 0); // High priority for the landing tile - this.attack.addBorderTile(tile); - - // Then add its neighbors that are owned by the target - this.addNeighbors(tile); + attack.clearBorder(); + this.toConquer.enqueue(tile, 0); + attack.addBorderTile(tile); + this.addNeighbors(tile, this._owner.smallID(), this.target.smallID()); } private refreshToConquer() { - if (this.attack === null) { - throw new Error("Attack not initialized"); - } - + const attack = this.attack!; + const ownerSmallID = this._owner.smallID(); + const targetSmallID = this.target.smallID(); this.toConquer.clear(); - this.attack.clearBorder(); + attack.clearBorder(); for (const tile of this._owner.borderTiles()) { - this.addNeighbors(tile); + this.addNeighbors(tile, ownerSmallID, targetSmallID); } } private retreat(malusPercent = 0) { - if (this.attack === null) { - throw new Error("Attack not initialized"); - } - - const deaths = this.attack.troops() * (malusPercent / 100); + const attack = this.attack!; + const deaths = attack.troops() * (malusPercent / 100); if (deaths) { this.mg.displayMessage( `Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`, @@ -217,27 +206,23 @@ export class AttackExecution implements Execution { this._owner.id(), ); } - const survivors = this.attack.troops() - deaths; + const survivors = attack.troops() - deaths; this._owner.addTroops(survivors); - this.attack.delete(); + attack.delete(); this.active = false; - // Not all retreats are canceled attacks - if (this.attack.retreated()) { - // Record stats + if (attack.retreated()) { this.mg.stats().attackCancel(this._owner, this.target, survivors); } } tick(ticks: number) { - if (this.attack === null) { - throw new Error("Attack not initialized"); - } - let troopCount = this.attack.troops(); // cache troop count - const targetIsPlayer = this.target.isPlayer(); // cache target type - const targetPlayer = targetIsPlayer ? (this.target as Player) : null; // cache target player + const attack = this.attack!; + let troopCount = attack.troops(); + const targetIsPlayer = this.target.isPlayer(); + const targetPlayer = targetIsPlayer ? (this.target as Player) : null; - if (this.attack.retreated()) { + if (attack.retreated()) { if (targetIsPlayer) { this.retreat(malusForRetreat); } else { @@ -247,11 +232,11 @@ export class AttackExecution implements Execution { return; } - if (this.attack.retreating()) { + if (attack.retreating()) { return; } - if (!this.attack.isActive()) { + if (!attack.isActive()) { this.active = false; return; } @@ -263,8 +248,6 @@ export class AttackExecution implements Execution { this.breakAlliance = false; this._owner.breakAlliance(alliance); } - // Consolidated: retreats on alliance/peace are now handled centrally via - // PlayerImpl.setNeutralWith, which orders retreats on hostile actions. // Calculate tiles to process this.tilesToProcessAccumulator += this.mg @@ -273,26 +256,39 @@ export class AttackExecution implements Execution { troopCount, this._owner, this.target, - this.attack.borderSize() + this.random.nextInt(0, 5), + attack.borderSize() + this.random.nextInt(0, 5), ); let numTilesPerTick = Math.floor(this.tilesToProcessAccumulator + 1e-9); this.tilesToProcessAccumulator -= numTilesPerTick; + // ── Hoist per-tick invariants out of the tile loop ──────────────────── const ownerSmallID = this._owner.smallID(); const targetSmallID = this.target.smallID(); + const mg = this.mg; + const config = mg.config(); + + // Hospital multiplier is constant within a tick (unit counts don't change) + const hospitalExp = this._owner.effectiveUnits(UnitType.Hospital); + const attackerMultiplier = 0.6 + 0.4 * Math.pow(0.75, hospitalExp); + const defenderMultiplier = targetPlayer + ? 0.6 + 0.4 * Math.pow(0.75, hospitalExp) + : 1; + + const isDeepStrike = this.isDeepStrike; + const sourceTile = this.sourceTile; - this.mg.beginBorderBatch(); + mg.beginBorderBatch(); try { while (numTilesPerTick > 0) { if (troopCount < 1) { - this.attack.delete(); + attack.delete(); this.active = false; return; } if (this.toConquer.size() === 0) { - if (!this.isDeepStrike) { + if (!isDeepStrike) { this.refreshToConquer(); } this.retreat(); @@ -300,47 +296,41 @@ export class AttackExecution implements Execution { } const [tileToConquer] = this.toConquer.dequeue(); - this.attack.removeBorderTile(tileToConquer); + attack.removeBorderTile(tileToConquer); + // Border check via forEachNeighbor (zero-alloc callback) let onBorder = false; - if (this.isDeepStrike && tileToConquer === this.sourceTile) { - onBorder = true; // The landing tile is always considered "on border" for a deep strike + if (isDeepStrike && tileToConquer === sourceTile) { + onBorder = true; } else { - for (const n of this.mg.neighbors(tileToConquer)) { - if (this.mg.ownerID(n) === ownerSmallID) { + mg.forEachNeighbor(tileToConquer, (n: TileRef) => { + if (!onBorder && mg.ownerID(n) === ownerSmallID) { onBorder = true; - break; } - } + }); } - if (this.mg.ownerID(tileToConquer) !== targetSmallID || !onBorder) { + if (mg.ownerID(tileToConquer) !== targetSmallID || !onBorder) { continue; } - this.addNeighbors(tileToConquer); + + this.addNeighbors(tileToConquer, ownerSmallID, targetSmallID); + const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = - this.mg - .config() - .attackLogic( - this.mg, - troopCount, - this._owner, - this.target, - tileToConquer, - ); + config.attackLogic( + mg, + troopCount, + this._owner, + this.target, + tileToConquer, + ); numTilesPerTick -= tilesPerTickUsed; troopCount -= attackerTroopLoss; - this.attack.setTroops(troopCount); + attack.setTroops(troopCount); if (targetPlayer) { targetPlayer.removeTroops(defenderTroopLoss); } - const attackerMultiplier = - 0.6 + - 0.4 * Math.pow(0.75, this._owner.effectiveUnits(UnitType.Hospital)); - const defenderMultiplier = targetPlayer - ? 0.6 + - 0.4 * Math.pow(0.75, this._owner.effectiveUnits(UnitType.Hospital)) - : 1; + // Hospital returns using hoisted multipliers const attackerReturns = attackerTroopLoss * (1 - attackerMultiplier); const defenderReturns = defenderTroopLoss * (1 - defenderMultiplier); @@ -348,11 +338,15 @@ export class AttackExecution implements Execution { if (targetPlayer) { targetPlayer.addHospitalReturns(defenderReturns); } - this.mg.conquer(this._owner, tileToConquer); - this.handleDeadDefender(); + mg.conquer(this._owner, tileToConquer); + + // Dead-defender check only for PvP (skipped for TN attacks) + if (targetIsPlayer) { + this.handleDeadDefender(); + } } } finally { - this.mg.endBorderBatch(); + mg.endBorderBatch(); } } @@ -365,52 +359,48 @@ export class AttackExecution implements Execution { } } - private addNeighbors(tile: TileRef) { - if (this.attack === null) { - throw new Error("Attack not initialized"); - } - - const tickNow = this.mg.ticks(); // cache tick - const targetSmallID = this.target.smallID(); - const ownerSmallID = this._owner.smallID(); - - for (const neighbor of this.mg.neighbors(tile)) { - if ( - this.mg.isWater(neighbor) || - this.mg.ownerID(neighbor) !== targetSmallID - ) { - continue; + private addNeighbors( + tile: TileRef, + ownerSmallID: number, + targetSmallID: number, + ): void { + const attack = this.attack!; + const mg = this.mg; + const tickNow = mg.ticks(); + const random = this.random; + const toConquer = this.toConquer; + + // forEachNeighbor uses direct callbacks — no Uint32Array subarray alloc + mg.forEachNeighbor(tile, (neighbor: TileRef) => { + if (mg.isWater(neighbor) || mg.ownerID(neighbor) !== targetSmallID) { + return; } - this.attack.addBorderTile(neighbor); + attack.addBorderTile(neighbor); + let numOwnedByMe = 0; - for (const n of this.mg.neighbors(neighbor)) { - if (this.mg.ownerID(n) === ownerSmallID) { + mg.forEachNeighbor(neighbor, (n: TileRef) => { + if (mg.ownerID(n) === ownerSmallID) { numOwnedByMe++; } - } - - let mag = 0; - switch (this.mg.terrainType(neighbor)) { - case TerrainType.Plains: - mag = 1; - break; - case TerrainType.Highland: - mag = 1.5; - break; - case TerrainType.Mountain: - mag = 2; - break; - case TerrainType.Barrier: - return; // Impassable - } + }); + + // magnitude() directly instead of terrainType() enum + switch + const magnitude = mg.magnitude(neighbor); + let mag: number; + if (magnitude >= 31) return; // Barrier — impassable + if (magnitude < 10) + mag = 1; // Plains + else if (magnitude < 20) + mag = 1.5; // Highland + else mag = 2; // Mountain const priority = - (this.random.nextInt(0, 7) + 10) * (1 - numOwnedByMe * 0.5 + mag / 2) + + (random.nextInt(0, 7) + 10) * (1 - numOwnedByMe * 0.5 + mag / 2) + tickNow - - (this.mg.hasRoadOnTile(neighbor) ? 10 : 0); // Lower priority is better, so subtract for roads + (mg.hasRoadOnTile(neighbor) ? 10 : 0); - this.toConquer.enqueue(neighbor, priority); - } + toConquer.enqueue(neighbor, priority); + }); } private handleDeadDefender() { diff --git a/tests/core/execution/AttackExecution.perf.test.ts b/tests/core/execution/AttackExecution.perf.test.ts new file mode 100644 index 000000000..1143362db --- /dev/null +++ b/tests/core/execution/AttackExecution.perf.test.ts @@ -0,0 +1,738 @@ +/** + * AttackExecution Performance Benchmark + * ====================================== + * Systematic benchmarks for AttackExecution covering the hot-path: + * init → tick loop (dequeue from heap, neighbor checks, conquer, border batch) + * + * Uses DefaultConfig's real attackLogic / attackTilesPerTick so the + * benchmark reflects production hot-paths (thousands of tiles per tick), + * not the simplified TestConfig stubs (1 tile/tick). + * + * Two measurement approaches: + * 1. **Full-attack**: launch an attack, measure every tick until it + * finishes. Reports per-tick stats over the attack's actual lifespan. + * 2. **Sustained-expansion**: measure ongoing TN expansion where the + * attack runs indefinitely. Reports steady-state tick cost. + * + * Scenarios: + * 1. Large TN expansion (sustained) — single player expanding on Eurasia + * 2a. PvP 500k troops — moderate attack on Australia (full lifecycle) + * 2b. PvP 5M deep push — heavy attack consuming all enemy tiles + * 2c. Parallel TN expansions — 3 concurrent TN attacks (multi-execution overhead) + * 3. Paired PvP — 2 independent A→B + C→D attacks simultaneously + * 4. Baseline: small map (isolated overhead) + * + * Territory setup uses deterministic BFS via game.conquer() — no + * attack-based growth — ensuring reliable, reproducible tile counts. + * + * The "world" map (2000×1000, 651k land tiles) produces realistic + * territory sizes and 1000+ tile borders. PvP tests use Australia + * (isolated landmass) for predictable territory partitioning. + * + * Compare CSV output across branches / PRs to detect regressions. + */ + +import fsSync from "fs"; +import path from "path"; +import { DefaultConfig } from "../../../src/core/configuration/DefaultConfig"; +import { AttackExecution } from "../../../src/core/execution/AttackExecution"; +import { SpawnExecution } from "../../../src/core/execution/SpawnExecution"; +import { + Difficulty, + Game, + GameMapType, + GameMode, + GameType, + Player, + PlayerInfo, + PlayerType, + Tick, +} from "../../../src/core/game/Game"; +import { createGame } from "../../../src/core/game/GameImpl"; +import { genTerrainFromBin } from "../../../src/core/game/TerrainMapLoader"; +import { UserSettings } from "../../../src/core/game/UserSettings"; +import { GameConfig, PeaceTimerDuration } from "../../../src/core/Schemas"; +import { TestServerConfig } from "../../util/TestServerConfig"; + +// ── Perf config (production attack math, test-safe elsewhere) ──────────── + +class PerfTestConfig extends DefaultConfig { + disableNavMesh(): boolean { + return true; + } + spawnImmunityDuration(): Tick { + return 0; + } +} + +// ── Map loader (mirrors tests/util/Setup.ts) ──────────────────────────── + +function prependDimensionHeader( + raw: Uint8Array, + w: number, + h: number, +): Uint8Array { + const hdr = new Uint8Array(4); + hdr[0] = w & 0xff; + hdr[1] = (w >> 8) & 0xff; + hdr[2] = h & 0xff; + hdr[3] = (h >> 8) & 0xff; + const out = new Uint8Array(4 + raw.length); + out.set(hdr); + out.set(raw, 4); + return out; +} + +function uint8ToBinStr(bytes: Uint8Array): string { + const CHUNK = 8192; + let s = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + s += String.fromCharCode( + ...bytes.subarray(i, Math.min(i + CHUNK, bytes.length)), + ); + } + return s; +} + +async function setupPerf( + mapName: string, + overrides: Partial = {}, +): Promise { + // Suppress noisy engine log output during test setup + console.debug = () => {}; + const origLog = console.log; + console.log = (...args: unknown[]) => { + if (typeof args[0] === "string" && args[0].startsWith("[tick")) return; + origLog(...args); + }; + + const dir = path.join(__dirname, "..", "..", "testdata", "maps", mapName); + const manifest = JSON.parse( + fsSync.readFileSync(path.join(dir, "manifest.json"), "utf-8"), + ); + + const mapBin = prependDimensionHeader( + new Uint8Array(fsSync.readFileSync(path.join(dir, "map.bin"))), + manifest.map.width, + manifest.map.height, + ); + const miniBin = prependDimensionHeader( + new Uint8Array(fsSync.readFileSync(path.join(dir, "map4x.bin"))), + manifest.map4x.width, + manifest.map4x.height, + ); + + const gameMap = await genTerrainFromBin(uint8ToBinStr(mapBin)); + const miniMap = await genTerrainFromBin(uint8ToBinStr(miniBin)); + + const cfg: GameConfig = { + gameMap: GameMapType.Asia, + gameMode: GameMode.FFA, + gameType: GameType.Singleplayer, + difficulty: Difficulty.Medium, + disableNPCs: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + peaceTimerDurationMinutes: PeaceTimerDuration.None, + startingGold: 0, + goldMultiplier: 1, + chatEnabled: false, + ...overrides, + }; + + return createGame( + [], + [], + gameMap, + miniMap, + new PerfTestConfig(new TestServerConfig(), cfg, new UserSettings(), false), + ); +} + +// ── Benchmark types and helpers ────────────────────────────────────────── + +interface BenchResult { + label: string; + /** ms for each tick measured */ + samples: number[]; + meanMs: number; + stddevMs: number; + medianMs: number; + p95Ms: number; + totalMs: number; + tilesConquered: number; + /** Border size of the primary attacker at the end */ + borderSize: number; + /** How many ticks it took (may differ from samples.length if warmup used) */ + ticksMeasured: number; +} + +function pct(sorted: number[], p: number): number { + return sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)]; +} + +function stats(samples: number[]) { + const sorted = [...samples].sort((a, b) => a - b); + const mean = samples.reduce((s, v) => s + v, 0) / samples.length; + const variance = + samples.reduce((s, v) => s + (v - mean) ** 2, 0) / samples.length; + return { + meanMs: mean, + stddevMs: Math.sqrt(variance), + medianMs: pct(sorted, 50), + p95Ms: pct(sorted, 95), + totalMs: samples.reduce((s, v) => s + v, 0), + }; +} + +/** + * Measure ticks of an ongoing attack until **all** of `attacker`'s + * outgoing attacks complete (or `maxTicks` reached). + * + * Great for PvP attacks that finish in a bounded number of ticks. + */ +function benchFullAttack( + label: string, + game: Game, + attacker: Player, + maxTicks = 200, +): BenchResult { + const tilesBefore = attacker.numTilesOwned(); + + // Run one init tick (the execution init runs on the first executeNextTick + // after addExecution; we don't count this toward samples because it also + // includes the init cost for other executions) + game.executeNextTick(); + + const samples: number[] = []; + for (let i = 0; i < maxTicks; i++) { + const t0 = performance.now(); + game.executeNextTick(); + samples.push(performance.now() - t0); + + if (attacker.outgoingAttacks().length === 0) break; + } + + const st = stats(samples); + return { + label, + samples, + ...st, + tilesConquered: attacker.numTilesOwned() - tilesBefore, + borderSize: attacker.borderTiles().size, + ticksMeasured: samples.length, + }; +} + +/** + * Measure `sampleTicks` ticks of an ongoing attack that is expected to + * persist across all measured ticks (e.g. TN expansion with huge troops). + * `warmupTicks` are executed first but not measured. + */ +function benchSustained( + label: string, + game: Game, + attacker: Player, + warmupTicks = 3, + sampleTicks = 30, +): BenchResult { + const tilesBefore = attacker.numTilesOwned(); + + for (let i = 0; i < warmupTicks; i++) game.executeNextTick(); + + const samples: number[] = []; + for (let i = 0; i < sampleTicks; i++) { + const t0 = performance.now(); + game.executeNextTick(); + samples.push(performance.now() - t0); + } + + const st = stats(samples); + return { + label, + samples, + ...st, + tilesConquered: attacker.numTilesOwned() - tilesBefore, + borderSize: attacker.borderTiles().size, + ticksMeasured: samples.length, + }; +} + +function fmt(r: BenchResult): string { + return [ + ` [${r.label}]`, + ` ticks: ${r.ticksMeasured}`, + ` mean: ${r.meanMs.toFixed(3)} ms/tick ± ${r.stddevMs.toFixed(3)}`, + ` median: ${r.medianMs.toFixed(3)} ms/tick`, + ` p95: ${r.p95Ms.toFixed(3)} ms/tick`, + ` total: ${r.totalMs.toFixed(1)} ms`, + ` tiles conquered: ${r.tilesConquered}`, + ` border size: ${r.borderSize}`, + ].join("\n"); +} + +// ── Territory growth ───────────────────────────────────────────────────── + +function growPlayer( + game: Game, + player: Player, + targetTiles: number, + maxTicks = 100_000, +): void { + let t = 0; + while (player.numTilesOwned() < targetTiles && t < maxTicks) { + player.setTroops(10_000_000); + if (player.outgoingAttacks().length === 0) { + game.addExecution( + new AttackExecution( + 5_000_000, + player, + game.terraNullius().id(), + null, + false, + ), + ); + } + game.executeNextTick(); + t++; + } +} + +/** + * Deterministic BFS territory builder. + * + * Does a round-robin BFS from each player's current border, assigning + * unowned land tiles directly via `game.conquer()`. This guarantees every + * player gets exactly `tilesEach` new tiles (or whatever the island can + * supply) and that adjacent players **share a border**. + * + * Much more reliable than attack-based growth for test setup because + * there's no race condition — no player can "run away" with all the land. + */ +function assignTerritory( + game: Game, + players: Player[], + tilesEach: number, +): void { + const map = game.map(); + + // BFS queues (array + head pointer to avoid shift() cost) + const queues: number[][] = players.map(() => []); + const heads: number[] = players.map(() => 0); + const visited = new Set(); + + // Seed queues from each player's existing border tiles' unowned neighbours + for (let i = 0; i < players.length; i++) { + for (const bt of players[i].borderTiles()) { + game.forEachNeighbor(bt, (n: number) => { + if (map.isLand(n) && !game.owner(n).isPlayer() && !visited.has(n)) { + visited.add(n); + queues[i].push(n); + } + }); + } + } + + game.beginBorderBatch(); + + const counts = players.map(() => 0); + + // Round-robin: give each player one tile per cycle + let progress = true; + while (progress) { + progress = false; + for (let i = 0; i < players.length; i++) { + if (counts[i] >= tilesEach) continue; + + while (heads[i] < queues[i].length) { + const tile = queues[i][heads[i]++]; + if (game.owner(tile).isPlayer()) continue; // already claimed + + game.conquer(players[i], tile); + counts[i]++; + progress = true; + + // enqueue unvisited land neighbours + game.forEachNeighbor(tile, (n: number) => { + if (map.isLand(n) && !visited.has(n)) { + visited.add(n); + queues[i].push(n); + } + }); + break; // one tile per player per cycle + } + } + } + + game.endBorderBatch(); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +jest.setTimeout(600_000); + +/** + * Helper: create a world game, spawn N players on Australia, and + * deterministically assign territory via BFS. + */ +async function createWorldGame( + spawns: [number, number][], + tilesEach: number, +): Promise<{ game: Game; players: Player[] }> { + const game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + + const codes = ["au", "nz", "pg", "fj"]; + const infos = spawns.map( + (_, i) => + new PlayerInfo(codes[i], `P${i}`, PlayerType.Human, null, `pid_${i}`), + ); + for (const info of infos) game.addPlayer(info); + + game.addExecution( + ...infos.map( + (info, i) => + new SpawnExecution(info, game.ref(spawns[i][0], spawns[i][1])), + ), + ); + while (game.inSpawnPhase()) game.executeNextTick(); + + const players = infos.map((info) => game.player(info.id)); + assignTerritory(game, players, tilesEach); + + return { game, players }; +} + +describe("AttackExecution Performance", () => { + const results: BenchResult[] = []; + + afterAll(() => { + console.log( + "\n╔══════════════════════════════════════════════════════════════╗", + ); + console.log( + "║ ATTACK EXECUTION PERFORMANCE SUMMARY ║", + ); + console.log( + "╚══════════════════════════════════════════════════════════════╝\n", + ); + for (const r of results) console.log(fmt(r) + "\n"); + console.log("--- CSV ---"); + console.log( + "label,ticks,mean_ms,stddev_ms,median_ms,p95_ms,total_ms,tiles_conquered,border_size", + ); + for (const r of results) { + console.log( + [ + r.label, + r.ticksMeasured, + r.meanMs.toFixed(3), + r.stddevMs.toFixed(3), + r.medianMs.toFixed(3), + r.p95Ms.toFixed(3), + r.totalMs.toFixed(1), + r.tilesConquered, + r.borderSize, + ].join(","), + ); + } + }); + + // ── Scenario 1: Sustained TN expansion ───────────────────────────────── + + describe("vs TerraNullius (sustained expansion)", () => { + let game: Game; + let attacker: Player; + + beforeAll(async () => { + game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + + const info = new PlayerInfo( + "us", + "Attacker", + PlayerType.Human, + null, + "attacker_id", + ); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(375, 272))); + while (game.inSpawnPhase()) game.executeNextTick(); + + attacker = game.player(info.id); + growPlayer(game, attacker, 10_000); + + console.log( + `[Setup] TN: ${attacker.numTilesOwned()} tiles, border: ${attacker.borderTiles().size}`, + ); + }); + + test("sustained TN expansion (10k+ tiles, real config)", () => { + attacker.setTroops(10_000_000); + game.addExecution( + new AttackExecution( + 100_000_000, + attacker, + game.terraNullius().id(), + null, + false, + ), + ); + + const r = benchSustained( + "Sustained TN expansion (10k+ tiles)", + game, + attacker, + 5, + 30, + ); + results.push(r); + console.log(fmt(r)); + expect(r.tilesConquered).toBeGreaterThan(0); + }); + }); + + // ── Scenario 2a: PvP attack — 500k troops ───────────────────────────── + + describe("PvP attack — 500k troops", () => { + let game: Game; + let playerA: Player; + let playerB: Player; + + beforeAll(async () => { + const ctx = await createWorldGame( + [ + [1620, 660], + [1780, 660], + ], + 15_000, + ); + game = ctx.game; + [playerA, playerB] = ctx.players; + + console.log( + `[Setup] PvP-500k: A=${playerA.numTilesOwned()} (border ${playerA.borderTiles().size}), ` + + `B=${playerB.numTilesOwned()} (border ${playerB.borderTiles().size}), ` + + `shared=${playerA.sharedBorderLength(playerB)}`, + ); + }); + + test("full PvP attack — A conquers B (500k troops)", () => { + playerA.setTroops(10_000_000); + playerB.setTroops(500_000); + + game.addExecution( + new AttackExecution(500_000, playerA, playerB.id(), null, false), + ); + + const r = benchFullAttack( + "PvP attack: 500k troops, 15k tiles each", + game, + playerA, + ); + results.push(r); + console.log(fmt(r)); + expect(r.tilesConquered).toBeGreaterThan(0); + expect(r.ticksMeasured).toBeGreaterThan(0); + }); + }); + + // ── Scenario 2b: PvP attack — 5M troops (deep push) ─────────────────── + + describe("PvP attack — 5M troops (deep push)", () => { + let game: Game; + let playerA: Player; + let playerB: Player; + + beforeAll(async () => { + const ctx = await createWorldGame( + [ + [1620, 660], + [1780, 660], + ], + 15_000, + ); + game = ctx.game; + [playerA, playerB] = ctx.players; + + console.log( + `[Setup] PvP-5M: A=${playerA.numTilesOwned()}, B=${playerB.numTilesOwned()}, ` + + `shared=${playerA.sharedBorderLength(playerB)}`, + ); + }); + + test("full PvP attack — A conquers B (5M troops, deep push)", () => { + playerA.setTroops(10_000_000); + playerB.setTroops(500_000); + + game.addExecution( + new AttackExecution(5_000_000, playerA, playerB.id(), null, false), + ); + + const r = benchFullAttack( + "PvP attack: 5M troops (deep push)", + game, + playerA, + 500, + ); + results.push(r); + console.log(fmt(r)); + expect(r.tilesConquered).toBeGreaterThan(0); + }); + }); + + // ── Scenario 2c: Parallel TN expansions (multi-attack overhead) ───────── + + describe("Parallel TN expansions (3 players)", () => { + let game: Game; + let players: Player[]; + + beforeAll(async () => { + const ctx = await createWorldGame( + [ + [1630, 660], + [1760, 660], + [1700, 700], + ], + 5_000, + ); + game = ctx.game; + players = ctx.players; + + console.log( + `[Setup] ParallelTN: ${players.map((p, i) => `P${i}=${p.numTilesOwned()}`).join(", ")}`, + ); + }); + + test("3 concurrent TN expansions — multi-attack overhead", () => { + for (const p of players) { + p.setTroops(10_000_000); + game.addExecution( + new AttackExecution( + 50_000_000, + p, + game.terraNullius().id(), + null, + false, + ), + ); + } + + const r = benchSustained( + "3× parallel TN expansion (5k tiles each)", + game, + players[0], + 3, + 30, + ); + results.push(r); + console.log(fmt(r)); + expect(r.tilesConquered).toBeGreaterThan(0); + }); + }); + + // ── Scenario 3: Paired PvP (2 independent attacks simultaneously) ───── + + describe("Paired PvP (A→B + C→D)", () => { + let game: Game; + let pA: Player; + let pB: Player; + let pC: Player; + let pD: Player; + + beforeAll(async () => { + const ctx = await createWorldGame( + [ + [1620, 650], + [1690, 650], + [1730, 680], + [1790, 680], + ], + 5_000, + ); + game = ctx.game; + [pA, pB, pC, pD] = ctx.players; + + console.log( + `[Setup] PairedPvP: A=${pA.numTilesOwned()}, B=${pB.numTilesOwned()}, ` + + `C=${pC.numTilesOwned()}, D=${pD.numTilesOwned()}, ` + + `AB-shared=${pA.sharedBorderLength(pB)}, CD-shared=${pC.sharedBorderLength(pD)}`, + ); + }); + + test("2 independent PvP attacks running simultaneously", () => { + pA.setTroops(10_000_000); + pB.setTroops(500_000); + pC.setTroops(10_000_000); + pD.setTroops(500_000); + + game.addExecution(new AttackExecution(500_000, pA, pB.id(), null, false)); + game.addExecution(new AttackExecution(500_000, pC, pD.id(), null, false)); + + const r = benchFullAttack("Paired PvP: A→B + C→D (500k each)", game, pA); + results.push(r); + console.log(fmt(r)); + expect(r.ticksMeasured).toBeGreaterThan(0); + }); + }); + + // ── Scenario 4: Baseline (small map) ─────────────────────────────────── + + describe("baseline: small map", () => { + let game: Game; + let attacker: Player; + + beforeAll(async () => { + const { setup } = await import("../../util/Setup"); + game = await setup("Plains", { + infiniteGold: true, + infiniteTroops: true, + }); + + const info = new PlayerInfo( + "us", + "Micro", + PlayerType.Human, + null, + "micro_id", + ); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(10, 10))); + while (game.inSpawnPhase()) game.executeNextTick(); + + attacker = game.player(info.id); + attacker.setTroops(1_000_000); + growPlayer(game, attacker, 200); + }); + + test("small TN attack — baseline tick cost", () => { + attacker.setTroops(1_000_000); + game.addExecution( + new AttackExecution( + 10_000, + attacker, + game.terraNullius().id(), + null, + false, + ), + ); + + const r = benchSustained( + "Small TN baseline (Plains, ~200 tiles)", + game, + attacker, + 3, + 50, + ); + results.push(r); + console.log(fmt(r)); + expect(r.tilesConquered).toBeGreaterThan(0); + }); + }); +}); From 4de1a201e1d6efa0c3e1bce1a4ba07e901b2e85a Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Mon, 23 Feb 2026 23:22:32 +0100 Subject: [PATCH 2/2] feat: optimize conquer method and add performance benchmarks for GameImpl --- src/core/game/GameImpl.ts | 49 +- tests/core/game/GameEngine.perf.test.ts | 799 ++++++++++++++++++++++++ 2 files changed, 830 insertions(+), 18 deletions(-) create mode 100644 tests/core/game/GameEngine.perf.test.ts diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 30fba99de..a223b61b4 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -872,37 +872,50 @@ export class GameImpl implements Game { if (!this.isLand(tile)) { throw Error(`cannot conquer water`); } - const currentOwner = this.owner(tile); - - if (currentOwner.isPlayer()) { - (currentOwner as PlayerImpl)._lastTileChange = this._ticks; - (currentOwner as PlayerImpl)._tiles.delete(tile); - (currentOwner as PlayerImpl)._borderTiles.delete(tile); - (currentOwner as PlayerImpl).invalidateNeighborCache(); - } - this._map.setOwnerID(tile, newOwner.smallID()); - (newOwner as PlayerImpl)._tiles.add(tile); - const numTiles = (newOwner as PlayerImpl).numTilesOwned(); - (newOwner as PlayerImpl).setProductivity( - ((newOwner as PlayerImpl).productivity() * (numTiles - 1)) / numTiles + - 1 / numTiles, + const map = this._map; + + // Direct ownerID check — avoids owner() lookup + isPlayer() virtual dispatch + const oid = map.ownerID(tile); + if (oid !== 0) { + const oldOwner = this._playersBySmallID[oid - 1] as PlayerImpl; + oldOwner._lastTileChange = this._ticks; + oldOwner._tiles.delete(tile); + oldOwner._borderTiles.delete(tile); + // In batch mode, endBorderBatch() invalidates caches once per player; + // in unbatched mode, invalidate immediately. + if (this._dirtyBorderTiles === null) { + oldOwner.invalidateNeighborCache(); + } + } + + const np = newOwner as PlayerImpl; + map.setOwnerID(tile, np.smallID()); + np._tiles.add(tile); + const numTiles = np.numTilesOwned(); + np.setProductivity( + (np.productivity() * (numTiles - 1)) / numTiles + 1 / numTiles, ); - (newOwner as PlayerImpl)._lastTileChange = this._ticks; + np._lastTileChange = this._ticks; + if (this._dirtyBorderTiles !== null) { this._dirtyBorderTiles.add(tile); } else { this.updateBorders(tile); } - this._map.setFallout(tile, false); + + // Conditional fallout clear — common case has no fallout (branch-predicted skip) + if (map.hasFallout(tile)) { + map.setFallout(tile, false); + } this.addUpdate({ type: GameUpdateType.TileOwnerChanged, tile: tile, - newOwnerID: newOwner.id(), + newOwnerID: np.id(), }); this.addUpdate({ type: GameUpdateType.Tile, - update: this.toTileUpdate(tile), + update: map.toTileUpdate(tile), }); } diff --git a/tests/core/game/GameEngine.perf.test.ts b/tests/core/game/GameEngine.perf.test.ts new file mode 100644 index 000000000..fce3a01ae --- /dev/null +++ b/tests/core/game/GameEngine.perf.test.ts @@ -0,0 +1,799 @@ +/** + * Game Engine Internals – Performance Micro-Benchmarks + * ===================================================== + * Isolates the dominant costs inside `GameImpl` that bottleneck deep-push + * PvP attacks (where AttackExecution V2 optimizations showed ~0% gain): + * + * 1. **conquer() throughput** — per-tile cost of ownership transfer + * (setOwnerID, Set.add/delete, productivity recalc, addUpdate ×2) + * 2. **endBorderBatch() scaling** — border recalculation overhead as + * the number of dirty tiles grows (100 → 1k → 5k → 10k) + * 3. **attackLogic() per-tile cost** — terrain lookup + defense post + * spatial query cost (with and without nearby defense posts) + * 4. **addUpdate() overhead** — GameUpdate object creation rate + * 5. **Batched vs unbatched conquer** — demonstrates why batching matters + * + * Reuses the same world-map infrastructure from AttackExecution.perf.test + * (2000×1000, 651k land tiles) for realistic data. + * + * Compare CSV output across branches / PRs to detect regressions. + */ + +import fsSync from "fs"; +import path from "path"; +import { DefaultConfig } from "../../../src/core/configuration/DefaultConfig"; +import { AttackExecution } from "../../../src/core/execution/AttackExecution"; +import { SpawnExecution } from "../../../src/core/execution/SpawnExecution"; +import { + Difficulty, + Game, + GameMapType, + GameMode, + GameType, + Player, + PlayerInfo, + PlayerType, + Tick, +} from "../../../src/core/game/Game"; +import { createGame } from "../../../src/core/game/GameImpl"; +import { TileRef } from "../../../src/core/game/GameMap"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import { genTerrainFromBin } from "../../../src/core/game/TerrainMapLoader"; +import { UserSettings } from "../../../src/core/game/UserSettings"; +import { GameConfig, PeaceTimerDuration } from "../../../src/core/Schemas"; +import { TestServerConfig } from "../../util/TestServerConfig"; + +// ── Config ─────────────────────────────────────────────────────────────── + +class PerfTestConfig extends DefaultConfig { + disableNavMesh(): boolean { + return true; + } + spawnImmunityDuration(): Tick { + return 0; + } +} + +// ── Map loader (shared with AttackExecution.perf.test) ─────────────────── + +function prependDimensionHeader( + raw: Uint8Array, + w: number, + h: number, +): Uint8Array { + const hdr = new Uint8Array(4); + hdr[0] = w & 0xff; + hdr[1] = (w >> 8) & 0xff; + hdr[2] = h & 0xff; + hdr[3] = (h >> 8) & 0xff; + const out = new Uint8Array(4 + raw.length); + out.set(hdr); + out.set(raw, 4); + return out; +} + +function uint8ToBinStr(bytes: Uint8Array): string { + const CHUNK = 8192; + let s = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + s += String.fromCharCode( + ...bytes.subarray(i, Math.min(i + CHUNK, bytes.length)), + ); + } + return s; +} + +async function setupPerf( + mapName: string, + overrides: Partial = {}, +): Promise { + console.debug = () => {}; + const origLog = console.log; + console.log = (...args: unknown[]) => { + if (typeof args[0] === "string" && args[0].startsWith("[tick")) return; + origLog(...args); + }; + + const dir = path.join(__dirname, "..", "..", "testdata", "maps", mapName); + const manifest = JSON.parse( + fsSync.readFileSync(path.join(dir, "manifest.json"), "utf-8"), + ); + + const mapBin = prependDimensionHeader( + new Uint8Array(fsSync.readFileSync(path.join(dir, "map.bin"))), + manifest.map.width, + manifest.map.height, + ); + const miniBin = prependDimensionHeader( + new Uint8Array(fsSync.readFileSync(path.join(dir, "map4x.bin"))), + manifest.map4x.width, + manifest.map4x.height, + ); + + const gameMap = await genTerrainFromBin(uint8ToBinStr(mapBin)); + const miniMap = await genTerrainFromBin(uint8ToBinStr(miniBin)); + + const cfg: GameConfig = { + gameMap: GameMapType.Asia, + gameMode: GameMode.FFA, + gameType: GameType.Singleplayer, + difficulty: Difficulty.Medium, + disableNPCs: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + peaceTimerDurationMinutes: PeaceTimerDuration.None, + startingGold: 0, + goldMultiplier: 1, + chatEnabled: false, + ...overrides, + }; + + return createGame( + [], + [], + gameMap, + miniMap, + new PerfTestConfig(new TestServerConfig(), cfg, new UserSettings(), false), + ); +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +interface MicroResult { + label: string; + ops: number; + totalMs: number; + opsPerSec: number; + usPerOp: number; +} + +function microBench(label: string, ops: number, fn: () => void): MicroResult { + // Warm up JIT + for (let i = 0; i < Math.min(ops, 100); i++) fn(); + + const t0 = performance.now(); + for (let i = 0; i < ops; i++) fn(); + const elapsed = performance.now() - t0; + + return { + label, + ops, + totalMs: elapsed, + opsPerSec: (ops / elapsed) * 1000, + usPerOp: (elapsed / ops) * 1000, + }; +} + +function fmtMicro(r: MicroResult): string { + return [ + ` [${r.label}]`, + ` ops: ${r.ops.toLocaleString()}`, + ` total: ${r.totalMs.toFixed(1)} ms`, + ` per-op: ${r.usPerOp.toFixed(3)} µs`, + ` throughput: ${(r.opsPerSec / 1000).toFixed(1)} k ops/sec`, + ].join("\n"); +} + +/** + * BFS to collect unowned land tiles reachable from a player's border. + * Returns up to `count` tiles ordered by BFS distance. + */ +function collectUnownedLandBFS( + game: Game, + player: Player, + count: number, +): TileRef[] { + const map = game.map(); + const result: TileRef[] = []; + const visited = new Set(); + const queue: TileRef[] = []; + + for (const bt of player.borderTiles()) { + game.forEachNeighbor(bt, (n: TileRef) => { + if (map.isLand(n) && !game.owner(n).isPlayer() && !visited.has(n)) { + visited.add(n); + queue.push(n); + } + }); + } + + let head = 0; + while (head < queue.length && result.length < count) { + const tile = queue[head++]; + if (game.owner(tile).isPlayer()) continue; + result.push(tile); + game.forEachNeighbor(tile, (n: TileRef) => { + if (map.isLand(n) && !visited.has(n)) { + visited.add(n); + queue.push(n); + } + }); + } + return result; +} + +/** + * BFS from attacker's border into defender territory. + * Returns up to `count` defender tiles ordered by distance from shared border. + */ +function collectDefenderTilesBFS( + game: Game, + attacker: Player, + defender: Player, + count: number, +): TileRef[] { + const result: TileRef[] = []; + const visited = new Set(); + const queue: TileRef[] = []; + + for (const bt of attacker.borderTiles()) { + game.forEachNeighbor(bt, (n: TileRef) => { + if (game.owner(n) === defender && !visited.has(n)) { + visited.add(n); + queue.push(n); + } + }); + } + + let head = 0; + while (head < queue.length && result.length < count) { + const t = queue[head++]; + result.push(t); + game.forEachNeighbor(t, (n: TileRef) => { + if (game.owner(n) === defender && !visited.has(n)) { + visited.add(n); + queue.push(n); + } + }); + } + return result; +} + +/** + * Deterministic BFS territory assignment (same as AttackExecution perf test). + */ +function assignTerritory( + game: Game, + players: Player[], + tilesEach: number, +): void { + const map = game.map(); + const queues: number[][] = players.map(() => []); + const heads: number[] = players.map(() => 0); + const visited = new Set(); + + for (let i = 0; i < players.length; i++) { + for (const bt of players[i].borderTiles()) { + game.forEachNeighbor(bt, (n: number) => { + if (map.isLand(n) && !game.owner(n).isPlayer() && !visited.has(n)) { + visited.add(n); + queues[i].push(n); + } + }); + } + } + + game.beginBorderBatch(); + const counts = players.map(() => 0); + let progress = true; + while (progress) { + progress = false; + for (let i = 0; i < players.length; i++) { + if (counts[i] >= tilesEach) continue; + while (heads[i] < queues[i].length) { + const tile = queues[i][heads[i]++]; + if (game.owner(tile).isPlayer()) continue; + game.conquer(players[i], tile); + counts[i]++; + progress = true; + game.forEachNeighbor(tile, (n: number) => { + if (map.isLand(n) && !visited.has(n)) { + visited.add(n); + queues[i].push(n); + } + }); + break; + } + } + } + game.endBorderBatch(); +} + +function growPlayer( + game: Game, + player: Player, + targetTiles: number, + maxTicks = 100_000, +): void { + let t = 0; + while (player.numTilesOwned() < targetTiles && t < maxTicks) { + player.setTroops(10_000_000); + if (player.outgoingAttacks().length === 0) { + game.addExecution( + new AttackExecution( + 5_000_000, + player, + game.terraNullius().id(), + null, + false, + ), + ); + } + game.executeNextTick(); + t++; + } +} + +async function createWorldGame( + spawns: [number, number][], + tilesEach: number, +): Promise<{ game: Game; players: Player[] }> { + const game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + + const codes = ["au", "nz", "pg", "fj"]; + const infos = spawns.map( + (_, i) => + new PlayerInfo(codes[i], `P${i}`, PlayerType.Human, null, `pid_${i}`), + ); + for (const info of infos) game.addPlayer(info); + + game.addExecution( + ...infos.map( + (info, i) => + new SpawnExecution(info, game.ref(spawns[i][0], spawns[i][1])), + ), + ); + while (game.inSpawnPhase()) game.executeNextTick(); + + const players = infos.map((info) => game.player(info.id)); + assignTerritory(game, players, tilesEach); + + return { game, players }; +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +jest.setTimeout(600_000); + +describe("Game Engine Internals Performance", () => { + const allResults: MicroResult[] = []; + + afterAll(() => { + console.log( + "\n╔══════════════════════════════════════════════════════════════╗", + ); + console.log( + "║ GAME ENGINE INTERNALS PERFORMANCE SUMMARY ║", + ); + console.log( + "╚══════════════════════════════════════════════════════════════╝\n", + ); + for (const r of allResults) console.log(fmtMicro(r) + "\n"); + console.log("--- CSV ---"); + console.log("label,ops,total_ms,us_per_op,k_ops_sec"); + for (const r of allResults) { + console.log( + [ + r.label, + r.ops, + r.totalMs.toFixed(3), + r.usPerOp.toFixed(3), + (r.opsPerSec / 1000).toFixed(1), + ].join(","), + ); + } + }); + + // ── 1. conquer() throughput ──────────────────────────────────────────── + + describe("conquer() throughput", () => { + test("batched conquer — 5000 TN tiles", async () => { + const game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + const info = new PlayerInfo( + "us", + "Conqueror", + PlayerType.Human, + null, + "conq_id", + ); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(375, 272))); + while (game.inSpawnPhase()) game.executeNextTick(); + const player = game.player(info.id); + growPlayer(game, player, 5_000); + + const tiles = collectUnownedLandBFS(game, player, 5_000); + const n = tiles.length; + expect(n).toBeGreaterThanOrEqual(1_000); + + console.log( + `[Setup] conquer: ${player.numTilesOwned()} tiles, border ${player.borderTiles().size}`, + ); + + game.beginBorderBatch(); + const t0 = performance.now(); + for (let i = 0; i < n; i++) game.conquer(player, tiles[i]); + const conquerMs = performance.now() - t0; + const t1 = performance.now(); + game.endBorderBatch(); + const borderMs = performance.now() - t1; + + const total = conquerMs + borderMs; + const r: MicroResult = { + label: `conquer() batched × ${n}`, + ops: n, + totalMs: total, + opsPerSec: (n / total) * 1000, + usPerOp: (total / n) * 1000, + }; + allResults.push(r); + console.log(fmtMicro(r)); + console.log( + ` conquer: ${conquerMs.toFixed(1)} ms border: ${borderMs.toFixed(1)} ms`, + ); + }); + + test("unbatched conquer — 1000 tiles", async () => { + const game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + const info = new PlayerInfo( + "us", + "Conqueror", + PlayerType.Human, + null, + "unbatch_id", + ); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(375, 272))); + while (game.inSpawnPhase()) game.executeNextTick(); + const player = game.player(info.id); + growPlayer(game, player, 5_000); + + const tiles = collectUnownedLandBFS(game, player, 1_000); + const n = tiles.length; + expect(n).toBeGreaterThanOrEqual(500); + + const t0 = performance.now(); + for (let i = 0; i < n; i++) { + game.conquer(player, tiles[i]); + } + const elapsed = performance.now() - t0; + + const r: MicroResult = { + label: `conquer() unbatched × ${n}`, + ops: n, + totalMs: elapsed, + opsPerSec: (n / elapsed) * 1000, + usPerOp: (elapsed / n) * 1000, + }; + allResults.push(r); + console.log(fmtMicro(r)); + }); + }); + + // ── 2. endBorderBatch() scaling ──────────────────────────────────────── + + describe("endBorderBatch() scaling", () => { + const batchSizes = [100, 500, 1_000, 2_000, 5_000, 10_000]; + + for (const size of batchSizes) { + test(`endBorderBatch after ${size} conquests`, async () => { + // Fresh game for each batch-size measurement + const game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + const info = new PlayerInfo( + "us", + "Scaler", + PlayerType.Human, + null, + `scale_${size}`, + ); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(375, 272))); + while (game.inSpawnPhase()) game.executeNextTick(); + const player = game.player(info.id); + growPlayer(game, player, 3_000); + + const tiles = collectUnownedLandBFS(game, player, size); + const n = tiles.length; + if (n < size * 0.5) { + console.warn( + `[WARN] Only found ${n}/${size} tiles — result may be under-representative`, + ); + } + + // Conquer inside batch (don't measure this part) + game.beginBorderBatch(); + for (let i = 0; i < n; i++) { + game.conquer(player, tiles[i]); + } + + // Measure only endBorderBatch + const t0 = performance.now(); + game.endBorderBatch(); + const elapsed = performance.now() - t0; + + const r: MicroResult = { + label: `endBorderBatch() @ ${n} dirty`, + ops: n, + totalMs: elapsed, + opsPerSec: (n / elapsed) * 1000, + usPerOp: (elapsed / n) * 1000, + }; + allResults.push(r); + console.log(fmtMicro(r)); + console.log(` → ${elapsed.toFixed(3)} ms total for ${n} dirty tiles`); + }); + } + }); + + // ── 3. attackLogic() per-tile cost ───────────────────────────────────── + + describe("attackLogic() per-tile cost", () => { + test("TN attack logic — no defense posts (pure math)", async () => { + const game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + const info = new PlayerInfo( + "us", + "Attacker", + PlayerType.Human, + null, + "atklogic_tn", + ); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(375, 272))); + while (game.inSpawnPhase()) game.executeNextTick(); + const player = game.player(info.id); + growPlayer(game, player, 5_000); + + // Collect border tiles to call attackLogic on realistic tiles + const borderArr = [...player.borderTiles()]; + const config = game.config(); + const tn = game.terraNullius(); + const count = Math.min(borderArr.length, 5_000); + expect(count).toBeGreaterThan(100); + + const r = microBench(`attackLogic() vs TN × ${count}`, count, () => { + for (let i = 0; i < count; i++) { + config.attackLogic( + game, + 1_000_000, + player, + tn, + borderArr[i % borderArr.length], + ); + } + }); + // Each call of the outer fn does `count` attackLogic calls + const adjusted: MicroResult = { + label: r.label, + ops: r.ops * count, + totalMs: r.totalMs, + opsPerSec: ((r.ops * count) / r.totalMs) * 1000, + usPerOp: (r.totalMs / (r.ops * count)) * 1000, + }; + allResults.push(adjusted); + console.log(fmtMicro(adjusted)); + }); + + test("PvP attack logic — with defense post scanning", async () => { + const ctx = await createWorldGame( + [ + [1620, 660], + [1780, 660], + ], + 15_000, + ); + const { game, players } = ctx; + const [attacker, defender] = players; + + attacker.setTroops(10_000_000); + defender.setTroops(5_000_000); + + // Get tiles on the shared border for realistic samples + const borderArr = [...attacker.borderTiles()]; + const config = game.config(); + const count = Math.min(borderArr.length, 5_000); + expect(count).toBeGreaterThan(50); + + const r = microBench( + `attackLogic() PvP (no defPosts) × ${count}`, + count, + () => { + for (let i = 0; i < count; i++) { + config.attackLogic( + game, + 1_000_000, + attacker, + defender, + borderArr[i % borderArr.length], + ); + } + }, + ); + const adjusted: MicroResult = { + label: r.label, + ops: r.ops * count, + totalMs: r.totalMs, + opsPerSec: ((r.ops * count) / r.totalMs) * 1000, + usPerOp: (r.totalMs / (r.ops * count)) * 1000, + }; + allResults.push(adjusted); + console.log(fmtMicro(adjusted)); + }); + }); + + // ── 4. addUpdate() overhead ──────────────────────────────────────────── + + describe("addUpdate() overhead", () => { + test("addUpdate() throughput — simulated conquer updates", async () => { + const game = await setupPerf("world", { + infiniteGold: true, + infiniteTroops: true, + }); + const info = new PlayerInfo( + "us", + "Updater", + PlayerType.Human, + null, + "update_id", + ); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(375, 272))); + while (game.inSpawnPhase()) game.executeNextTick(); + const player = game.player(info.id); + + const OPS = 50_000; + const playerId = player.id(); + + // Measure raw addUpdate throughput + const t0 = performance.now(); + for (let i = 0; i < OPS; i++) { + game.addUpdate({ + type: GameUpdateType.TileOwnerChanged, + tile: i as TileRef, + newOwnerID: playerId, + }); + game.addUpdate({ + type: GameUpdateType.Tile, + update: BigInt(i), + }); + } + const elapsed = performance.now() - t0; + + const totalUpdates = OPS * 2; + const r: MicroResult = { + label: `addUpdate() × ${totalUpdates} (2 per faux-conquer)`, + ops: totalUpdates, + totalMs: elapsed, + opsPerSec: (totalUpdates / elapsed) * 1000, + usPerOp: (elapsed / totalUpdates) * 1000, + }; + allResults.push(r); + console.log(fmtMicro(r)); + }); + }); + + // ── 5. Full conquer pipeline breakdown ───────────────────────────────── + + describe("full conquer pipeline breakdown (PvP)", () => { + test("conquer pipeline — PvP 10k tiles", async () => { + const ctx = await createWorldGame( + [ + [1620, 660], + [1780, 660], + ], + 15_000, + ); + const game = ctx.game; + const [attacker, defender] = ctx.players; + attacker.setTroops(10_000_000); + defender.setTroops(500_000); + + const defTiles = collectDefenderTilesBFS( + game, + attacker, + defender, + 10_000, + ); + const n = defTiles.length; + expect(n).toBeGreaterThanOrEqual(5_000); + + game.beginBorderBatch(); + const t0 = performance.now(); + for (let i = 0; i < n; i++) game.conquer(attacker, defTiles[i]); + const cqMs = performance.now() - t0; + const t1 = performance.now(); + game.endBorderBatch(); + const brMs = performance.now() - t1; + + const total = cqMs + brMs; + const r: MicroResult = { + label: `PvP conquer() × ${n}`, + ops: n, + totalMs: total, + opsPerSec: (n / total) * 1000, + usPerOp: (total / n) * 1000, + }; + allResults.push(r); + console.log(fmtMicro(r)); + console.log( + ` conquer: ${cqMs.toFixed(1)} ms border: ${brMs.toFixed(1)} ms`, + ); + }); + }); + + // ── 6. Set operations profiling ──────────────────────────────────────── + + describe("Set operations profiling (synthetic)", () => { + test("Set.add / Set.delete / Set.has throughput at scale", () => { + const sizes = [1_000, 10_000, 50_000, 100_000]; + + for (const size of sizes) { + const set = new Set(); + // Pre-fill + for (let i = 0; i < size; i++) set.add(i); + + const OPS = 100_000; + + // has() + const t0 = performance.now(); + for (let i = 0; i < OPS; i++) set.has(i % size); + const hasMs = performance.now() - t0; + + // add() existing + const t1 = performance.now(); + for (let i = 0; i < OPS; i++) set.add(i % size); + const addExistMs = performance.now() - t1; + + // delete() + add() (churn) + const t2 = performance.now(); + for (let i = 0; i < OPS; i++) { + set.delete(i % size); + set.add(i % size); + } + const churnMs = performance.now() - t2; + + const rHas: MicroResult = { + label: `Set.has() @ ${size.toLocaleString()} elems`, + ops: OPS, + totalMs: hasMs, + opsPerSec: (OPS / hasMs) * 1000, + usPerOp: (hasMs / OPS) * 1000, + }; + const rAdd: MicroResult = { + label: `Set.add(existing) @ ${size.toLocaleString()} elems`, + ops: OPS, + totalMs: addExistMs, + opsPerSec: (OPS / addExistMs) * 1000, + usPerOp: (addExistMs / OPS) * 1000, + }; + const rChurn: MicroResult = { + label: `Set.delete+add (churn) @ ${size.toLocaleString()} elems`, + ops: OPS, + totalMs: churnMs, + opsPerSec: (OPS / churnMs) * 1000, + usPerOp: (churnMs / OPS) * 1000, + }; + allResults.push(rHas, rAdd, rChurn); + console.log(fmtMicro(rHas)); + console.log(fmtMicro(rAdd)); + console.log(fmtMicro(rChurn)); + } + }); + }); +});