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):
<img width="700" height="160" alt="image"
src="https://github.com/user-attachments/assets/cd3ceb42-6d5f-4ad1-b35a-f8e5e0513821"
/>


Now:
<img width="450" height="269" alt="image"
src="https://github.com/user-attachments/assets/55dec3b9-8619-4a6c-9a16-f5368fe40da1"
/>

## 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
This commit is contained in:
DevelopingTom
2026-02-13 00:00:56 +01:00
committed by GitHub
parent cb6e97ed11
commit a1b3afe534
5 changed files with 66 additions and 15 deletions
+7
View File
@@ -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 {
@@ -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();
}
}
+1
View File
@@ -9,4 +9,5 @@ export interface RailNetwork {
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
stationManager(): StationManager;
overlappingRailroads(tile: TileRef): number[];
recomputeClusters(): void;
}
+33 -14
View File
@@ -86,6 +86,7 @@ export class RailNetworkImpl implements RailNetwork {
private gridCellSize: number = 4;
private railGrid: RailSpatialGrid;
private nextId: number = 0;
private dirtyClusters = new Set<Cluster>();
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) {
+5 -1
View File
@@ -53,7 +53,7 @@ export class TrainStation {
id: number = -1; // assigned by StationManager
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
{};
private cluster: Cluster | null;
private cluster: Cluster | null = null;
private railroads: Set<Railroad> = new Set();
// Quick lookup from neighboring station to connecting railroad
private railroadByNeighbor: Map<TrainStation, Railroad> = 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;
}