mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 10:22:03 +00:00
perf: cut core-sim GC churn another 36% (61% cumulative) (#4496)
## Summary Round 2 of GC-churn reduction, attacking the next tier of allocation sources found by the profiling harness from #4494. All changes are **behavior-preserving** — the simulation is bit-identical (final hash unchanged on three seeded runs). ### Changes | Site | Change | Churn target | |---|---|---| | `Player.units()` / `Game.units()` | Rest parameter → fixed-arity + array overloads (`units()`, `units(types[])`, `units(t1, t2?, t3?)`). The rest array was allocated on **every call** of one of the hottest functions in the sim. Spread call sites (`units(...Structures.types)`) now pass the array directly. `GameImpl.units()` builds one flat array instead of `Array.from().flatMap()` per-player intermediates. | ~18 GB | | `PlayerExecution` cluster flood fill | Results are plain `TileRef[]` in mark order instead of `Set<TileRef>` — the generation-stamped visited array already deduplicates, and consumers only iterate/measure. DFS stack reused across fills. | ~3.7 GB | | `SpatialQuery.bfsNearest` | Fused generation-stamped BFS with per-game scratch buffers (`WeakMap`-keyed, same pattern as `PlayerExecution`) instead of materializing a `Set` of the entire search area per query. Identical traversal and tie-breaking. | ~2.2 GB | | `NationWarshipBehavior` ship tracking | Single-pass loops instead of `filter().forEach()`; dropped defensive `Array.from(set)` copies (deleting the current entry while iterating a `Set` is well-defined). | ~1.4 GB | ### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-min, seed `perf-default`) | Metric | Before | After | vs. pre-#4494 | |---|---|---|---| | Sampled allocations (incl. collected) | 59.2 GB | **37.8 GB (−36%)** | 97.7 GB (**−61%**) | | GC count / total pause | 1,076 / 1,830 ms | 772 / 1,442 ms | 1,682 / 3,313 ms | | Ticks/sec | 73 | **82** | 66 (+24%) | | Mean / p99 tick | 13.6 / 39.2 ms | 12.2 / 36.0 ms | 15.2 / 49.9 ms | `units()` no longer appears in the top-30 allocator list at all. The remaining leaders (possible round 3): the minimap pathfinding `Cell` pipeline (~8.5 GB), `diffPlayerUpdate`/`toFullUpdate` per-tick serialization (~4.6 GB), and iterator allocations (~3.3 GB). ## Determinism Final game-state hash unchanged on all three reference runs: - Giant World Map 12,000 ticks: `57830793797434300` ✓ - Giant World Map 2,000 ticks: `55125379638382860` ✓ - World 1,800 ticks: `32337437717390864` ✓ ## Test plan - [x] Full suite green (1,905 tests), including updated `units()` semantics tests (array overload, snapshot isolation, insertion order) - [x] Hash equality on 3 seeded headless runs (2 maps) - [x] Before/after 20-min GC benchmarks on the same commit base 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -127,7 +127,7 @@ export function simpleHash(str: string): number {
|
||||
|
||||
export function calculateBoundingBox(
|
||||
gm: GameMap,
|
||||
borderTiles: ReadonlySet<TileRef>,
|
||||
borderTiles: Iterable<TileRef>,
|
||||
): { min: Cell; max: Cell } {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
|
||||
@@ -13,6 +13,8 @@ import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
||||
interface ClusterTraversalState {
|
||||
visited: Uint32Array;
|
||||
gen: number;
|
||||
// Reusable DFS stack for flood fills; cleared at the start of each fill.
|
||||
stack: TileRef[];
|
||||
}
|
||||
|
||||
// Per-game traversal state used by calculateClusters() to avoid per-player buffers.
|
||||
@@ -125,9 +127,9 @@ export class PlayerExecution implements Execution {
|
||||
|
||||
// Find the largest cluster with a single linear scan (O(n)).
|
||||
let largestIndex = 0;
|
||||
let largestSize = clusters[0].size;
|
||||
let largestSize = clusters[0].length;
|
||||
for (let i = 1; i < clusters.length; i++) {
|
||||
const size = clusters[i].size;
|
||||
const size = clusters[i].length;
|
||||
if (size > largestSize) {
|
||||
largestSize = size;
|
||||
largestIndex = i;
|
||||
@@ -158,7 +160,7 @@ export class PlayerExecution implements Execution {
|
||||
}
|
||||
|
||||
private surroundedBySamePlayer(
|
||||
cluster: Set<TileRef>,
|
||||
cluster: readonly TileRef[],
|
||||
clusterBox: { min: Cell; max: Cell },
|
||||
): false | Player {
|
||||
const enemies = new Set<number>();
|
||||
@@ -211,7 +213,7 @@ export class PlayerExecution implements Execution {
|
||||
return false;
|
||||
}
|
||||
|
||||
private isSurrounded(cluster: Set<TileRef>): boolean {
|
||||
private isSurrounded(cluster: readonly TileRef[]): boolean {
|
||||
let hasEnemy = false;
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
@@ -246,7 +248,7 @@ export class PlayerExecution implements Execution {
|
||||
return inscribed(enemyBox, clusterBox);
|
||||
}
|
||||
|
||||
private removeCluster(cluster: Set<TileRef>) {
|
||||
private removeCluster(cluster: readonly TileRef[]) {
|
||||
for (const t of cluster) {
|
||||
if (this.mg?.ownerID(t) !== this.player?.smallID()) {
|
||||
// Other removeCluster operations could change tile owners,
|
||||
@@ -260,8 +262,8 @@ export class PlayerExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTile = cluster.values().next().value;
|
||||
if (!firstTile) {
|
||||
const firstTile = cluster[0];
|
||||
if (firstTile === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,7 +275,7 @@ export class PlayerExecution implements Execution {
|
||||
(tile) => this.mg.ownerID(tile) === this.player.smallID(),
|
||||
);
|
||||
|
||||
if (this.player.numTilesOwned() === tiles.size) {
|
||||
if (this.player.numTilesOwned() === tiles.length) {
|
||||
this.mg.conquerPlayer(capturing, this.player);
|
||||
}
|
||||
|
||||
@@ -282,7 +284,7 @@ export class PlayerExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
||||
private getCapturingPlayer(cluster: readonly TileRef[]): Player | null {
|
||||
const neighbors = new Map<Player, number>();
|
||||
const map = this.map;
|
||||
const mySmallID = this.player.smallID();
|
||||
@@ -327,7 +329,7 @@ export class PlayerExecution implements Execution {
|
||||
return getMode(neighbors);
|
||||
}
|
||||
|
||||
private calculateClusters(): Set<TileRef>[] {
|
||||
private calculateClusters(): TileRef[][] {
|
||||
const borderTiles = this.player.borderTiles();
|
||||
if (borderTiles.size === 0) return [];
|
||||
|
||||
@@ -335,7 +337,7 @@ export class PlayerExecution implements Execution {
|
||||
const currentGen = this.bumpGeneration();
|
||||
const visited = state.visited;
|
||||
|
||||
const clusters: Set<TileRef>[] = [];
|
||||
const clusters: TileRef[][] = [];
|
||||
|
||||
for (const startTile of borderTiles) {
|
||||
if (visited[startTile] === currentGen) continue;
|
||||
@@ -370,6 +372,7 @@ export class PlayerExecution implements Execution {
|
||||
state = {
|
||||
visited: new Uint32Array(totalTiles),
|
||||
gen: 0,
|
||||
stack: [],
|
||||
};
|
||||
traversalStates.set(this.mg, state);
|
||||
}
|
||||
@@ -392,15 +395,19 @@ export class PlayerExecution implements Execution {
|
||||
startTiles: TileRef[],
|
||||
neighborFn: (tile: TileRef, callback: (neighbor: TileRef) => void) => void,
|
||||
includeFn: (tile: TileRef) => boolean,
|
||||
): Set<TileRef> {
|
||||
const result = new Set<TileRef>();
|
||||
const stack: TileRef[] = [];
|
||||
): TileRef[] {
|
||||
// The visited generation array already deduplicates, so the result can be
|
||||
// a plain array (in mark order) — far cheaper than a Set of the same
|
||||
// size. The DFS stack is reused across fills via the traversal state.
|
||||
const result: TileRef[] = [];
|
||||
const stack = this.traversalState().stack;
|
||||
stack.length = 0;
|
||||
|
||||
for (const start of startTiles) {
|
||||
if (visited[start] === currentGen) continue;
|
||||
if (!includeFn(start)) continue;
|
||||
visited[start] = currentGen;
|
||||
result.add(start);
|
||||
result.push(start);
|
||||
stack.push(start);
|
||||
}
|
||||
|
||||
@@ -412,7 +419,7 @@ export class PlayerExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
visited[neighbor] = currentGen;
|
||||
result.add(neighbor);
|
||||
result.push(neighbor);
|
||||
stack.push(neighbor);
|
||||
};
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export class NationNukeBehavior {
|
||||
}
|
||||
const range = this.game.config().nukeMagnitudes(nukeType).outer;
|
||||
|
||||
const structures = nukeTarget.units(...Structures.types);
|
||||
const structures = nukeTarget.units(Structures.types);
|
||||
const structureTiles = structures.map((u) => u.tile());
|
||||
const difficulty = this.game.config().gameConfig().difficulty;
|
||||
// Use more random tiles on Impossible difficulty to improve chances of finding a perfect SAM outranging spot
|
||||
@@ -284,7 +284,7 @@ export class NationNukeBehavior {
|
||||
if (this.player.isFriendly(other)) continue;
|
||||
const tilesOwned = other.numTilesOwned();
|
||||
if (tilesOwned === 0) continue;
|
||||
const structures = other.units(...Structures.types);
|
||||
const structures = other.units(Structures.types);
|
||||
let levelSum = 0;
|
||||
for (const s of structures) levelSum += s.level();
|
||||
// Skip players with too few structures regardless of density
|
||||
|
||||
@@ -724,7 +724,7 @@ export class NationStructureBehavior {
|
||||
private getTotalStructureDensity(): number {
|
||||
const tilesOwned = this.player.numTilesOwned();
|
||||
return tilesOwned > 0
|
||||
? this.player.units(...Structures.types).length / tilesOwned
|
||||
? this.player.units(Structures.types).length / tilesOwned
|
||||
: 0; //ignoring levels for structures
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +104,9 @@ export class NationWarshipBehavior {
|
||||
.units(UnitType.TransportShip)
|
||||
.forEach((u) => this.trackedTransportShips.add(u));
|
||||
|
||||
// Iterate tracked transport ships; if it got destroyed by an enemy: retaliate
|
||||
for (const ship of Array.from(this.trackedTransportShips)) {
|
||||
// Iterate tracked transport ships; if it got destroyed by an enemy:
|
||||
// retaliate. Deleting the current entry while iterating a Set is safe.
|
||||
for (const ship of this.trackedTransportShips) {
|
||||
if (!ship.isActive()) {
|
||||
// Distinguish between arrival/retreat and enemy destruction
|
||||
if (ship.wasDestroyedByEnemy() && ship.destroyer() !== undefined) {
|
||||
@@ -127,8 +128,9 @@ export class NationWarshipBehavior {
|
||||
.units(UnitType.TradeShip)
|
||||
.forEach((u) => this.trackedTradeShips.add(u));
|
||||
|
||||
// Iterate tracked trade ships; if we no longer own it, it was captured: retaliate
|
||||
for (const ship of Array.from(this.trackedTradeShips)) {
|
||||
// Iterate tracked trade ships; if we no longer own it, it was captured:
|
||||
// retaliate. Deleting the current entry while iterating a Set is safe.
|
||||
for (const ship of this.trackedTradeShips) {
|
||||
if (!ship.isActive()) {
|
||||
this.trackedTradeShips.delete(ship);
|
||||
continue;
|
||||
@@ -143,21 +145,21 @@ export class NationWarshipBehavior {
|
||||
|
||||
private trackIncomingTransportsAndRetaliate(): void {
|
||||
// Add any transports which are targeting us to our tracking map
|
||||
this.game
|
||||
.units(UnitType.TransportShip)
|
||||
.filter((p) => {
|
||||
const target = p.targetTile();
|
||||
return (
|
||||
target &&
|
||||
p.isActive() &&
|
||||
!p.transportShipState().isRetreating &&
|
||||
this.game.ownerID(target) === this.player?.smallID() &&
|
||||
p.owner().smallID() !== this.player?.smallID()
|
||||
);
|
||||
})
|
||||
.forEach((p) => this.trackedIncomingTransportShips.add(p));
|
||||
for (const p of this.game.units(UnitType.TransportShip)) {
|
||||
const target = p.targetTile();
|
||||
if (
|
||||
target &&
|
||||
p.isActive() &&
|
||||
!p.transportShipState().isRetreating &&
|
||||
this.game.ownerID(target) === this.player?.smallID() &&
|
||||
p.owner().smallID() !== this.player?.smallID()
|
||||
) {
|
||||
this.trackedIncomingTransportShips.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
for (const transport of Array.from(this.trackedIncomingTransportShips)) {
|
||||
// Deleting the current entry while iterating a Set is safe.
|
||||
for (const transport of this.trackedIncomingTransportShips) {
|
||||
const target = transport.targetTile();
|
||||
if (
|
||||
!transport.isActive() ||
|
||||
|
||||
+11
-2
@@ -584,7 +584,13 @@ export interface Player {
|
||||
removeTroops(troops: number): number;
|
||||
|
||||
// Units
|
||||
units(...types: UnitType[]): Unit[];
|
||||
// Fixed-arity + array overloads instead of a rest parameter: the rest array
|
||||
// would be allocated on every call, and this is one of the hottest calls in
|
||||
// the simulation. With no arguments the player's live unit array is
|
||||
// returned — do not mutate it; typed queries return a fresh snapshot array.
|
||||
units(): Unit[];
|
||||
units(types: readonly UnitType[]): Unit[];
|
||||
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
|
||||
unitCount(type: UnitType): number;
|
||||
unitsConstructed(type: UnitType): number;
|
||||
unitsOwned(type: UnitType): number;
|
||||
@@ -758,7 +764,10 @@ export interface Game extends GameMap {
|
||||
|
||||
// Units
|
||||
unit(id: number): Unit | undefined;
|
||||
units(...types: UnitType[]): Unit[];
|
||||
// See Player.units() for why this is not a rest parameter.
|
||||
units(): Unit[];
|
||||
units(types: readonly UnitType[]): Unit[];
|
||||
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
|
||||
unitCount(type: UnitType): number;
|
||||
unitInfo(type: UnitType): UnitInfo;
|
||||
hasUnitNearby(
|
||||
|
||||
@@ -293,8 +293,46 @@ export class GameImpl implements Game {
|
||||
return this._unitMap.get(id);
|
||||
}
|
||||
|
||||
units(...types: UnitType[]): Unit[] {
|
||||
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
|
||||
units(): Unit[];
|
||||
units(types: readonly UnitType[]): Unit[];
|
||||
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
|
||||
units(
|
||||
first?: UnitType | readonly UnitType[],
|
||||
second?: UnitType,
|
||||
third?: UnitType,
|
||||
): Unit[] {
|
||||
// Built as a single flat array per call; per-player intermediate arrays
|
||||
// would churn the heap (player.units() with no args is allocation-free).
|
||||
const out: Unit[] = [];
|
||||
if (Array.isArray(first) && (first as readonly UnitType[]).length > 0) {
|
||||
const ts = new Set(first as readonly UnitType[]);
|
||||
for (const p of this._players.values()) {
|
||||
for (const u of p.units()) {
|
||||
if (ts.has(u.type())) out.push(u);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (first === undefined || Array.isArray(first)) {
|
||||
for (const p of this._players.values()) {
|
||||
for (const u of p.units()) {
|
||||
out.push(u);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (const p of this._players.values()) {
|
||||
for (const u of p.units()) {
|
||||
const t = u.type();
|
||||
if (
|
||||
t === first ||
|
||||
(second !== undefined && (t === second || t === third))
|
||||
) {
|
||||
out.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
unitCount(type: UnitType): number {
|
||||
|
||||
+34
-26
@@ -88,6 +88,7 @@ const EMPTY_EMBARGOES = new Set<string>();
|
||||
// are fully consumed before any re-entrant call, so sharing is safe.
|
||||
const NEIGHBOR_SCRATCH: TileRef[] = [0, 0, 0, 0];
|
||||
const UNITS_SCRATCH: Unit[] = [];
|
||||
const TYPE_SET_SCRATCH = new Set<UnitType>();
|
||||
// N, S, W, E — the sampling directions used by shoreReachableNeighbors().
|
||||
const SHORE_DIRECTIONS_DX = [0, 0, -1, 1];
|
||||
const SHORE_DIRECTIONS_DY = [-1, 1, 0, 0];
|
||||
@@ -362,45 +363,52 @@ export class PlayerImpl implements Player {
|
||||
return this.playerInfo.playerType;
|
||||
}
|
||||
|
||||
units(...types: UnitType[]): Unit[] {
|
||||
const len = types.length;
|
||||
if (len === 0) {
|
||||
units(): Unit[];
|
||||
units(types: readonly UnitType[]): Unit[];
|
||||
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
|
||||
units(
|
||||
first?: UnitType | readonly UnitType[],
|
||||
second?: UnitType,
|
||||
third?: UnitType,
|
||||
): Unit[] {
|
||||
if (first === undefined) {
|
||||
return this._units;
|
||||
}
|
||||
|
||||
// Hot path. Matches are gathered into a reusable scratch buffer and
|
||||
// copied out with an exact-size slice, so each call allocates exactly
|
||||
// one right-sized result array.
|
||||
// one right-sized result array. Fixed-arity parameters (rather than a
|
||||
// rest parameter) avoid allocating an argument array per call.
|
||||
const scratch = UNITS_SCRATCH;
|
||||
let n = 0;
|
||||
|
||||
// Fast paths for common small arity calls to avoid Set allocation.
|
||||
if (len === 1) {
|
||||
const t0 = types[0]!;
|
||||
for (const u of this._units) {
|
||||
if (u.type() === t0) scratch[n++] = u;
|
||||
if (Array.isArray(first)) {
|
||||
const types = first as readonly UnitType[];
|
||||
if (types.length === 0) {
|
||||
return this._units;
|
||||
}
|
||||
} else if (len === 2) {
|
||||
const t0 = types[0]!;
|
||||
const t1 = types[1]!;
|
||||
for (const u of this._units) {
|
||||
const t = u.type();
|
||||
if (t === t0 || t === t1) scratch[n++] = u;
|
||||
const ts = TYPE_SET_SCRATCH;
|
||||
ts.clear();
|
||||
for (const t of types) {
|
||||
ts.add(t);
|
||||
}
|
||||
} else if (len === 3) {
|
||||
const t0 = types[0]!;
|
||||
const t1 = types[1]!;
|
||||
const t2 = types[2]!;
|
||||
// Keep semantics identical for duplicates in types by using direct comparisons.
|
||||
for (const u of this._units) {
|
||||
const t = u.type();
|
||||
if (t === t0 || t === t1 || t === t2) scratch[n++] = u;
|
||||
}
|
||||
} else {
|
||||
const ts = new Set(types);
|
||||
for (const u of this._units) {
|
||||
if (ts.has(u.type())) scratch[n++] = u;
|
||||
}
|
||||
} else if (second === undefined) {
|
||||
for (const u of this._units) {
|
||||
if (u.type() === first) scratch[n++] = u;
|
||||
}
|
||||
} else if (third === undefined) {
|
||||
for (const u of this._units) {
|
||||
const t = u.type();
|
||||
if (t === first || t === second) scratch[n++] = u;
|
||||
}
|
||||
} else {
|
||||
for (const u of this._units) {
|
||||
const t = u.type();
|
||||
if (t === first || t === second || t === third) scratch[n++] = u;
|
||||
}
|
||||
}
|
||||
return scratch.slice(0, n);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ type Owner = Player | TerraNullius;
|
||||
|
||||
const REFINE_MAX_SEARCH_AREA = 100 * 100;
|
||||
|
||||
// Per-game BFS scratch (generation-stamped visited array + reusable stack) so
|
||||
// bfsNearest allocates nothing per query. Keyed by game because SpatialQuery
|
||||
// instances are created per call site.
|
||||
interface BfsScratch {
|
||||
visited: Uint32Array;
|
||||
gen: number;
|
||||
stack: TileRef[];
|
||||
}
|
||||
const bfsScratches = new WeakMap<Game, BfsScratch>();
|
||||
|
||||
export class SpatialQuery {
|
||||
private boundedAStar: AStarWaterBounded | null = null;
|
||||
|
||||
@@ -26,28 +36,63 @@ export class SpatialQuery {
|
||||
* Find nearest tile matching predicate using BFS traversal.
|
||||
* Uses Manhattan distance filter, ignores terrain barriers.
|
||||
*/
|
||||
private bfsScratch(): BfsScratch {
|
||||
const map = this.game.map();
|
||||
const totalTiles = map.width() * map.height();
|
||||
let s = bfsScratches.get(this.game);
|
||||
if (!s || s.visited.length < totalTiles) {
|
||||
s = { visited: new Uint32Array(totalTiles), gen: 0, stack: [] };
|
||||
bfsScratches.set(this.game, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private bfsNearest(
|
||||
from: TileRef,
|
||||
maxDist: number,
|
||||
predicate: (t: TileRef) => boolean,
|
||||
): TileRef | null {
|
||||
const map = this.game.map();
|
||||
const scratch = this.bfsScratch();
|
||||
scratch.gen++;
|
||||
if (scratch.gen === 0xffffffff) {
|
||||
scratch.visited.fill(0);
|
||||
scratch.gen = 1;
|
||||
}
|
||||
const gen = scratch.gen;
|
||||
const visited = scratch.visited;
|
||||
const stack = scratch.stack;
|
||||
stack.length = 0;
|
||||
|
||||
// Strict < keeps the first candidate on distance ties, so the winner
|
||||
// depends only on the deterministic BFS visit order.
|
||||
// Strict < keeps the first candidate at the minimum distance, so the
|
||||
// winner depends only on the deterministic traversal order (LIFO with
|
||||
// neighbors visited in the shared N, S, W, E order).
|
||||
let best: TileRef | null = null;
|
||||
let bestDist = Infinity;
|
||||
for (const tile of map.bfs(
|
||||
from,
|
||||
(_, t) => map.manhattanDist(from, t) <= maxDist,
|
||||
)) {
|
||||
if (predicate(tile)) {
|
||||
const dist = map.manhattanDist(from, tile);
|
||||
|
||||
const mark = (t: TileRef) => {
|
||||
visited[t] = gen;
|
||||
stack.push(t);
|
||||
if (predicate(t)) {
|
||||
const dist = map.manhattanDist(from, t);
|
||||
if (dist < bestDist) {
|
||||
best = tile;
|
||||
best = t;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (maxDist >= 0) {
|
||||
mark(from);
|
||||
}
|
||||
const visit = (n: TileRef) => {
|
||||
if (visited[n] !== gen && map.manhattanDist(from, n) <= maxDist) {
|
||||
mark(n);
|
||||
}
|
||||
};
|
||||
while (stack.length > 0) {
|
||||
const curr = stack.pop()!;
|
||||
map.forEachNeighbor(curr, visit);
|
||||
}
|
||||
|
||||
return best;
|
||||
|
||||
@@ -120,14 +120,14 @@ describe("PlayerImpl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("four or more types (Set path) and no match", () => {
|
||||
test("array of types (Set path) and no match", () => {
|
||||
expect(
|
||||
player.units(
|
||||
player.units([
|
||||
UnitType.City,
|
||||
UnitType.DefensePost,
|
||||
UnitType.MissileSilo,
|
||||
UnitType.Port,
|
||||
),
|
||||
]),
|
||||
).toEqual(
|
||||
expected(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user