Files
OpenFrontIO/tests/core/game/RailNetwork.test.ts
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

169 lines
5.2 KiB
TypeScript

import { Unit } from "../../../src/core/game/Game";
import {
RailNetworkImpl,
StationManagerImpl,
} from "../../../src/core/game/RailNetworkImpl";
import { Railroad } from "../../../src/core/game/Railroad";
import { Cluster } from "../../../src/core/game/TrainStation";
// Mock types
const createMockStation = (unitId: number): any => {
const cluster = new Cluster();
const railroads = new Set<Railroad>();
return {
unit: {
id: unitId,
setTrainStation: vi.fn(),
},
tile: vi.fn(),
neighbors: vi.fn(() => []),
getCluster: vi.fn(() => cluster),
setCluster: vi.fn(),
addRailroad: vi.fn(),
getRailroads: vi.fn(() => railroads),
clearRailroads: vi.fn(),
};
};
describe("StationManagerImpl", () => {
let manager: StationManagerImpl;
beforeEach(() => {
manager = new StationManagerImpl();
});
test("adds and retrieves station", () => {
const station = createMockStation(1);
manager.addStation(station);
expect(manager.findStation(station.unit)).toBe(station);
});
test("removes station", () => {
const station = createMockStation(1);
manager.addStation(station);
manager.removeStation(station);
expect(manager.findStation(station.unit)).toBe(null);
});
});
describe("RailNetworkImpl", () => {
let network: RailNetworkImpl;
let stationManager: any;
let pathService: any;
let game: any;
beforeEach(() => {
stationManager = {
addStation: vi.fn(),
removeStation: vi.fn(),
findStation: vi.fn(),
getAll: vi.fn(() => new Set()),
};
pathService = {
findTilePath: vi.fn(() => [0]),
findStationsPath: vi.fn(() => [0]),
};
game = {
nearbyUnits: vi.fn(() => []),
addExecution: vi.fn(),
config: () => ({
trainStationMaxRange: () => 80,
trainStationMinRange: () => 10,
railroadMaxSize: () => 100,
}),
x: vi.fn(() => 0),
y: vi.fn(() => 0),
};
network = new RailNetworkImpl(game, stationManager, pathService);
});
test("does not connect if path is empty or too long", () => {
const stationA = createMockStation(1);
const stationB = createMockStation(2);
game.nearbyUnits.mockReturnValue([stationB]);
pathService.findTilePath.mockReturnValue([]);
network.connectStation(stationA);
const cluster = stationB.getCluster();
cluster.addStation = vi.fn();
expect(cluster.addStation).not.toHaveBeenCalled();
pathService.findTilePath.mockReturnValue(new Array(200));
network.connectStation(stationA);
expect(cluster.addStation).not.toHaveBeenCalled();
});
test("removeStation removes all neighbor links", () => {
const neighbor = { removeNeighboringRails: vi.fn() };
const station = createMockStation(1);
station.neighbors = vi.fn(() => [neighbor]);
stationManager.findStation.mockReturnValue(station);
network.removeStation(station);
expect(station.clearRailroads).toHaveBeenCalled();
});
test("connectStation calls addStation and connects to nearby", () => {
const station = createMockStation(1);
network.connectStation(station);
expect(stationManager.addStation).toHaveBeenCalledWith(station);
});
test("removeStation does nothing if station not found", () => {
stationManager.findStation.mockReturnValue(null);
network.removeStation({ id: 1 } as unknown as Unit);
expect(stationManager.removeStation).not.toHaveBeenCalled();
});
test("removeStation disconnects and removes from cluster if one neighbor", () => {
const cluster = new Cluster();
const neighbor = createMockStation(1);
const station = createMockStation(2);
station.getCluster = vi.fn(() => cluster);
station.neighbors = vi.fn(() => [neighbor]);
cluster.removeStation = vi.fn();
stationManager.findStation.mockReturnValue(station);
network.removeStation(station.unit);
expect(cluster.removeStation).toHaveBeenCalledWith(station);
expect(stationManager.removeStation).toHaveBeenCalledWith(station);
});
test("findStationsPath", () => {
const stationA = createMockStation(1);
const stationB = createMockStation(2);
const result = network.findStationsPath(stationA, stationB);
expect(result).toEqual([0]);
});
test("connectToNearbyStations creates new cluster when no neighbors", () => {
const station = createMockStation(1);
game.nearbyUnits.mockReturnValue([]);
network.connectStation(station);
expect(stationManager.addStation).toHaveBeenCalledWith(station);
expect(station.setCluster).toHaveBeenCalled();
});
test("connectToNearbyStations connects and merges clusters", () => {
const station = createMockStation(1);
const neighborStation = createMockStation(2);
const cluster = new Cluster();
cluster.addStation(neighborStation);
neighborStation.getCluster = vi.fn(() => cluster);
cluster.has = vi.fn(() => false);
const neighborUnit = { unit: neighborStation.unit, distSquared: 20 };
game.nearbyUnits.mockReturnValue([neighborUnit]);
stationManager.findStation.mockReturnValue(neighborStation);
network.connectStation(station);
// Both station should have their cluster reset to the merged one
expect(station.setCluster).toHaveBeenCalled();
expect(neighborStation.setCluster).toHaveBeenCalled();
});
});