From 653c9fb76bf4a4c25fa0419221a7b13b460025c8 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Fri, 13 Feb 2026 19:16:09 +0100 Subject: [PATCH 1/6] Feat: Display ghost railways when building cities and ports --- src/client/graphics/GameRenderer.ts | 6 +- src/client/graphics/UIState.ts | 2 + src/client/graphics/layers/RailroadLayer.ts | 75 ++++++-- .../graphics/layers/StructureIconsLayer.ts | 5 + src/core/game/Game.ts | 1 + src/core/game/PlayerImpl.ts | 14 ++ src/core/game/RailNetwork.ts | 1 + src/core/game/RailNetworkImpl.ts | 46 +++++ tests/InputHandler.test.ts | 1 + tests/core/game/RailNetwork.test.ts | 163 ++++++++++++++++++ 10 files changed, 298 insertions(+), 16 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 0de490f3f8..bd7408b352 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -54,11 +54,13 @@ export function createRenderer( const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); - const uiState = { + const uiState: UIState = { attackRatio: 20, ghostStructure: null, + overlappingRailroads: [], + ghostRailPaths: [], rocketDirectionUp: true, - } as UIState; + }; //hide when the game renders const startingModal = document.querySelector( diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index f10094e0ac..277c91d424 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -1,8 +1,10 @@ import { UnitType } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; export interface UIState { attackRatio: number; ghostStructure: UnitType | null; overlappingRailroads: number[]; + ghostRailPaths: TileRef[][]; rocketDirectionUp: boolean; } diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index f0c2f073db..e7368fa69b 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -199,9 +199,6 @@ export class RailroadLayer implements Layer { if (scale <= 1) { return; } - if (this.existingRailroads.size === 0) { - return; - } this.updateRailColors(); const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1 const alpha = Math.max(0, Math.min(1, rawAlpha)); @@ -229,20 +226,70 @@ export class RailroadLayer implements Layer { context.save(); context.globalAlpha = alpha; this.highlightOverlappingRailroads(context); - context.drawImage( - this.canvas, - srcX, - srcY, - srcW, - srcH, - dstX, - dstY, - visWidth, - visHeight, - ); + this.renderGhostRailroads(context); + if (this.existingRailroads.size > 0) { + context.drawImage( + this.canvas, + srcX, + srcY, + srcW, + srcH, + dstX, + dstY, + visWidth, + visHeight, + ); + } context.restore(); } + private renderGhostRailroads(context: CanvasRenderingContext2D) { + if ( + this.uiState.ghostStructure !== UnitType.City && + this.uiState.ghostStructure !== UnitType.Port + ) + return; + if (this.uiState.ghostRailPaths.length === 0) return; + + const offsetX = -this.game.width() / 2; + const offsetY = -this.game.height() / 2; + context.fillStyle = "rgba(255, 255, 255, 0.6)"; + + for (const path of this.uiState.ghostRailPaths) { + const railTiles = computeRailTiles(this.game, path); + for (const railTile of railTiles) { + const x = this.game.x(railTile.tile); + const y = this.game.y(railTile.tile); + + if (this.game.isWater(railTile.tile)) { + context.save(); + context.fillStyle = "rgba(197, 69, 72, 0.6)"; + const bridgeRects = getBridgeRects(railTile.type); + for (const [dx, dy, w, h] of bridgeRects) { + context.fillRect( + x + offsetX + dx / 2, + y + offsetY + dy / 2, + w / 2, + h / 2, + ); + } + context.restore(); + context.fillStyle = "rgba(255, 255, 255, 0.6)"; + } + + const railRects = getRailroadRects(railTile.type); + for (const [dx, dy, w, h] of railRects) { + context.fillRect( + x + offsetX + dx / 2, + y + offsetY + dy / 2, + w / 2, + h / 2, + ); + } + } + } + } + private onRailroadSnapEvent(update: RailroadSnapUpdate) { const original = this.railroads.get(update.originalId); if (!original) { diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 9cb3cd3aff..6323640c92 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -335,13 +335,16 @@ export class StructureIconsLayer implements Layer { } // No overlapping when a structure is upgradable this.uiState.overlappingRailroads = []; + this.uiState.ghostRailPaths = []; } else if (unit.canBuild === false) { this.ghostUnit.container.filters = [ new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), ]; this.uiState.overlappingRailroads = []; + this.uiState.ghostRailPaths = []; } else { this.uiState.overlappingRailroads = unit.overlappingRailroads; + this.uiState.ghostRailPaths = unit.ghostRailPaths; } const scale = this.transformHandler.scale; @@ -461,6 +464,7 @@ export class StructureIconsLayer implements Layer { canUpgrade: false, cost: 0n, overlappingRailroads: [], + ghostRailPaths: [], }, }; const showPrice = this.game.config().userSettings().cursorCostLabel(); @@ -480,6 +484,7 @@ export class StructureIconsLayer implements Layer { this.potentialUpgrade.dotContainer.filters = []; this.potentialUpgrade = undefined; } + this.uiState.ghostRailPaths = []; } private removeGhostStructure() { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d0a1d3f977..a991565feb 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -849,6 +849,7 @@ export interface BuildableUnit { type: UnitType; cost: Gold; overlappingRailroads: number[]; + ghostRailPaths: TileRef[][]; } export interface PlayerProfile { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index d00948d449..c39f2eff31 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -974,6 +974,19 @@ export class PlayerImpl implements Player { canBuild = this.canBuild(u, tile, validTiles); } } + let ghostRailPaths: TileRef[][] = []; + if ( + canBuild !== false && + (u === UnitType.City || u === UnitType.Port) && + this.mg.hasUnitNearby( + canBuild, + this.mg.config().trainStationMaxRange(), + UnitType.Factory, + ) + ) { + ghostRailPaths = this.mg.railNetwork().computeGhostRailPaths(canBuild); + } + return { type: u, canBuild, @@ -983,6 +996,7 @@ export class PlayerImpl implements Player { canBuild !== false ? this.mg.railNetwork().overlappingRailroads(canBuild) : [], + ghostRailPaths, } as BuildableUnit; }); } diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 7ad57c6106..f36007e21d 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -9,5 +9,6 @@ export interface RailNetwork { findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; overlappingRailroads(tile: TileRef): number[]; + computeGhostRailPaths(tile: TileRef): TileRef[][]; recomputeClusters(): void; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index b35fb80e8f..d2a0f01a18 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -228,6 +228,52 @@ export class RailNetworkImpl implements RailNetwork { ); } + computeGhostRailPaths(tile: TileRef): TileRef[][] { + const snappableRails = this.railGrid.query(tile, this.stationRadius); + if (snappableRails.size > 0) { + return []; + } + + const maxRange = this.game.config().trainStationMaxRange(); + const minRangeSquared = this.game.config().trainStationMinRange() ** 2; + const maxPathSize = this.game.config().railroadMaxSize(); + + const neighbors = this.game.nearbyUnits(tile, maxRange, [ + UnitType.City, + UnitType.Factory, + UnitType.Port, + ]); + neighbors.sort((a, b) => a.distSquared - b.distSquared); + + const paths: TileRef[][] = []; + const connectedStations: TrainStation[] = []; + for (const neighbor of neighbors) { + if (paths.length >= 5) break; + 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; + + const path = this.pathService.findTilePath(tile, neighborStation.tile()); + if (path.length > 0 && path.length < maxPathSize) { + paths.push(path); + connectedStations.push(neighborStation); + } + } + + return paths; + } + private connectToNearbyStations(station: TrainStation) { const neighbors = this.game.nearbyUnits( station.tile(), diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 6176d27715..a49144d6ab 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -39,6 +39,7 @@ describe("InputHandler AutoUpgrade", () => { ghostStructure: null, rocketDirectionUp: true, overlappingRailroads: [], + ghostRailPaths: [], }, mockCanvas, eventBus, diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index 70be4febd7..407bb97ed4 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -166,4 +166,167 @@ describe("RailNetworkImpl", () => { 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(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(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(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(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(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(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(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(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); + }); + }); }); From 143e8e3dd99e92d08611d1af37412c9bf079b844 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Fri, 13 Feb 2026 19:46:31 +0100 Subject: [PATCH 2/6] Small refactor --- src/client/graphics/layers/RailroadLayer.ts | 1 - src/core/game/PlayerImpl.ts | 17 ++++------------- src/core/game/RailNetwork.ts | 4 ++-- src/core/game/RailNetworkImpl.ts | 7 ++++++- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index e7368fa69b..29a28684f6 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -274,7 +274,6 @@ export class RailroadLayer implements Layer { ); } context.restore(); - context.fillStyle = "rgba(255, 255, 255, 0.6)"; } const railRects = getRailroadRects(railTile.type); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index c39f2eff31..6c84a2b19f 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -974,18 +974,6 @@ export class PlayerImpl implements Player { canBuild = this.canBuild(u, tile, validTiles); } } - let ghostRailPaths: TileRef[][] = []; - if ( - canBuild !== false && - (u === UnitType.City || u === UnitType.Port) && - this.mg.hasUnitNearby( - canBuild, - this.mg.config().trainStationMaxRange(), - UnitType.Factory, - ) - ) { - ghostRailPaths = this.mg.railNetwork().computeGhostRailPaths(canBuild); - } return { type: u, @@ -996,7 +984,10 @@ export class PlayerImpl implements Player { canBuild !== false ? this.mg.railNetwork().overlappingRailroads(canBuild) : [], - ghostRailPaths, + ghostRailPaths: + canBuild !== false + ? this.mg.railNetwork().computeGhostRailPaths(u, canBuild) + : [], } as BuildableUnit; }); } diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index f36007e21d..c808807838 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -1,4 +1,4 @@ -import { Unit } from "./Game"; +import { Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; import { StationManager } from "./RailNetworkImpl"; import { TrainStation } from "./TrainStation"; @@ -9,6 +9,6 @@ export interface RailNetwork { findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; overlappingRailroads(tile: TileRef): number[]; - computeGhostRailPaths(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 d2a0f01a18..ed5550171a 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -228,7 +228,12 @@ export class RailNetworkImpl implements RailNetwork { ); } - computeGhostRailPaths(tile: TileRef): TileRef[][] { + computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][] { + if (![UnitType.City, UnitType.Port].includes(unitType)) { + return []; + } + + // Skip if can snap to existing railway const snappableRails = this.railGrid.query(tile, this.stationRadius); if (snappableRails.size > 0) { return []; From 459c3ac584d868921e1daaf94b343c4662960623 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Fri, 13 Feb 2026 20:02:40 +0100 Subject: [PATCH 3/6] Refactor color, fix nearby units detection --- src/client/graphics/layers/RailroadLayer.ts | 4 ++-- src/core/game/RailNetworkImpl.ts | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index 29a28684f6..7095326da9 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -253,7 +253,7 @@ export class RailroadLayer implements Layer { const offsetX = -this.game.width() / 2; const offsetY = -this.game.height() / 2; - context.fillStyle = "rgba(255, 255, 255, 0.6)"; + context.fillStyle = "rgba(0, 0, 0, 0.4)"; for (const path of this.uiState.ghostRailPaths) { const railTiles = computeRailTiles(this.game, path); @@ -263,7 +263,7 @@ export class RailroadLayer implements Layer { if (this.game.isWater(railTile.tile)) { context.save(); - context.fillStyle = "rgba(197, 69, 72, 0.6)"; + context.fillStyle = "rgba(197, 69, 72, 0.4)"; const bridgeRects = getBridgeRects(railTile.type); for (const [dx, dy, w, h] of bridgeRects) { context.fillRect( diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index ed5550171a..11dd4a7a6e 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -243,11 +243,16 @@ 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)) { + return []; + } + const neighbors = this.game.nearbyUnits(tile, maxRange, [ - UnitType.City, - UnitType.Factory, - UnitType.Port, - ]); + UnitType.City, + UnitType.Factory, + UnitType.Port, + ]); neighbors.sort((a, b) => a.distSquared - b.distSquared); const paths: TileRef[][] = []; From bb00da9787b31d59a96fd1252bea7e75e0e5a1fc Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Fri, 13 Feb 2026 20:06:09 +0100 Subject: [PATCH 4/6] Fix tests --- src/core/game/RailNetworkImpl.ts | 8 ++++---- tests/core/game/RailNetwork.test.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 11dd4a7a6e..00e7d03593 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -249,10 +249,10 @@ export class RailNetworkImpl implements RailNetwork { } const neighbors = this.game.nearbyUnits(tile, maxRange, [ - UnitType.City, - UnitType.Factory, - UnitType.Port, - ]); + UnitType.City, + UnitType.Factory, + UnitType.Port, + ]); neighbors.sort((a, b) => a.distSquared - b.distSquared); const paths: TileRef[][] = []; diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index 407bb97ed4..00d023118d 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -174,7 +174,7 @@ describe("RailNetworkImpl", () => { const railGridMock = { query: vi.fn(() => new Set([{}])) }; (network as any).railGrid = railGridMock; - const result = network.computeGhostRailPaths(tile); + const result = network.computeGhostRailPaths(UnitType.City, tile); expect(result).toEqual([]); expect(railGridMock.query).toHaveBeenCalledWith(tile, 3); }); @@ -185,7 +185,7 @@ describe("RailNetworkImpl", () => { (network as any).railGrid = railGridMock; game.nearbyUnits.mockReturnValue([]); - const result = network.computeGhostRailPaths(tile); + const result = network.computeGhostRailPaths(UnitType.City, tile); expect(result).toEqual([]); }); @@ -205,7 +205,7 @@ describe("RailNetworkImpl", () => { { unit: neighborStation.unit, distSquared: 400 }, ]); - const result = network.computeGhostRailPaths(tile); + const result = network.computeGhostRailPaths(UnitType.City, tile); expect(result).toEqual([mockPath]); expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100); }); @@ -224,7 +224,7 @@ describe("RailNetworkImpl", () => { { unit: neighborStation.unit, distSquared: 50 }, ]); - const result = network.computeGhostRailPaths(tile); + const result = network.computeGhostRailPaths(UnitType.City, tile); expect(result).toEqual([]); }); @@ -237,7 +237,7 @@ describe("RailNetworkImpl", () => { game.nearbyUnits.mockReturnValue([{ unit: { id: 1 }, distSquared: 400 }]); - const result = network.computeGhostRailPaths(tile); + const result = network.computeGhostRailPaths(UnitType.City, tile); expect(result).toEqual([]); }); @@ -257,7 +257,7 @@ describe("RailNetworkImpl", () => { { unit: neighborStation.unit, distSquared: 400 }, ]); - const result = network.computeGhostRailPaths(tile); + const result = network.computeGhostRailPaths(UnitType.City, tile); expect(result).toEqual([]); }); @@ -286,7 +286,7 @@ describe("RailNetworkImpl", () => { game.nearbyUnits.mockReturnValue(neighbors); - const result = network.computeGhostRailPaths(tile); + const result = network.computeGhostRailPaths(UnitType.City, tile); expect(result.length).toBe(5); }); @@ -322,7 +322,7 @@ describe("RailNetworkImpl", () => { { unit: stationB.unit, distSquared: 900 }, ]); - const result = network.computeGhostRailPaths(tile); + 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); From 0a6394c6da6325020433d1691840df78d0516464 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Fri, 13 Feb 2026 20:15:30 +0100 Subject: [PATCH 5/6] Fix tests for real --- tests/core/game/RailNetwork.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index 00d023118d..a2efcca172 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -65,6 +65,7 @@ describe("RailNetworkImpl", () => { findStationsPath: vi.fn(() => [0]), }; game = { + hasUnitNearby: vi.fn(() => true), nearbyUnits: vi.fn(() => []), addExecution: vi.fn(), config: () => ({ From cec8dc122f4e17b6cd61459300580efad2839826 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Tue, 17 Feb 2026 09:10:08 +0100 Subject: [PATCH 6/6] Address review comments --- src/client/graphics/layers/RailroadLayer.ts | 6 +++++- src/core/game/RailNetworkImpl.ts | 11 ++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index 7095326da9..1b3815ac6d 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -225,9 +225,12 @@ export class RailroadLayer implements Layer { context.save(); context.globalAlpha = alpha; - this.highlightOverlappingRailroads(context); + this.renderGhostRailroads(context); + if (this.existingRailroads.size > 0) { + this.highlightOverlappingRailroads(context); + context.drawImage( this.canvas, srcX, @@ -240,6 +243,7 @@ export class RailroadLayer implements Layer { visHeight, ); } + context.restore(); } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 00e7d03593..813098401f 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -228,14 +228,18 @@ export class RailNetworkImpl implements RailNetwork { ); } + private canSnapToExistingRailway(tile: TileRef): boolean { + return this.railGrid.query(tile, this.stationRadius).size > 0; + } + computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][] { + // Factories already show their radius, so we'll exclude from ghost rails + // in order not to clutter the interface too much. if (![UnitType.City, UnitType.Port].includes(unitType)) { return []; } - // Skip if can snap to existing railway - const snappableRails = this.railGrid.query(tile, this.stationRadius); - if (snappableRails.size > 0) { + if (this.canSnapToExistingRailway(tile)) { return []; } @@ -258,6 +262,7 @@ export class RailNetworkImpl implements RailNetwork { const paths: TileRef[][] = []; const connectedStations: TrainStation[] = []; for (const neighbor of neighbors) { + // Limit to the closest 5 stations to avoid running too many pathfinding calls. if (paths.length >= 5) break; if (neighbor.distSquared <= minRangeSquared) continue;