From 769da272571d871170684012aad7ba4bf18a9298 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 14 Jun 2026 12:52:17 -0700 Subject: [PATCH] Fix railroad glowing green for non-snapping structures (#4281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When placing a building near a railroad, the railroad glows green to show the building would snap to it. This should only apply to **City**, **Port**, and **Factory** — but missile silos, SAMs, and defense posts (which cannot be placed on railroads) were also triggering the green highlight. ## Root cause The core's `overlappingRailroads()` populated snap tiles for *every* buildable type. In v31 the green highlight didn't leak because the client renderer (`RailroadLayer.ts`) gated it with a `SNAPPABLE_STRUCTURES = [Port, City, Factory]` allowlist: ```ts if (!SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure)) return; ``` That guard was lost when the rendering was rewritten into the WebGL `RailroadPass`, which now unconditionally highlights every tile in `overlappingRailroads`. The data was always there; only the renderer's filter was protecting it. ## Fix Filter by unit type inside `overlappingRailroads()`, mirroring the existing guard in `computeGhostRailPaths()`. This keeps the snap-eligible type list defined once in the core (`RailNetworkImpl`) and fixes the leak regardless of which renderer consumes the data — rather than re-adding a client-side allowlist a future rewrite could drop again. ## Tests Updated `tests/core/game/RailNetwork.test.ts` for the new signature and added a case asserting `MissileSilo`/`DefensePost`/`SAMLauncher` return `[]` (and don't even query the rail grid). All 23 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- src/core/game/PlayerImpl.ts | 2 +- src/core/game/RailNetwork.ts | 2 +- src/core/game/RailNetworkImpl.ts | 5 ++++- tests/core/game/RailNetwork.test.ts | 24 ++++++++++++++++++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 7d762efc2..e2d3d14ee 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1317,7 +1317,7 @@ export class PlayerImpl implements Player { canUpgrade, cost, overlappingRailroads: buildNew - ? rail.overlappingRailroads(canBuild as TileRef) + ? rail.overlappingRailroads(u, canBuild as TileRef) : [], ghostRailPaths: buildNew ? rail.computeGhostRailPaths(u, canBuild as TileRef) diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 420e59c29..c86921218 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -8,7 +8,7 @@ export interface RailNetwork { removeStation(unit: Unit): void; findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; - overlappingRailroads(tile: TileRef): TileRef[]; + overlappingRailroads(unitType: UnitType, tile: TileRef): TileRef[]; computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][]; recomputeClusters(): void; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 9144734a7..e955790d2 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -223,7 +223,10 @@ export class RailNetworkImpl implements RailNetwork { return editedClusters.size !== 0; } - overlappingRailroads(tile: TileRef): TileRef[] { + overlappingRailroads(unitType: UnitType, tile: TileRef): TileRef[] { + if (![UnitType.City, UnitType.Port, UnitType.Factory].includes(unitType)) { + return []; + } const tiles = new Set(); for (const railroad of this.railGrid.query(tile, this.stationRadius)) { for (const t of railroad.tiles) { diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index e63fadef2..f0ed13fc3 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -178,7 +178,7 @@ describe("RailNetworkImpl", () => { }; (network as any).railGrid = railGridMock; - const result = network.overlappingRailroads(tile); + const result = network.overlappingRailroads(UnitType.City, tile); expect(railGridMock.query).toHaveBeenCalledWith(tile, 3); expect(result).toEqual([42, 45, 50, 60]); // Deduplicated and sorted @@ -189,10 +189,30 @@ describe("RailNetworkImpl", () => { const railGridMock = { query: vi.fn(() => new Set()) }; (network as any).railGrid = railGridMock; - const result = network.overlappingRailroads(tile); + 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", () => {