mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:45 +00:00
Share water pathfinder chain across ships (~150 MB savings) (#4068)
## Description
Each `TradeShipExecution` / `WarshipExecution` /
`TransportShipExecution` constructed its own `WaterPathFinder`, which
built a full transformer chain wrapping the (already-shared)
`AStarWaterHierarchical`. The chain's `SmoothingWaterTransformer`
allocates its own `AStarWaterBounded` with a 100×100 scratch (~480 KB:
four typed arrays + a MinHeap). With ~300 concurrent ships, that's ~150
MB of duplicated scratch buffers serving identical purposes.
Heap snapshot before:
- `WaterPathFinder` ×309 → 151 MB retained
- `AStarWaterBounded` ×312 (= 3 from the shared HPA + 309 from per-ship
smoothers) → 152 MB retained
- Worker total: 230 MB
## Fix
Cache the transformer chain in a module-level `WeakMap<Game, {version,
chain}>` in `PathFinder.ts`, keyed by `Game` and invalidated when
`waterGraphVersion()` changes. `PathFinding.Water` /
`PathFinding.WaterSimple` and the per-ship `WaterPathFinder` all wrap a
fresh (cheap) `PathFinderStepper` around the shared chain. Each ship
keeps its own stepper for its private path cache.
## Why sharing is safe
- The worker is single-threaded; `findPath` runs synchronously, so no
two callers touch the chain's scratch buffers at the same time.
- `AStarWaterBounded.searchBounded` already uses a `stamp++` pattern to
invalidate stale data — it doesn't care whether the previous caller was
the same instance or a different one.
- All transformers in the chain are either stateless or use the same
stamp-protected pattern.
## Stagger preserved
The per-ship stagger after a water-graph rebuild (so 300 ships don't
re-A* in the same tick) is intact. The chain itself rebuilds once per
version bump; each `WaterPathFinder` still counts down its own
`_staggerCountdown` before replacing its stepper (which invalidates its
cached path and forces re-A* against the new chain).
## Heap snapshot after
- `WaterPathFinder` no longer in top retainers
- `AStarWaterBounded` folded into the single 9 MB
`AStarWaterHierarchical`
- Worker total: 80 MB (≈150 MB freed)
## Please complete the following:
- [x] I have added screenshots for all UI updates (N/A — internal
refactor)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (N/A — no user-facing text)
- [x] I have added relevant tests to the test directory (existing tests
cover the behavior — all 1279 still pass)
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Discord
evan
This commit is contained in:
@@ -10,12 +10,17 @@ import {
|
||||
} from "./PathFinder.Parabola";
|
||||
import { StationPathFinder } from "./PathFinder.Station";
|
||||
import { PathFinderBuilder } from "./PathFinderBuilder";
|
||||
import { StepperConfig } from "./PathFinderStepper";
|
||||
import { PathFinderStepper, StepperConfig } from "./PathFinderStepper";
|
||||
import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransformer";
|
||||
import { MiniMapTransformer } from "./transformers/MiniMapTransformer";
|
||||
import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer";
|
||||
import { SmoothingWaterTransformer } from "./transformers/SmoothingWaterTransformer";
|
||||
import { PathResult, PathStatus, SteppingPathFinder } from "./types";
|
||||
import {
|
||||
PathFinder,
|
||||
PathResult,
|
||||
PathStatus,
|
||||
SteppingPathFinder,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Pathfinders that work with GameMap - usable in both simulation and UI layers
|
||||
@@ -29,37 +34,64 @@ export class UniversalPathFinding {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared water-pathfinder chain cache. The transformer chain wraps the
|
||||
// already-shared AStarWaterHierarchical (owned by WaterManager) and holds the
|
||||
// only large per-ship allocation we had — SmoothingWaterTransformer's bounded
|
||||
// A* scratch. Sharing the chain across all callers cuts ~500 KB per ship.
|
||||
// Single-threaded worker + stamp-based scratch invalidation makes sharing safe.
|
||||
const _waterChainCache = new WeakMap<
|
||||
Game,
|
||||
{ version: number; chain: PathFinder<TileRef> }
|
||||
>();
|
||||
|
||||
function buildWaterChain(game: Game): PathFinder<TileRef> {
|
||||
const hpa = game.miniWaterHPA();
|
||||
const graph = game.miniWaterGraph();
|
||||
const miniMap = game.miniMap();
|
||||
|
||||
if (!hpa || !graph || graph.nodeCount < 100) {
|
||||
const simple = new AStarWater(miniMap);
|
||||
return PathFinderBuilder.create(simple)
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.build();
|
||||
}
|
||||
|
||||
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
|
||||
return PathFinderBuilder.create(hpa)
|
||||
.wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn))
|
||||
.wrap((pf) => new SmoothingWaterTransformer(pf, miniMap))
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.build();
|
||||
}
|
||||
|
||||
function sharedWaterChain(game: Game): PathFinder<TileRef> {
|
||||
const version = game.waterGraphVersion();
|
||||
const cached = _waterChainCache.get(game);
|
||||
if (cached && cached.version === version) {
|
||||
return cached.chain;
|
||||
}
|
||||
const chain = buildWaterChain(game);
|
||||
_waterChainCache.set(game, { version, chain });
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pathfinders that require Game - simulation layer only
|
||||
*/
|
||||
export class PathFinding {
|
||||
static Water(game: Game): SteppingPathFinder<TileRef> {
|
||||
const pf = game.miniWaterHPA();
|
||||
const graph = game.miniWaterGraph();
|
||||
|
||||
if (!pf || !graph || graph.nodeCount < 100) {
|
||||
return PathFinding.WaterSimple(game);
|
||||
}
|
||||
|
||||
const miniMap = game.miniMap();
|
||||
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn))
|
||||
.wrap((pf) => new SmoothingWaterTransformer(pf, miniMap))
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
return new PathFinderStepper(
|
||||
sharedWaterChain(game),
|
||||
tileStepperConfig(game),
|
||||
);
|
||||
}
|
||||
|
||||
static WaterSimple(game: Game): SteppingPathFinder<TileRef> {
|
||||
const miniMap = game.miniMap();
|
||||
const pf = new AStarWater(miniMap);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
// Kept for backwards compatibility; shared chain auto-selects simple vs
|
||||
// hierarchical based on graph availability.
|
||||
return PathFinding.Water(game);
|
||||
}
|
||||
|
||||
static Rail(game: Game): SteppingPathFinder<TileRef> {
|
||||
@@ -91,10 +123,11 @@ export class PathFinding {
|
||||
|
||||
/**
|
||||
* Water pathfinder that auto-rebuilds when the water graph changes.
|
||||
* Wraps SteppingPathFinder and tracks waterGraphVersion internally.
|
||||
* Wraps a per-ship stepper around the shared water chain on Game; tracks
|
||||
* waterGraphVersion to stagger when each ship invalidates its cached path.
|
||||
*/
|
||||
export class WaterPathFinder implements SteppingPathFinder<TileRef> {
|
||||
private inner: SteppingPathFinder<TileRef>;
|
||||
private stepper: SteppingPathFinder<TileRef>;
|
||||
private _waterGraphVersion: number;
|
||||
private _rebuilt = false;
|
||||
|
||||
@@ -112,7 +145,10 @@ export class WaterPathFinder implements SteppingPathFinder<TileRef> {
|
||||
private game: Game,
|
||||
private _stagger: number = 0,
|
||||
) {
|
||||
this.inner = PathFinding.Water(game);
|
||||
this.stepper = new PathFinderStepper(
|
||||
sharedWaterChain(game),
|
||||
tileStepperConfig(game),
|
||||
);
|
||||
this._waterGraphVersion = game.waterGraphVersion();
|
||||
this._staggerCountdown = 0;
|
||||
}
|
||||
@@ -140,27 +176,32 @@ export class WaterPathFinder implements SteppingPathFinder<TileRef> {
|
||||
|
||||
if (this._staggerCountdown > 0) {
|
||||
this._staggerCountdown--;
|
||||
return; // Keep using old pathfinder for now
|
||||
return; // Keep using old stepper (and its cached path) for now
|
||||
}
|
||||
|
||||
// Countdown complete — rebuild.
|
||||
// Countdown complete — swap to a fresh stepper around the (now-current)
|
||||
// shared chain. Dropping the old stepper invalidates the cached path,
|
||||
// which forces an A* re-run on the next call against the new graph.
|
||||
this._waterGraphVersion = v;
|
||||
this.inner = PathFinding.Water(this.game);
|
||||
this.stepper = new PathFinderStepper(
|
||||
sharedWaterChain(this.game),
|
||||
tileStepperConfig(this.game),
|
||||
);
|
||||
this._rebuilt = true;
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult<TileRef> {
|
||||
this.ensureFresh();
|
||||
return this.inner.next(from, to, dist);
|
||||
return this.stepper.next(from, to, dist);
|
||||
}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
this.ensureFresh();
|
||||
return this.inner.findPath(from, to);
|
||||
return this.stepper.findPath(from, to);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.inner.invalidate();
|
||||
this.stepper.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user