Files
OpenFrontIO/src/core/game/TrainStation.ts
T
DevelopingTom a1b3afe534 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
2026-02-12 15:00:56 -08:00

257 lines
6.3 KiB
TypeScript

import { TrainExecution } from "../execution/TrainExecution";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType } from "./GameUpdates";
import { Railroad } from "./Railroad";
/**
* Handle train stops at various station types
*/
interface TrainStopHandler {
onStop(mg: Game, station: TrainStation, trainExecution: TrainExecution): void;
}
class TradeStationStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
const gold = mg.config().trainGold(rel(trainOwner, stationOwner));
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
stationOwner.addGold(gold, station.tile());
mg.stats().trainExternalTrade(trainOwner, gold);
}
trainOwner.addGold(gold, station.tile());
mg.stats().trainSelfTrade(trainOwner, gold);
}
}
class FactoryStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {}
}
export function createTrainStopHandlers(
random: PseudoRandom,
): Partial<Record<UnitType, TrainStopHandler>> {
return {
[UnitType.City]: new TradeStationStopHandler(),
[UnitType.Port]: new TradeStationStopHandler(),
[UnitType.Factory]: new FactoryStopHandler(),
};
}
export class TrainStation {
id: number = -1; // assigned by StationManager
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
{};
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();
constructor(
private mg: Game,
public unit: Unit,
) {
this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks()));
}
tradeAvailable(otherPlayer: Player): boolean {
const player = this.unit.owner();
return otherPlayer === player || player.canTrade(otherPlayer);
}
clearRailroads() {
this.railroads.clear();
this.railroadByNeighbor.clear();
}
addRailroad(railRoad: Railroad) {
this.railroads.add(railRoad);
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
this.railroadByNeighbor.set(neighbor, railRoad);
}
removeRailroad(railRoad: Railroad) {
this.railroads.delete(railRoad);
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
this.railroadByNeighbor.delete(neighbor);
}
removeNeighboringRails(station: TrainStation) {
const toRemove = [...this.railroads].find(
(r) => r.from === station || r.to === station,
);
if (toRemove) {
this.mg.addUpdate({
type: GameUpdateType.RailroadDestructionEvent,
id: toRemove.id,
});
this.removeRailroad(toRemove);
}
}
neighbors(): TrainStation[] {
const neighbors: TrainStation[] = [];
for (const r of this.railroads) {
if (r.from !== this) {
neighbors.push(r.from);
} else {
neighbors.push(r.to);
}
}
return neighbors;
}
tile(): TileRef {
return this.unit.tile();
}
isActive(): boolean {
return this.unit.isActive();
}
getRailroads(): Set<Railroad> {
return this.railroads;
}
getRailroadTo(station: TrainStation): Railroad | null {
return this.railroadByNeighbor.get(station) ?? null;
}
setCluster(cluster: Cluster | null) {
// Properly disconnect cluster if it's already set
if (this.cluster !== null) {
this.cluster.removeStation(this);
}
this.cluster = cluster;
}
getCluster(): Cluster | null {
return this.cluster;
}
onTrainStop(trainExecution: TrainExecution) {
const type = this.unit.type();
const handler = this.stopHandlers[type];
if (handler) {
handler.onStop(this.mg, this, trainExecution);
}
}
}
/**
* Cluster of connected stations
*/
export class Cluster {
public stations: Set<TrainStation> = new Set();
private tradeStations: Set<TrainStation> = new Set();
private isTradeStation(station: TrainStation): boolean {
const type = station.unit.type();
return type === UnitType.City || type === UnitType.Port;
}
has(station: TrainStation) {
return this.stations.has(station);
}
addStation(station: TrainStation) {
this.stations.add(station);
if (this.isTradeStation(station)) {
this.tradeStations.add(station);
}
station.setCluster(this);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
this.tradeStations.delete(station);
}
addStations(stations: Set<TrainStation>) {
for (const station of stations) {
this.addStation(station);
}
}
merge(other: Cluster) {
for (const s of other.stations) {
this.addStation(s);
}
}
hasAnyTradeDestination(player: Player): boolean {
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
return true;
}
}
return false;
}
randomTradeDestination(
player: Player,
random: PseudoRandom,
): TrainStation | null {
let selected: TrainStation | null = null;
let eligibleSeen = 0;
for (const station of this.tradeStations) {
if (!station.tradeAvailable(player)) continue;
eligibleSeen++;
// Reservoir sampling: keep each eligible station with probability 1/eligibleSeen.
if (random.nextInt(0, eligibleSeen) === 0) {
selected = station;
}
}
return selected;
}
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
return tradingStations;
}
size() {
return this.stations.size;
}
clear() {
this.stations.clear();
this.tradeStations.clear();
}
}
function rel(
player: Player,
other: Player,
): "self" | "team" | "ally" | "other" {
if (player === other) {
return "self";
}
if (player.isOnSameTeam(other)) {
return "team";
}
if (player.isAlliedWith(other)) {
return "ally";
}
return "other";
}