mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 23:31:55 +00:00
ba2a947061
## Description: Based on [this suggestion on Discord](https://discord.com/channels/1284581928254701718/1447110257196138577) and feedback gathered in [this thread](https://discord.com/channels/1359946986937258015/1469598906173227184). Supersedes #3143 This PR introduces "ghost railways": when you are going to place a city or port, previews railway connections that will be made when actually building the structure. Ghost railways are skipped if the structure is going to be snapped to existing railways (as in railway snapping functionality introduced in #3156 ). ### Video https://github.com/user-attachments/assets/ff8cf325-6501-4df8-801d-c8ae3ced3d0e ### Ghost rails color revisited black with 40% opacity <img width="695" height="430" alt="image" src="https://github.com/user-attachments/assets/272efbcc-4185-426a-921c-7fae61f6c462" /> ## 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: deshack_82603
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { Unit, UnitType } 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(),
|
|
type: vi.fn(() => UnitType.City),
|
|
},
|
|
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 = {
|
|
hasUnitNearby: vi.fn(() => true),
|
|
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();
|
|
});
|
|
|
|
describe("computeGhostRailPaths", () => {
|
|
test("returns empty when snappable rails exist nearby", () => {
|
|
const tile = 42 as any;
|
|
// Accessing private railGrid via any to set up mock
|
|
const railGridMock = { query: vi.fn(() => new Set([{}])) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result).toEqual([]);
|
|
expect(railGridMock.query).toHaveBeenCalledWith(tile, 3);
|
|
});
|
|
|
|
test("returns empty when no nearby stations found", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
game.nearbyUnits.mockReturnValue([]);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
test("returns paths to nearby stations within range", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const neighborStation = createMockStation(1);
|
|
neighborStation.tile.mockReturnValue(100);
|
|
stationManager.findStation.mockReturnValue(neighborStation);
|
|
|
|
const mockPath = [42, 50, 60, 100];
|
|
pathService.findTilePath.mockReturnValue(mockPath);
|
|
|
|
game.nearbyUnits.mockReturnValue([
|
|
{ unit: neighborStation.unit, distSquared: 400 },
|
|
]);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result).toEqual([mockPath]);
|
|
expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100);
|
|
});
|
|
|
|
test("skips neighbors within min range", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const neighborStation = createMockStation(1);
|
|
neighborStation.tile.mockReturnValue(43);
|
|
stationManager.findStation.mockReturnValue(neighborStation);
|
|
|
|
// distSquared = 50 <= minRange^2 (10^2 = 100)
|
|
game.nearbyUnits.mockReturnValue([
|
|
{ unit: neighborStation.unit, distSquared: 50 },
|
|
]);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
test("skips neighbors without train stations", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
stationManager.findStation.mockReturnValue(null);
|
|
|
|
game.nearbyUnits.mockReturnValue([{ unit: { id: 1 }, distSquared: 400 }]);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
test("skips paths that exceed max railroad size", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const neighborStation = createMockStation(1);
|
|
neighborStation.tile.mockReturnValue(100);
|
|
stationManager.findStation.mockReturnValue(neighborStation);
|
|
|
|
// Path length >= railroadMaxSize (100)
|
|
pathService.findTilePath.mockReturnValue(new Array(100));
|
|
|
|
game.nearbyUnits.mockReturnValue([
|
|
{ unit: neighborStation.unit, distSquared: 400 },
|
|
]);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
test("limits to at most 5 paths", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const neighbors: Array<{ unit: any; distSquared: number }> = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
const station = createMockStation(i);
|
|
station.tile.mockReturnValue(100 + i);
|
|
neighbors.push({ unit: station.unit, distSquared: 400 + i });
|
|
}
|
|
|
|
stationManager.findStation.mockImplementation((unit: any) => {
|
|
const station = createMockStation(unit.id);
|
|
station.tile.mockReturnValue(100 + unit.id);
|
|
return station;
|
|
});
|
|
|
|
pathService.findTilePath.mockImplementation((_from: any, to: any) => [
|
|
_from,
|
|
to,
|
|
]);
|
|
|
|
game.nearbyUnits.mockReturnValue(neighbors);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result.length).toBe(5);
|
|
});
|
|
|
|
test("skips stations reachable through already-connected stations", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
// Create two neighbor stations where B is reachable from A
|
|
const stationA = createMockStation(1);
|
|
stationA.tile.mockReturnValue(100);
|
|
const stationB = createMockStation(2);
|
|
stationB.tile.mockReturnValue(200);
|
|
|
|
// Make A and B neighbors of each other (1 hop apart)
|
|
stationA.neighbors.mockReturnValue([stationB]);
|
|
stationB.neighbors.mockReturnValue([stationA]);
|
|
|
|
stationManager.findStation.mockImplementation((unit: any) => {
|
|
if (unit.id === 1) return stationA;
|
|
if (unit.id === 2) return stationB;
|
|
return null;
|
|
});
|
|
|
|
pathService.findTilePath.mockImplementation((_from: any, to: any) => [
|
|
_from,
|
|
to,
|
|
]);
|
|
|
|
// Station A is closer, station B is farther
|
|
game.nearbyUnits.mockReturnValue([
|
|
{ unit: stationA.unit, distSquared: 400 },
|
|
{ unit: stationB.unit, distSquared: 900 },
|
|
]);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
// Only station A should get a path; B is reachable from A within maxConnectionDistance - 1
|
|
expect(result.length).toBe(1);
|
|
expect(pathService.findTilePath).toHaveBeenCalledTimes(1);
|
|
expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100);
|
|
});
|
|
});
|
|
});
|