diff --git a/src/client/UIState.ts b/src/client/UIState.ts index c4f529615..90b8194fd 100644 --- a/src/client/UIState.ts +++ b/src/client/UIState.ts @@ -1,10 +1,7 @@ import { PlayerBuildableUnitType } from "../core/game/Game"; -import { TileRef } from "../core/game/GameMap"; export interface UIState { attackRatio: number; ghostStructure: PlayerBuildableUnitType | null; - overlappingRailroads: number[]; - ghostRailPaths: TileRef[][]; rocketDirectionUp: boolean; } diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 661a65805..14b5ad3ab 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -191,15 +191,6 @@ export class BuildPreviewController implements Controller { this.ghostUnit.buildableUnit = unit; - if (unit.canUpgrade || unit.canBuild === false) { - // No rail-snap overlap for upgrades or invalid placements. - this.uiState.overlappingRailroads = []; - this.uiState.ghostRailPaths = []; - } else { - this.uiState.overlappingRailroads = unit.overlappingRailroads; - this.uiState.ghostRailPaths = unit.ghostRailPaths; - } - if (this.pendingConfirm !== null) { const ev = this.pendingConfirm; this.pendingConfirm = null; @@ -326,14 +317,22 @@ export class BuildPreviewController implements Controller { // Range circle: SAM placement preview shows targetable radius; nuke // previews show the outer blast radius at the target tile. let rangeRadius = 0; - if (u.type === UnitType.SAMLauncher) { - const level = this.resolveGhostRangeLevel(u) ?? 1; - rangeRadius = this.game.config().samRange(level); - } else if ( - u.type === UnitType.AtomBomb || - u.type === UnitType.HydrogenBomb - ) { - rangeRadius = this.game.config().nukeMagnitudes(u.type).outer; + switch (u.type) { + case UnitType.SAMLauncher: { + const level = this.resolveGhostRangeLevel(u) ?? 1; + rangeRadius = this.game.config().samRange(level); + break; + } + case UnitType.AtomBomb: + case UnitType.HydrogenBomb: + rangeRadius = this.game.config().nukeMagnitudes(u.type).outer; + break; + case UnitType.Factory: + rangeRadius = this.game.config().trainStationMaxRange(); + break; + case UnitType.DefensePost: + rangeRadius = this.game.config().defensePostRange(); + break; } return { @@ -428,7 +427,6 @@ export class BuildPreviewController implements Controller { private clearGhostStructure() { this.pendingConfirm = null; this.ghostUnit = null; - this.uiState.ghostRailPaths = []; this.lastGhostData = null; this.view.updateGhostPreview(null); this.view.updateNukeTrajectory(null); diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index 5ad90783a..4ef7fbfc4 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -51,8 +51,6 @@ export function createRenderer( const uiState: UIState = { attackRatio: 20, ghostStructure: null, - overlappingRailroads: [], - ghostRailPaths: [], rocketDirectionUp: true, }; diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 0c99d958d..ef53602a8 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -1,3 +1,5 @@ +import type { TileRef } from "../../../core/game/GameMap"; + /** TrainType enum — numeric values matching UnitState.trainType. */ export enum TrainType { Engine = 0, @@ -152,8 +154,8 @@ export interface GhostPreviewData { canBuild: boolean; // Valid placement? canUpgrade: boolean; // Upgrading existing structure? cost: number; // Gold cost - ghostRailPaths: number[][]; // TileRef paths (City/Port only) - overlappingRailroads: number[]; // Rail IDs in snap zone + ghostRailPaths: TileRef[][]; // TileRef paths (City/Port only) + overlappingRailroads: TileRef[]; // TileRefs containing rails in snap zone ownerID: number; // Player's smallID (for color) /** Tile position of existing structure being upgraded (null if fresh build). */ upgradeTargetTile: number | null; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 02b0b6d38..3bf5a080b 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -985,7 +985,7 @@ export interface BuildableUnit { canUpgrade: number | false; type: PlayerBuildableUnitType; cost: Gold; - overlappingRailroads: number[]; + overlappingRailroads: TileRef[]; ghostRailPaths: TileRef[][]; } diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index c80880783..420e59c29 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): number[]; + overlappingRailroads(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 43bb8f1d8..7692b9ba2 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -223,10 +223,14 @@ export class RailNetworkImpl implements RailNetwork { return editedClusters.size !== 0; } - overlappingRailroads(tile: TileRef): number[] { - return [...this.railGrid.query(tile, this.stationRadius)].map( - (railroad: Railroad) => railroad.id, - ); + overlappingRailroads(tile: TileRef): TileRef[] { + const tiles = new Set(); + for (const railroad of this.railGrid.query(tile, this.stationRadius)) { + for (const t of railroad.tiles) { + tiles.add(t); + } + } + return Array.from(tiles).sort((a, b) => a - b); } private canSnapToExistingRailway(tile: TileRef): boolean { diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index f0a8f2e04..b89e2f46d 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -65,8 +65,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], }, mockCanvas, eventBus, @@ -541,8 +539,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -597,8 +593,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -649,8 +643,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -671,8 +663,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -699,8 +689,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -724,8 +712,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -752,8 +738,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; }); @@ -892,8 +876,6 @@ describe("Warship box selection (Shift+drag)", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index a2efcca17..e63fadef2 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -168,6 +168,33 @@ describe("RailNetworkImpl", () => { 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(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(tile); + + expect(result).toEqual([]); + }); + }); + describe("computeGhostRailPaths", () => { test("returns empty when snappable rails exist nearby", () => { const tile = 42 as any;