mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 17:36:44 +00:00
83cd864018
## Problem Fixes #4284. When you build a factory in an area with **no pre-existing factory** (e.g. just a city nearby), no rail ghost preview appeared — even though building the factory *would* lay rail lines connecting it to that city. ## Root cause `computeGhostRailPaths` in `RailNetworkImpl.ts` had two factory-hostile assumptions: 1. It bailed out early unless a `Factory` was already in range (`hasUnitNearby(..., UnitType.Factory)`). 2. It only matched neighbors that were *already* train stations (`findStation(...)` → skipped if null). But a **Factory** always becomes a station itself and *promotes* nearby City/Port/Factory into the rail network (see `FactoryExecution`). So it needs no pre-existing factory, and its neighbors won't be stations yet on first build. A **City/Port** only joins the network when a factory already exists (`CityExecution`/`PortExecution`) — so their behavior is correctly left unchanged. ## Fix - Skip the "factory must be nearby" gate when the placed unit is itself a `Factory`. - For a factory build, pathfind to nearby City/Port/Factory even if they aren't stations yet. City/Port keep connecting only to existing stations. ## Tests Added two cases to `RailNetwork.test.ts` (factory connects with no pre-existing factory; city still doesn't without one). All 25 tests pass. ## Note on scope As @Katokoda noted on the issue, a fully build-exact preview (neighboring structures also connecting to *each other*, merging existing networks, etc.) is larger and order-dependent. This PR resolves the reported bug — the initial factory now shows its rail ghost — and leaves the exact-match cascade as a separate follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
412 lines
14 KiB
TypeScript
412 lines
14 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("overlappingRailroads", () => {
|
|
test("returns deterministic deduplicated TileRef array", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = {
|
|
query: vi.fn(
|
|
() => new Set([{ tiles: [50, 42, 60] }, { tiles: [60, 45, 42] }]),
|
|
),
|
|
};
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const result = network.overlappingRailroads(UnitType.City, tile);
|
|
|
|
expect(railGridMock.query).toHaveBeenCalledWith(tile, 3);
|
|
expect(result).toEqual([42, 45, 50, 60]); // Deduplicated and sorted
|
|
});
|
|
|
|
test("returns empty array when no railroads overlap", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const result = network.overlappingRailroads(UnitType.City, tile);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
test.each([
|
|
UnitType.MissileSilo,
|
|
UnitType.DefensePost,
|
|
UnitType.SAMLauncher,
|
|
])(
|
|
"returns empty array for %s which cannot snap to railroads",
|
|
(unitType) => {
|
|
const tile = 42 as any;
|
|
const railGridMock = {
|
|
query: vi.fn(() => new Set([{ tiles: [50, 42, 60] }])),
|
|
};
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
const result = network.overlappingRailroads(unitType, tile);
|
|
|
|
expect(result).toEqual([]);
|
|
expect(railGridMock.query).not.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);
|
|
});
|
|
|
|
test("factory connects to nearby structures with no pre-existing factory", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
// No factory in range, and the nearby city is not a station yet.
|
|
game.hasUnitNearby.mockReturnValue(false);
|
|
stationManager.findStation.mockReturnValue(null);
|
|
|
|
const cityUnit = { id: 1, tile: vi.fn(() => 100) };
|
|
game.nearbyUnits.mockReturnValue([{ unit: cityUnit, distSquared: 400 }]);
|
|
|
|
const mockPath = [42, 50, 60, 100];
|
|
pathService.findTilePath.mockReturnValue(mockPath);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.Factory, tile);
|
|
expect(result).toEqual([mockPath]);
|
|
expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100);
|
|
});
|
|
|
|
test("city does not connect to non-station neighbors without a factory", () => {
|
|
const tile = 42 as any;
|
|
const railGridMock = { query: vi.fn(() => new Set()) };
|
|
(network as any).railGrid = railGridMock;
|
|
|
|
game.hasUnitNearby.mockReturnValue(false);
|
|
|
|
const result = network.computeGhostRailPaths(UnitType.City, tile);
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
});
|