From a1b3afe5341eaf7856d7f9102c9e1151c8e59af4 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 13 Feb 2026 00:00:56 +0100 Subject: [PATCH] Fix cluster deletion (#3185) ## Description: When a train station is removed, the clusters are recomputed. However the cluster recomputation code has not been changed from the original rail network implementation, which was a tree. The deletion code made assumptions that are not true anymore since we introduced loops in the network. As a result the cluster recomputation was very inefficient, although the data was correct. Changes: - Fix clusters computation when a structure is deleted - Structures are frequently deleted in bulk: atom/hydro/MIRV. Re-computing the clusters when a single structure is deleted would be inefficient because the recomputed cluster would probably need to be recomputed again in the same tick. Instead, when a structure is deleted, flag the cluster as "dirty", and recompute all the dirty clusters once per tick only. Previous performances (hydro over a dense area): image Now: image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- src/core/GameRunner.ts | 7 +++ .../RecomputeRailClusterExecution.ts | 20 ++++++++ src/core/game/RailNetwork.ts | 1 + src/core/game/RailNetworkImpl.ts | 47 +++++++++++++------ src/core/game/TrainStation.ts | 6 ++- 5 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 src/core/execution/RecomputeRailClusterExecution.ts diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 619fb2645..856e19691 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -1,6 +1,7 @@ import { placeName } from "../client/graphics/NameBoxCalculator"; import { getConfig } from "./configuration/ConfigLoader"; import { Executor } from "./execution/ExecutionManager"; +import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution"; import { WinCheckExecution } from "./execution/WinCheckExecution"; import { AllPlayers, @@ -16,6 +17,7 @@ import { PlayerInfo, PlayerProfile, PlayerType, + UnitType, } from "./game/Game"; import { createGame } from "./game/GameImpl"; import { TileRef } from "./game/GameMap"; @@ -105,6 +107,11 @@ export class GameRunner { this.game.addExecution(...this.execManager.nationExecutions()); } this.game.addExecution(new WinCheckExecution()); + if (!this.game.config().isUnitDisabled(UnitType.Factory)) { + this.game.addExecution( + new RecomputeRailClusterExecution(this.game.railNetwork()), + ); + } } public addTurn(turn: Turn): void { diff --git a/src/core/execution/RecomputeRailClusterExecution.ts b/src/core/execution/RecomputeRailClusterExecution.ts new file mode 100644 index 000000000..c346ca481 --- /dev/null +++ b/src/core/execution/RecomputeRailClusterExecution.ts @@ -0,0 +1,20 @@ +import { Execution, Game } from "../game/Game"; +import { RailNetwork } from "../game/RailNetwork"; + +export class RecomputeRailClusterExecution implements Execution { + constructor(private railNetwork: RailNetwork) {} + + isActive(): boolean { + return true; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + init(mg: Game, ticks: number): void {} + + tick(ticks: number): void { + this.railNetwork.recomputeClusters(); + } +} diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 52d9fbe0d..7ad57c610 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -9,4 +9,5 @@ export interface RailNetwork { findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; overlappingRailroads(tile: TileRef): number[]; + recomputeClusters(): void; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 61227c6a9..b35fb80e8 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -86,6 +86,7 @@ export class RailNetworkImpl implements RailNetwork { private gridCellSize: number = 4; private railGrid: RailSpatialGrid; private nextId: number = 0; + private dirtyClusters = new Set(); constructor( private game: Game, @@ -106,26 +107,48 @@ export class RailNetworkImpl implements RailNetwork { } } + recomputeClusters() { + if (this.dirtyClusters.size === 0) return; + + for (const cluster of this.dirtyClusters) { + const allOriginalStations = new Set(cluster.stations); + while (allOriginalStations.size > 0) { + const nextStation = allOriginalStations.values().next().value; + const allConnectedStations = this.computeCluster(nextStation); + // Filter stations that are connected to the current cluster + for (const connectedStation of allConnectedStations) { + allOriginalStations.delete(connectedStation); + } + // Those stations were disconnected: new cluster + if (allOriginalStations.size > 0) { + const newCluster = new Cluster(); + // Switching their cluster will automatically remove them from their current cluster + newCluster.addStations(allConnectedStations); + } + } + } + this.dirtyClusters.clear(); + } + removeStation(unit: Unit): void { const station = this._stationManager.findStation(unit); if (!station) return; - const neighbors = station.neighbors(); this.disconnectFromNetwork(station); this._stationManager.removeStation(station); + station.unit.setTrainStation(false); const cluster = station.getCluster(); if (!cluster) return; - if (neighbors.length === 1) { - cluster.removeStation(station); - } else if (neighbors.length > 1) { - for (const neighbor of neighbors) { - const stations = this.computeCluster(neighbor); - const newCluster = new Cluster(); - newCluster.addStations(stations); - } + + cluster.removeStation(station); + if (cluster.size() === 0) { + this.deleteCluster(cluster); + this.dirtyClusters.delete(cluster); + return; } - station.unit.setTrainStation(false); + + this.dirtyClusters.add(cluster); } /** @@ -258,10 +281,6 @@ export class RailNetworkImpl implements RailNetwork { this.railGrid.unregister(rail); } station.clearRailroads(); - const cluster = station.getCluster(); - if (cluster !== null && cluster.size() === 1) { - this.deleteCluster(cluster); - } } private deleteCluster(cluster: Cluster) { diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 6a38c9f8f..e2b687a6f 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -53,7 +53,7 @@ export class TrainStation { id: number = -1; // assigned by StationManager private readonly stopHandlers: Partial> = {}; - private cluster: Cluster | null; + private cluster: Cluster | null = null; private railroads: Set = new Set(); // Quick lookup from neighboring station to connecting railroad private railroadByNeighbor: Map = new Map(); @@ -129,6 +129,10 @@ export class TrainStation { } setCluster(cluster: Cluster | null) { + // Properly disconnect cluster if it's already set + if (this.cluster !== null) { + this.cluster.removeStation(this); + } this.cluster = cluster; }