Files
OpenFrontIO/src/core/game/RailNetworkImpl.ts
T
DevelopingTom 9415162f51 Split railroads when placing overlapping structures (#3003)
## Description:
Players wrongly assume that building a structure over an existing
railroad will connect it properly. What actually happens is that the
structure will connect on the network with its own railroad, even if the
new railroads are overlapping over the existing network.

To address this issue, this PR splits the overlapping railroad into two
segments when a structure is built over it, and inserts the structure as
a new node in the rail graph. It does not alter the rail network
visually because the same railroad tiles are used for the new segments.

Railroad tiles are not stored directly in the map, they exist only as
edges in the rail graph, so looking for nearby rails would be terribly
inefficient. To address that, this PR introduces a new `RailSpatialGrid`
class which indexes rails on a 4×4 grid, allowing fast spatial queries.

Alternative considered: removing overlapping rails and rebuilding them
from the new structure. It would visually modify the rail network, which
may be unexpected for the player.

It's still missing a visual indicator so the player knows that the
structures has been connected properly.

### Line placement:


![snap_line](https://github.com/user-attachments/assets/f24ddd36-1594-4316-91ff-093a5cebd576)

### Multi-railroad overlap:


![snap_cross](https://github.com/user-attachments/assets/b2cc962e-6dce-4444-b689-7e04a09de603)


## 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-01-22 19:19:51 -08:00

324 lines
9.6 KiB
TypeScript

import { RailroadExecution } from "../execution/RailroadExecution";
import { PathFinding } from "../pathfinding/PathFinder";
import { Game, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { RailNetwork } from "./RailNetwork";
import { Railroad } from "./Railroad";
import { RailSpatialGrid } from "./RailroadSpatialGrid";
import { Cluster, TrainStation } from "./TrainStation";
/**
* The Stations handle their own neighbors so the graph is naturally traversable,
* but it would be expensive to look through the graph to find a station.
* This class stores the existing stations for quick access
*/
export interface StationManager {
addStation(station: TrainStation): void;
removeStation(station: TrainStation): void;
findStation(unit: Unit): TrainStation | null;
getAll(): Set<TrainStation>;
getById(id: number): TrainStation | undefined;
count(): number;
}
export class StationManagerImpl implements StationManager {
private stations: Set<TrainStation> = new Set();
private stationsById: (TrainStation | undefined)[] = [];
private nextId = 1; // Start from 1; 0 is reserved as invalid/sentinel
addStation(station: TrainStation) {
station.id = this.nextId++;
this.stationsById[station.id] = station;
this.stations.add(station);
}
removeStation(station: TrainStation) {
this.stationsById[station.id] = undefined;
this.stations.delete(station);
}
findStation(unit: Unit): TrainStation | null {
for (const station of this.stations) {
if (station.unit === unit) return station;
}
return null;
}
getAll(): Set<TrainStation> {
return this.stations;
}
getById(id: number): TrainStation | undefined {
return this.stationsById[id];
}
count(): number {
return this.nextId;
}
}
export interface RailPathFinderService {
findTilePath(from: TileRef, to: TileRef): TileRef[];
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
}
class RailPathFinderServiceImpl implements RailPathFinderService {
constructor(private game: Game) {}
findTilePath(from: TileRef, to: TileRef): TileRef[] {
return PathFinding.Rail(this.game).findPath(from, to) ?? [];
}
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
return PathFinding.Stations(this.game).findPath(from, to) ?? [];
}
}
export function createRailNetwork(game: Game): RailNetwork {
const stationManager = new StationManagerImpl();
const pathService = new RailPathFinderServiceImpl(game);
return new RailNetworkImpl(game, stationManager, pathService);
}
export class RailNetworkImpl implements RailNetwork {
private maxConnectionDistance: number = 4;
private stationRadius: number = 3;
private gridCellSize: number = 4;
private railGrid: RailSpatialGrid;
constructor(
private game: Game,
private _stationManager: StationManager,
private pathService: RailPathFinderService,
) {
this.railGrid = new RailSpatialGrid(game, this.gridCellSize); // 4x4 tiles spatial grid
}
stationManager(): StationManager {
return this._stationManager;
}
connectStation(station: TrainStation) {
this._stationManager.addStation(station);
if (!this.connectToExistingRails(station)) {
this.connectToNearbyStations(station);
}
}
removeStation(unit: Unit): void {
const station = this._stationManager.findStation(unit);
if (!station) return;
const neighbors = station.neighbors();
this.disconnectFromNetwork(station);
this._stationManager.removeStation(station);
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);
}
}
station.unit.setTrainStation(false);
}
/**
* Return the intermediary stations connecting two stations
*/
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
return this.pathService.findStationsPath(from, to);
}
private connectToExistingRails(station: TrainStation): boolean {
const rails = this.railGrid.query(station.tile(), this.stationRadius);
const editedClusters = new Set<Cluster>();
for (const rail of rails) {
const from = rail.from;
const to = rail.to;
const closestRailIndex = rail.getClosestTileIndex(
this.game,
station.tile(),
);
if (closestRailIndex === 0 || closestRailIndex >= rail.tiles.length) {
continue;
}
// Disconnect current rail as it will become invalid
from.removeRailroad(rail);
to.removeRailroad(rail);
this.railGrid.unregister(rail);
const newRailFrom = new Railroad(
from,
station,
rail.tiles.slice(0, closestRailIndex),
);
const newRailTo = new Railroad(
station,
to,
rail.tiles.slice(closestRailIndex),
);
// New station is connected to both new rails
station.addRailroad(newRailFrom);
station.addRailroad(newRailTo);
// From and to are connected to the new segments
from.addRailroad(newRailFrom);
to.addRailroad(newRailTo);
this.railGrid.register(newRailTo);
this.railGrid.register(newRailFrom);
const cluster = from.getCluster();
if (cluster) {
cluster.addStation(station);
editedClusters.add(cluster);
}
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
this.mergeClusters(editedClusters);
}
return editedClusters.size !== 0;
}
private connectToNearbyStations(station: TrainStation) {
const neighbors = this.game.nearbyUnits(
station.tile(),
this.game.config().trainStationMaxRange(),
[UnitType.City, UnitType.Factory, UnitType.Port],
);
const editedClusters = new Set<Cluster>();
neighbors.sort((a, b) => a.distSquared - b.distSquared);
for (const neighbor of neighbors) {
if (neighbor.unit === station.unit) continue;
const neighborStation = this._stationManager.findStation(neighbor.unit);
if (!neighborStation) continue;
const distanceToStation = this.distanceFrom(
neighborStation,
station,
this.maxConnectionDistance,
);
const neighborCluster = neighborStation.getCluster();
if (neighborCluster === null) continue;
const connectionAvailable =
distanceToStation > this.maxConnectionDistance ||
distanceToStation === -1;
if (
connectionAvailable &&
neighbor.distSquared > this.game.config().trainStationMinRange() ** 2
) {
if (this.connect(station, neighborStation)) {
neighborCluster.addStation(station);
editedClusters.add(neighborCluster);
}
}
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
this.mergeClusters(editedClusters);
} else if (editedClusters.size === 0) {
// If no cluster owns the station, creates a new one for it
const newCluster = new Cluster();
newCluster.addStation(station);
}
}
private disconnectFromNetwork(station: TrainStation) {
for (const rail of station.getRailroads()) {
rail.delete(this.game);
this.railGrid.unregister(rail);
}
station.clearRailroads();
const cluster = station.getCluster();
if (cluster !== null && cluster.size() === 1) {
this.deleteCluster(cluster);
}
}
private deleteCluster(cluster: Cluster) {
for (const station of cluster.stations) {
station.setCluster(null);
}
cluster.clear();
}
private connect(from: TrainStation, to: TrainStation) {
const path = this.pathService.findTilePath(from.tile(), to.tile());
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
const railRoad = new Railroad(from, to, path);
this.game.addExecution(new RailroadExecution(railRoad));
from.addRailroad(railRoad);
to.addRailroad(railRoad);
this.railGrid.register(railRoad);
return true;
}
return false;
}
private distanceFrom(
start: TrainStation,
dest: TrainStation,
maxDistance: number,
): number {
if (start === dest) return 0;
const visited = new Set<TrainStation>();
const queue: Array<{ station: TrainStation; distance: number }> = [
{ station: start, distance: 0 },
];
while (queue.length > 0) {
const { station, distance } = queue.shift()!;
if (visited.has(station)) continue;
visited.add(station);
if (distance >= maxDistance) continue;
for (const neighbor of station.neighbors()) {
if (neighbor === dest) return distance + 1;
if (!visited.has(neighbor)) {
queue.push({ station: neighbor, distance: distance + 1 });
}
}
}
// If destination not found within maxDistance
return -1;
}
private computeCluster(start: TrainStation): Set<TrainStation> {
const visited = new Set<TrainStation>();
const queue = [start];
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
for (const neighbor of current.neighbors()) {
if (!visited.has(neighbor)) queue.push(neighbor);
}
}
return visited;
}
private mergeClusters(clustersToMerge: Set<Cluster>) {
const merged = new Cluster();
for (const cluster of clustersToMerge) {
merged.merge(cluster);
}
}
}