Show rail ghost for initial factory 🚂 (#4294)

## 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>
This commit is contained in:
Evan
2026-06-17 08:22:01 -07:00
committed by GitHub
parent 678112492c
commit 83cd864018
2 changed files with 64 additions and 14 deletions
+33 -14
View File
@@ -253,8 +253,15 @@ export class RailNetworkImpl implements RailNetwork {
const minRangeSquared = this.game.config().trainStationMinRange() ** 2;
const maxPathSize = this.game.config().railroadMaxSize();
// Cannot connect if outside the max range of a factory
if (!this.game.hasUnitNearby(tile, maxRange, UnitType.Factory)) {
// A City or Port only joins the rail network when a Factory is already in
// range (see CityExecution/PortExecution). A Factory always becomes a
// station and pulls nearby City/Port/Factory into the network itself, so
// it needs no pre-existing factory to connect to.
const buildingFactory = unitType === UnitType.Factory;
if (
!buildingFactory &&
!this.game.hasUnitNearby(tile, maxRange, UnitType.Factory)
) {
return [];
}
@@ -273,22 +280,34 @@ export class RailNetworkImpl implements RailNetwork {
if (neighbor.distSquared <= minRangeSquared) continue;
const neighborStation = this._stationManager.findStation(neighbor.unit);
if (!neighborStation) continue;
const alreadyReachable = connectedStations.some(
(s) =>
this.distanceFrom(
neighborStation,
s,
this.maxConnectionDistance - 1,
) !== -1,
);
if (alreadyReachable) continue;
// Building a factory connects to nearby structures even if they aren't
// stations yet — they get promoted to stations when the factory is
// built. For a city/port, only existing stations are relevant.
let targetTile: TileRef;
if (neighborStation) {
const alreadyReachable = connectedStations.some(
(s) =>
this.distanceFrom(
neighborStation,
s,
this.maxConnectionDistance - 1,
) !== -1,
);
if (alreadyReachable) continue;
targetTile = neighborStation.tile();
} else if (buildingFactory) {
targetTile = neighbor.unit.tile();
} else {
continue;
}
const path = this.pathService.findTilePath(tile, neighborStation.tile());
const path = this.pathService.findTilePath(tile, targetTile);
if (path.length > 0 && path.length < maxPathSize) {
paths.push(path);
connectedStations.push(neighborStation);
if (neighborStation) {
connectedStations.push(neighborStation);
}
}
}
+31
View File
@@ -376,5 +376,36 @@ describe("RailNetworkImpl", () => {
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([]);
});
});
});