From 6ed1483127be2eba5afb6250f50b8c541c0f58cd Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 29 May 2026 12:49:25 -0700 Subject: [PATCH] Share water pathfinder chain across ships (~150 MB savings) (#4068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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` 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 --- src/core/pathfinding/PathFinder.ts | 109 ++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 34 deletions(-) diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index b40c914bb..96e117435 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -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 } +>(); + +function buildWaterChain(game: Game): PathFinder { + 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 { + 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 { - 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 { - 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 { @@ -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 { - private inner: SteppingPathFinder; + private stepper: SteppingPathFinder; private _waterGraphVersion: number; private _rebuilt = false; @@ -112,7 +145,10 @@ export class WaterPathFinder implements SteppingPathFinder { 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 { 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 { 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(); } }