From 83cd86401855b36c18c5c9171ddc7cdb9b5c7ca1 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 17 Jun 2026 08:22:01 -0700 Subject: [PATCH] =?UTF-8?q?Show=20rail=20ghost=20for=20initial=20factory?= =?UTF-8?q?=20=F0=9F=9A=82=20(#4294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/core/game/RailNetworkImpl.ts | 47 ++++++++++++++++++++--------- tests/core/game/RailNetwork.test.ts | 31 +++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index e955790d2..5b94bc45d 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -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); + } } } diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index f0ed13fc3..c11a5b5a9 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -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([]); + }); }); });