From 8f53785a80f13d2d4c40226bf8d6f35a6a1716ed Mon Sep 17 00:00:00 2001 From: CrackeRR11 <92652241+CrackeRR11@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:45:14 +0100 Subject: [PATCH] BUG FIX: Gold double deduction + Rmoval of UnitType.Construction (#2378) ## Description: - Removed the temporary UnitType.Construction and embedded construction state into real units via isUnderConstruction(). - Centralized non-structure spawning to perform a single validation right before unit creation/launch. - Updated UI layers to render construction state without relying on the removed enum. - Adjusted and created tests to match the new flow and to cover the no-refundscenarios. # Tests updated - tests/economy/ConstructionGold.test.ts: covers structure cost deduction and income, tolerant of passive income; ensures no refunds during construction. - tests/nukes/HydrogenAndMirv.test.ts: accounts for single-check launch flow; MIRV test targets a player-owned tile; ensures launch after payment. - tests/client/graphics/UILayer.test.ts: mocks now provide isUnderConstruction and real type strings; ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: CrackeRR1 --------- Co-authored-by: Evan --- .../graphics/layers/RadialMenuElements.ts | 2 +- .../graphics/layers/StructureDrawingUtils.ts | 11 +- .../graphics/layers/StructureIconsLayer.ts | 22 +- src/client/graphics/layers/StructureLayer.ts | 8 +- src/client/graphics/layers/TerritoryLayer.ts | 5 + src/client/graphics/layers/UILayer.ts | 42 ++-- src/core/configuration/DefaultConfig.ts | 5 - src/core/execution/CityExecution.ts | 42 ++-- src/core/execution/ConstructionExecution.ts | 88 ++++---- src/core/execution/DefensePostExecution.ts | 24 +-- src/core/execution/FactoryExecution.ts | 47 ++--- src/core/execution/MissileSiloExecution.ts | 28 +-- src/core/execution/NukeExecution.ts | 2 +- src/core/execution/PortExecution.ts | 59 +++--- src/core/execution/SAMLauncherExecution.ts | 4 + src/core/game/Game.ts | 12 +- src/core/game/GameImpl.ts | 11 +- src/core/game/GameUpdates.ts | 2 +- src/core/game/GameView.ts | 4 +- src/core/game/PlayerImpl.ts | 32 +-- src/core/game/UnitGrid.ts | 22 +- src/core/game/UnitImpl.ts | 20 +- tests/PortExecution.test.ts | 21 +- tests/ShellRandom.test.ts | 8 +- tests/client/graphics/UILayer.test.ts | 11 +- tests/core/executions/NukeExecution.test.ts | 3 + tests/economy/ConstructionGold.test.ts | 71 +++++++ tests/nukes/HydrogenAndMirv.test.ts | 197 ++++++++++++++++++ 28 files changed, 528 insertions(+), 275 deletions(-) create mode 100644 tests/economy/ConstructionGold.test.ts create mode 100644 tests/nukes/HydrogenAndMirv.test.ts diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 29e5bd379..7eeb88f29 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -452,7 +452,7 @@ export const deleteUnitElement: MenuElement = { .units() .filter( (unit) => - unit.constructionType() === undefined && + !unit.isUnderConstruction() && unit.markedForDeletion() === false && params.game.manhattanDist(unit.tile(), params.tile) <= DELETE_SELECTION_RADIUS, diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts index ad1496dfa..31a13b036 100644 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ b/src/client/graphics/layers/StructureDrawingUtils.ts @@ -143,19 +143,12 @@ export class SpriteFactory { const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos); const isMarkedForDeletion = unit.markedForDeletion() !== false; - const isConstruction = unit.type() === UnitType.Construction; - const constructionType = unit.constructionType(); - const structureType = isConstruction ? constructionType! : unit.type(); + const isConstruction = unit.isUnderConstruction(); + const structureType = unit.type(); const { type, stage } = options; const { scale } = this.transformHandler; if (type === "icon" || type === "dot") { - if (isConstruction && constructionType === undefined) { - console.warn( - `Unit ${unit.id()} is a construction but has no construction type.`, - ); - return parentContainer; - } const texture = this.createTexture( structureType, unit.owner(), diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 3504eed05..1458e7aff 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -469,10 +469,7 @@ export class StructureIconsLayer implements Layer { this.checkForOwnershipChange(render, unitView); this.checkForLevelChange(render, unitView); } - } else if ( - this.structures.has(unitView.type()) || - unitView.type() === UnitType.Construction - ) { + } else if (this.structures.has(unitView.type())) { this.addNewStructure(unitView); } } @@ -485,10 +482,7 @@ export class StructureIconsLayer implements Layer { } private modifyVisibility(render: StructureRenderInfo) { - const structureType = - render.unit.type() === UnitType.Construction - ? render.unit.constructionType()! - : render.unit.type(); + const structureType = render.unit.type(); const structureInfos = this.structures.get(structureType); let focusStructure = false; @@ -529,10 +523,7 @@ export class StructureIconsLayer implements Layer { render: StructureRenderInfo, unit: UnitView, ) { - if ( - render.underConstruction && - render.unit.type() !== UnitType.Construction - ) { + if (render.underConstruction && !unit.isUnderConstruction()) { render.underConstruction = false; render.iconContainer?.destroy(); render.dotContainer?.destroy(); @@ -580,10 +571,7 @@ export class StructureIconsLayer implements Layer { : screenPos.y, ); - const type = - render.unit.type() === UnitType.Construction - ? render.unit.constructionType() - : render.unit.type(); + const type = render.unit.type(); const margin = type !== undefined && STRUCTURE_SHAPES[type] !== undefined ? ICON_SIZE[STRUCTURE_SHAPES[type]] @@ -637,7 +625,7 @@ export class StructureIconsLayer implements Layer { this.createLevelSprite(unitView), this.createDotSprite(unitView), unitView.level(), - unitView.type() === UnitType.Construction, + unitView.isUnderConstruction(), ); this.renders.push(render); this.computeNewLocation(render); diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index f0c0d6c5e..55ec7dd89 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -190,7 +190,7 @@ export class StructureLayer implements Layer { )) { this.paintCell( new Cell(this.game.x(tile), this.game.y(tile)), - unit.type() === UnitType.Construction + unit.isUnderConstruction() ? underConstructionColor : unit.owner().territoryColor(), 130, @@ -199,7 +199,7 @@ export class StructureLayer implements Layer { } private handleUnitRendering(unit: UnitView) { - const unitType = unit.constructionType() ?? unit.type(); + const unitType = unit.type(); const iconType = unitType; if (!this.isUnitTypeSupported(unitType)) return; @@ -208,7 +208,7 @@ export class StructureLayer implements Layer { let borderColor = unit.owner().borderColor(); // Handle cooldown states and special icons - if (unit.type() === UnitType.Construction) { + if (unit.isUnderConstruction()) { icon = this.unitIcons.get(iconType); borderColor = underConstructionColor; } else { @@ -247,7 +247,7 @@ export class StructureLayer implements Layer { unit: UnitView, ) { let color = unit.owner().borderColor(); - if (unit.type() === UnitType.Construction) { + if (unit.isUnderConstruction()) { // eslint-disable-next-line @typescript-eslint/no-unused-vars color = underConstructionColor; } diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index b7e302ade..2e6fa2113 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -90,6 +90,11 @@ export class TerritoryLayer implements Layer { const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; unitUpdates.forEach((update) => { if (update.unitType === UnitType.DefensePost) { + // Only update borders if the defense post is not under construction + if (update.underConstruction) { + return; // Skip barrier creation while under construction + } + const tile = update.pos; this.game .bfs(tile, euclDistFN(tile, this.game.config().defensePostRange())) diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 43092a642..d8edb3f02 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -103,16 +103,12 @@ export class UILayer implements Layer { } onUnitEvent(unit: UnitView) { + const underConst = unit.isUnderConstruction(); + if (underConst) { + this.createLoadingBar(unit); + return; + } switch (unit.type()) { - case UnitType.Construction: { - const constructionType = unit.constructionType(); - if (constructionType === undefined) { - // Skip units without construction type - return; - } - this.createLoadingBar(unit); - break; - } case UnitType.Warship: { this.drawHealthBar(unit); break; @@ -318,22 +314,20 @@ export class UILayer implements Layer { if (!unit.isActive()) { return 1; } - switch (unit.type()) { - case UnitType.Construction: { - const constructionType = unit.constructionType(); - if (constructionType === undefined) { - return 1; - } - const constDuration = - this.game.unitInfo(constructionType).constructionDuration; - if (constDuration === undefined) { - throw new Error("unit does not have constructionTime"); - } - return ( - (this.game.ticks() - unit.createdAt()) / - (constDuration === 0 ? 1 : constDuration) - ); + const underConst = unit.isUnderConstruction(); + if (underConst) { + const constDuration = this.game.unitInfo( + unit.type(), + ).constructionDuration; + if (constDuration === undefined) { + throw new Error("unit does not have constructionTime"); } + return ( + (this.game.ticks() - unit.createdAt()) / + (constDuration === 0 ? 1 : constDuration) + ); + } + switch (unit.type()) { case UnitType.MissileSilo: case UnitType.SAMLauncher: return !unit.markedForDeletion() diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 0dfc68e03..4b8c89588 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -545,11 +545,6 @@ export class DefaultConfig implements Config { experimental: true, upgradable: true, }; - case UnitType.Construction: - return { - cost: () => 0n, - territoryBound: true, - }; case UnitType.Train: return { cost: () => 0n, diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts index be24d1bef..fd31c940e 100644 --- a/src/core/execution/CityExecution.ts +++ b/src/core/execution/CityExecution.ts @@ -1,40 +1,26 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; -import { TileRef } from "../game/GameMap"; +import { Execution, Game, Unit, UnitType } from "../game/Game"; import { TrainStationExecution } from "./TrainStationExecution"; export class CityExecution implements Execution { private mg: Game; - private city: Unit | null = null; private active: boolean = true; + private stationCreated = false; - constructor( - private player: Player, - private tile: TileRef, - ) {} + constructor(private city: Unit) {} init(mg: Game, ticks: number): void { this.mg = mg; } tick(ticks: number): void { - if (this.city === null) { - const spawnTile = this.player.canBuild(UnitType.City, this.tile); - if (spawnTile === false) { - console.warn("cannot build city"); - this.active = false; - return; - } - this.city = this.player.buildUnit(UnitType.City, spawnTile, {}); + if (!this.stationCreated) { this.createStation(); + this.stationCreated = true; } if (!this.city.isActive()) { this.active = false; return; } - - if (this.player !== this.city.owner()) { - this.player = this.city.owner(); - } } isActive(): boolean { @@ -45,16 +31,14 @@ export class CityExecution implements Execution { return false; } - createStation(): void { - if (this.city !== null) { - const nearbyFactory = this.mg.hasUnitNearby( - this.city.tile()!, - this.mg.config().trainStationMaxRange(), - UnitType.Factory, - ); - if (nearbyFactory) { - this.mg.addExecution(new TrainStationExecution(this.city)); - } + private createStation(): void { + const nearbyFactory = this.mg.hasUnitNearby( + this.city.tile()!, + this.mg.config().trainStationMaxRange(), + UnitType.Factory, + ); + if (nearbyFactory) { + this.mg.addExecution(new TrainStationExecution(this.city)); } } } diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 8217f497d..799556fe0 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -1,12 +1,4 @@ -import { - Execution, - Game, - Gold, - Player, - Tick, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Tick, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { CityExecution } from "./CityExecution"; import { DefensePostExecution } from "./DefensePostExecution"; @@ -19,14 +11,12 @@ import { SAMLauncherExecution } from "./SAMLauncherExecution"; import { WarshipExecution } from "./WarshipExecution"; export class ConstructionExecution implements Execution { - private construction: Unit | null = null; + private structure: Unit | null = null; private active: boolean = true; private mg: Game; private ticksUntilComplete: Tick; - private cost: Gold; - constructor( private player: Player, private constructionType: UnitType, @@ -52,45 +42,52 @@ export class ConstructionExecution implements Execution { } tick(ticks: number): void { - if (this.construction === null) { + if (this.structure === null) { const info = this.mg.unitInfo(this.constructionType); - if (info.constructionDuration === undefined) { + // For non-structure units (nukes/warship), charge once and delegate to specialized executions. + const isStructure = this.isStructure(this.constructionType); + if (!isStructure) { + // Defer validation and gold deduction to the specific execution this.completeConstruction(); this.active = false; return; } + + // Structures: build real unit and mark under construction const spawnTile = this.player.canBuild(this.constructionType, this.tile); if (spawnTile === false) { console.warn(`cannot build ${this.constructionType}`); this.active = false; return; } - this.construction = this.player.buildUnit( - UnitType.Construction, + this.structure = this.player.buildUnit( + this.constructionType, spawnTile, {}, ); - this.cost = this.mg.unitInfo(this.constructionType).cost(this.player); - this.player.removeGold(this.cost); - this.construction.setConstructionType(this.constructionType); - this.ticksUntilComplete = info.constructionDuration; - return; - } - - if (!this.construction.isActive()) { + const duration = info.constructionDuration ?? 0; + if (duration > 0) { + this.structure.setUnderConstruction(true); + this.ticksUntilComplete = duration; + return; + } + // No construction time + this.completeConstruction(); this.active = false; return; } - if (this.player !== this.construction.owner()) { - this.player = this.construction.owner(); + if (!this.structure.isActive()) { + this.active = false; + return; + } + + if (this.player !== this.structure.owner()) { + this.player = this.structure.owner(); } if (this.ticksUntilComplete === 0) { - this.player = this.construction.owner(); - this.construction.delete(false); - // refund the cost so player has the gold to build the unit - this.player.addGold(this.cost); + this.player = this.structure.owner(); this.completeConstruction(); this.active = false; return; @@ -99,6 +96,9 @@ export class ConstructionExecution implements Execution { } private completeConstruction() { + if (this.structure) { + this.structure.setUnderConstruction(false); + } const player = this.player; switch (this.constructionType) { case UnitType.AtomBomb: @@ -116,22 +116,24 @@ export class ConstructionExecution implements Execution { ); break; case UnitType.Port: - this.mg.addExecution(new PortExecution(player, this.tile)); + this.mg.addExecution(new PortExecution(this.structure!)); break; case UnitType.MissileSilo: - this.mg.addExecution(new MissileSiloExecution(player, this.tile)); + this.mg.addExecution(new MissileSiloExecution(this.structure!)); break; case UnitType.DefensePost: - this.mg.addExecution(new DefensePostExecution(player, this.tile)); + this.mg.addExecution(new DefensePostExecution(this.structure!)); break; case UnitType.SAMLauncher: - this.mg.addExecution(new SAMLauncherExecution(player, this.tile)); + this.mg.addExecution( + new SAMLauncherExecution(player, null, this.structure!), + ); break; case UnitType.City: - this.mg.addExecution(new CityExecution(player, this.tile)); + this.mg.addExecution(new CityExecution(this.structure!)); break; case UnitType.Factory: - this.mg.addExecution(new FactoryExecution(player, this.tile)); + this.mg.addExecution(new FactoryExecution(this.structure!)); break; default: console.warn( @@ -141,6 +143,20 @@ export class ConstructionExecution implements Execution { } } + private isStructure(type: UnitType): boolean { + switch (type) { + case UnitType.Port: + case UnitType.MissileSilo: + case UnitType.DefensePost: + case UnitType.SAMLauncher: + case UnitType.City: + case UnitType.Factory: + return true; + default: + return false; + } + } + isActive(): boolean { return this.active; } diff --git a/src/core/execution/DefensePostExecution.ts b/src/core/execution/DefensePostExecution.ts index ab36f81ae..c38f175cf 100644 --- a/src/core/execution/DefensePostExecution.ts +++ b/src/core/execution/DefensePostExecution.ts @@ -1,10 +1,8 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; -import { TileRef } from "../game/GameMap"; +import { Execution, Game, Unit } from "../game/Game"; import { ShellExecution } from "./ShellExecution"; export class DefensePostExecution implements Execution { private mg: Game; - private post: Unit | null = null; private active: boolean = true; private target: Unit | null = null; @@ -12,17 +10,13 @@ export class DefensePostExecution implements Execution { private alreadySentShell = new Set(); - constructor( - private player: Player, - private tile: TileRef, - ) {} + constructor(private post: Unit) {} init(mg: Game, ticks: number): void { this.mg = mg; } private shoot() { - if (this.post === null) return; if (this.target === null) return; const shellAttackRate = this.mg.config().defensePostShellAttackRate(); if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) { @@ -45,22 +39,14 @@ export class DefensePostExecution implements Execution { } tick(ticks: number): void { - if (this.post === null) { - const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile); - if (spawnTile === false) { - console.warn("cannot build Defense Post"); - this.active = false; - return; - } - this.post = this.player.buildUnit(UnitType.DefensePost, spawnTile, {}); - } if (!this.post.isActive()) { this.active = false; return; } - if (this.player !== this.post.owner()) { - this.player = this.post.owner(); + // Do nothing while the structure is under construction + if (this.post.isUnderConstruction()) { + return; } if (this.target !== null && !this.target.isActive()) { diff --git a/src/core/execution/FactoryExecution.ts b/src/core/execution/FactoryExecution.ts index fd24de674..92908ed39 100644 --- a/src/core/execution/FactoryExecution.ts +++ b/src/core/execution/FactoryExecution.ts @@ -1,39 +1,26 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; -import { TileRef } from "../game/GameMap"; +import { Execution, Game, Unit, UnitType } from "../game/Game"; import { TrainStationExecution } from "./TrainStationExecution"; export class FactoryExecution implements Execution { - private factory: Unit | null = null; private active: boolean = true; private game: Game; - constructor( - private player: Player, - private tile: TileRef, - ) {} + private stationCreated = false; + + constructor(private factory: Unit) {} init(mg: Game, ticks: number): void { this.game = mg; } tick(ticks: number): void { - if (!this.factory) { - const spawnTile = this.player.canBuild(UnitType.Factory, this.tile); - if (spawnTile === false) { - console.warn("cannot build factory"); - this.active = false; - return; - } - this.factory = this.player.buildUnit(UnitType.Factory, spawnTile, {}); + if (!this.stationCreated) { this.createStation(); + this.stationCreated = true; } if (!this.factory.isActive()) { this.active = false; return; } - - if (this.player !== this.factory.owner()) { - this.player = this.factory.owner(); - } } isActive(): boolean { @@ -44,19 +31,17 @@ export class FactoryExecution implements Execution { return false; } - createStation(): void { - if (this.factory !== null) { - const structures = this.game.nearbyUnits( - this.factory.tile()!, - this.game.config().trainStationMaxRange(), - [UnitType.City, UnitType.Port, UnitType.Factory], - ); + private createStation(): void { + const structures = this.game.nearbyUnits( + this.factory.tile()!, + this.game.config().trainStationMaxRange(), + [UnitType.City, UnitType.Port, UnitType.Factory], + ); - this.game.addExecution(new TrainStationExecution(this.factory, true)); - for (const { unit } of structures) { - if (!unit.hasTrainStation()) { - this.game.addExecution(new TrainStationExecution(unit)); - } + this.game.addExecution(new TrainStationExecution(this.factory, true)); + for (const { unit } of structures) { + if (!unit.hasTrainStation()) { + this.game.addExecution(new TrainStationExecution(unit)); } } } diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index a7cd2bb6c..a94727eab 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -1,35 +1,21 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; -import { TileRef } from "../game/GameMap"; +import { Execution, Game, Unit } from "../game/Game"; export class MissileSiloExecution implements Execution { private active = true; private mg: Game; - private silo: Unit | null = null; + private silo: Unit; - constructor( - private player: Player, - private tile: TileRef, - ) {} + constructor(silo: Unit) { + this.silo = silo; + } init(mg: Game, ticks: number): void { this.mg = mg; } tick(ticks: number): void { - if (this.silo === null) { - const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile); - if (spawn === false) { - console.warn( - `player ${this.player} cannot build missile silo at ${this.tile}`, - ); - this.active = false; - return; - } - this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {}); - - if (this.player !== this.silo.owner()) { - this.player = this.silo.owner(); - } + if (this.silo.isUnderConstruction()) { + return; } // frontTime is the time the earliest missile fired. diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 1fd9222bb..923535f1f 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -103,7 +103,7 @@ export class NukeExecution implements Execution { tick(ticks: number): void { if (this.nuke === null) { - const spawn = this.src ?? this.player.canBuild(this.nukeType, this.dst); + const spawn = this.player.canBuild(this.nukeType, this.dst); if (spawn === false) { console.warn(`cannot build Nuke`); this.active = false; diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index de7b70752..1b042a4a5 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -1,5 +1,4 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; -import { TileRef } from "../game/GameMap"; +import { Execution, Game, Unit, UnitType } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { TradeShipExecution } from "./TradeShipExecution"; import { TrainStationExecution } from "./TrainStationExecution"; @@ -7,14 +6,13 @@ import { TrainStationExecution } from "./TrainStationExecution"; export class PortExecution implements Execution { private active = true; private mg: Game; - private port: Unit | null = null; + private port: Unit; private random: PseudoRandom; private checkOffset: number; - constructor( - private player: Player, - private tile: TileRef, - ) {} + constructor(port: Unit) { + this.port = port; + } init(mg: Game, ticks: number): void { this.mg = mg; @@ -26,27 +24,18 @@ export class PortExecution implements Execution { if (this.mg === null || this.random === null || this.checkOffset === null) { throw new Error("Not initialized"); } - if (this.port === null) { - const tile = this.tile; - const spawn = this.player.canBuild(UnitType.Port, tile); - if (spawn === false) { - console.warn( - `player ${this.player.id()} cannot build port at ${this.tile}`, - ); - this.active = false; - return; - } - this.port = this.player.buildUnit(UnitType.Port, spawn, {}); - this.createStation(); - } if (!this.port.isActive()) { this.active = false; return; } - if (this.player.id() !== this.port.owner().id()) { - this.player = this.port.owner(); + if (this.port.isUnderConstruction()) { + return; + } + + if (!this.port.hasTrainStation()) { + this.createStation(); } // Only check every 10 ticks for performance. @@ -65,7 +54,9 @@ export class PortExecution implements Execution { } const port = this.random.randElement(ports); - this.mg.addExecution(new TradeShipExecution(this.player, this.port, port)); + this.mg.addExecution( + new TradeShipExecution(this.port.owner(), this.port, port), + ); } isActive(): boolean { @@ -78,8 +69,10 @@ export class PortExecution implements Execution { shouldSpawnTradeShip(): boolean { const numTradeShips = this.mg.unitCount(UnitType.TradeShip); - const numPlayerPorts = this.player.unitCount(UnitType.Port); - const numPlayerTradeShips = this.player.unitCount(UnitType.TradeShip); + const numPlayerPorts = this.port!.owner().unitCount(UnitType.Port); + const numPlayerTradeShips = this.port!.owner().unitCount( + UnitType.TradeShip, + ); const spawnRate = this.mg .config() .tradeShipSpawnRate(numTradeShips, numPlayerPorts, numPlayerTradeShips); @@ -92,15 +85,13 @@ export class PortExecution implements Execution { } createStation(): void { - if (this.port !== null) { - const nearbyFactory = this.mg.hasUnitNearby( - this.port.tile()!, - this.mg.config().trainStationMaxRange(), - UnitType.Factory, - ); - if (nearbyFactory) { - this.mg.addExecution(new TrainStationExecution(this.port)); - } + const nearbyFactory = this.mg.hasUnitNearby( + this.port.tile()!, + this.mg.config().trainStationMaxRange(), + UnitType.Factory, + ); + if (nearbyFactory) { + this.mg.addExecution(new TrainStationExecution(this.port)); } } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 33b909b10..c669b42fd 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -216,6 +216,10 @@ export class SAMLauncherExecution implements Execution { } this.targetingSystem ??= new SAMTargetingSystem(this.mg, this.sam); + if (this.sam.isUnderConstruction()) { + return; + } + if (this.sam.isInCooldown()) { const frontTime = this.sam.missileTimerQueue()[0]; if (frontTime === undefined) { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 2cf908b96..0cd4b07c5 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -193,7 +193,6 @@ export enum UnitType { City = "City", MIRV = "MIRV", MIRVWarhead = "MIRV Warhead", - Construction = "Construction", Train = "Train", Factory = "Factory", } @@ -205,7 +204,6 @@ export enum TrainType { const _structureTypes: ReadonlySet = new Set([ UnitType.City, - UnitType.Construction, UnitType.DefensePost, UnitType.SAMLauncher, UnitType.MissileSilo, @@ -279,8 +277,6 @@ export interface UnitParamsMap { [UnitType.MIRVWarhead]: { targetTile?: number; }; - - [UnitType.Construction]: Record; } // Type helper to get params type for a specific unit type @@ -495,9 +491,9 @@ export interface Unit { setSafeFromPirates(): void; // Only for trade ships isSafeFromPirates(): boolean; // Only for trade ships - // Construction - constructionType(): UnitType | null; - setConstructionType(type: UnitType): void; + // Construction phase on structures + isUnderConstruction(): boolean; + setUnderConstruction(underConstruction: boolean): void; // Upgradable Structures level(): number; @@ -702,12 +698,14 @@ export interface Game extends GameMap { searchRange: number, type: UnitType, playerId?: PlayerID, + includeUnderConstruction?: boolean, ): boolean; nearbyUnits( tile: TileRef, searchRange: number, types: UnitType | UnitType[], predicate?: UnitPredicate, + includeUnderConstruction?: boolean, ): Array<{ unit: Unit; distSquared: number }>; addExecution(...exec: Execution[]): void; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 5854a1e5b..2c23ad864 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -768,8 +768,15 @@ export class GameImpl implements Game { searchRange: number, type: UnitType, playerId?: PlayerID, + includeUnderConstruction?: boolean, ) { - return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId); + return this.unitGrid.hasUnitNearby( + tile, + searchRange, + type, + playerId, + includeUnderConstruction, + ); } nearbyUnits( @@ -777,12 +784,14 @@ export class GameImpl implements Game { searchRange: number, types: UnitType | UnitType[], predicate?: UnitPredicate, + includeUnderConstruction?: boolean, ): Array<{ unit: Unit; distSquared: number }> { return this.unitGrid.nearbyUnits( tile, searchRange, types, predicate, + includeUnderConstruction, ) as Array<{ unit: Unit; distSquared: number; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 455ef1ac1..c558a3391 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -128,7 +128,7 @@ export interface UnitUpdate { targetUnitId?: number; // Only for trade ships targetTile?: TileRef; // Only for nukes health?: number; - constructionType?: UnitType; + underConstruction?: boolean; missileTimerQueue: number[]; level: number; hasTrainStation: boolean; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 471555751..63ce987de 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -121,8 +121,8 @@ export class UnitView { health(): number { return this.data.health ?? 0; } - constructionType(): UnitType | undefined { - return this.data.constructionType; + isUnderConstruction(): boolean { + return this.data.underConstruction === true; } targetUnitId(): number | undefined { return this.data.targetUnitId; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 2dbe79784..c32c1f6f4 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -232,8 +232,8 @@ export class PlayerImpl implements Player { const built = this.numUnitsConstructed[type] ?? 0; let constructing = 0; for (const unit of this._units) { - if (unit.type() !== UnitType.Construction) continue; - if (unit.constructionType() !== type) continue; + if (unit.type() !== type) continue; + if (!unit.isUnderConstruction()) continue; constructing++; } const total = constructing + built; @@ -256,12 +256,12 @@ export class PlayerImpl implements Player { let total = 0; for (const unit of this._units) { if (unit.type() === type) { - total += unit.level(); - continue; + if (unit.isUnderConstruction()) { + total++; + } else { + total += unit.level(); + } } - if (unit.type() !== UnitType.Construction) continue; - if (unit.constructionType() !== type) continue; - total++; } return total; } @@ -886,7 +886,7 @@ export class PlayerImpl implements Player { public findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false { const range = this.mg.config().structureMinDist(); const existing = this.mg - .nearbyUnits(targetTile, range, type) + .nearbyUnits(targetTile, range, type, undefined, true) .sort((a, b) => a.distSquared - b.distSquared); if (existing.length === 0) { return false; @@ -902,6 +902,9 @@ export class PlayerImpl implements Player { if (unit.isMarkedForDeletion()) { return false; } + if (unit.isUnderConstruction()) { + return false; + } if (!this.mg.config().unitInfo(unit.type()).upgradable) { return false; } @@ -988,7 +991,6 @@ export class PlayerImpl implements Player { case UnitType.SAMLauncher: case UnitType.City: case UnitType.Factory: - case UnitType.Construction: return this.landBasedStructureSpawn(targetTile, validTiles); default: assertNever(unitType); @@ -1002,10 +1004,10 @@ export class PlayerImpl implements Player { return false; } } - // only get missilesilos that are not on cooldown + // only get missilesilos that are not on cooldown and not under construction const spawns = this.units(UnitType.MissileSilo) .filter((silo) => { - return !silo.isInCooldown(); + return !silo.isInCooldown() && !silo.isUnderConstruction(); }) .sort(distSortUnit(this.mg, tile)); if (spawns.length === 0) { @@ -1077,7 +1079,13 @@ export class PlayerImpl implements Player { return this.mg.config().unitInfo(unitTypeValue).territoryBound; }); - const nearbyUnits = this.mg.nearbyUnits(tile, searchRadius * 2, types); + const nearbyUnits = this.mg.nearbyUnits( + tile, + searchRadius * 2, + types, + undefined, + true, + ); const nearbyTiles = this.mg.bfs(tile, (gm, t) => { return ( this.mg.euclideanDistSquared(tile, t) < searchRadiusSquared && diff --git a/src/core/game/UnitGrid.ts b/src/core/game/UnitGrid.ts index 7d1a1b738..68b20fac5 100644 --- a/src/core/game/UnitGrid.ts +++ b/src/core/game/UnitGrid.ts @@ -137,6 +137,7 @@ export class UnitGrid { searchRange: number, types: readonly UnitType[] | UnitType, predicate?: UnitPredicate, + includeUnderConstruction: boolean = false, ): Array<{ unit: Unit | UnitView; distSquared: number }> { const nearby: Array<{ unit: Unit | UnitView; distSquared: number }> = []; const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange( @@ -152,6 +153,10 @@ export class UnitGrid { if (unitSet === undefined) continue; for (const unit of unitSet) { if (!unit.isActive()) continue; + // Exclude units under construction by default (e.g., defense posts being built) + // But include them for spacing checks + if (!includeUnderConstruction && unit.isUnderConstruction()) + continue; const distSquared = this.squaredDistanceFromTile(unit, tile); if (distSquared > rangeSquared) continue; const value = { unit, distSquared }; @@ -169,10 +174,16 @@ export class UnitGrid { tile: TileRef, rangeSquared: number, playerId?: PlayerID, + includeUnderConstruction: boolean = false, ): boolean { if (!unit.isActive()) { return false; } + // Exclude units under construction by default (e.g., defense posts being built) + // But include them for spacing checks + if (!includeUnderConstruction && unit.isUnderConstruction()) { + return false; + } if (playerId !== undefined && unit.owner().id() !== playerId) { return false; } @@ -186,6 +197,7 @@ export class UnitGrid { searchRange: number, type: UnitType, playerId?: PlayerID, + includeUnderConstruction: boolean = false, ): boolean { const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange( tile, @@ -197,7 +209,15 @@ export class UnitGrid { const unitSet = this.grid[cy][cx].get(type); if (unitSet === undefined) continue; for (const unit of unitSet) { - if (this.unitIsInRange(unit, tile, rangeSquared, playerId)) { + if ( + this.unitIsInRange( + unit, + tile, + rangeSquared, + playerId, + includeUnderConstruction, + ) + ) { return true; } } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index c4f3c00e0..a41b83a97 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -26,7 +26,7 @@ export class UnitImpl implements Unit { private _reachedTarget = false; private _wasDestroyedByEnemy: boolean = false; private _lastSetSafeFromPirates: number; // Only for trade ships - private _constructionType: UnitType | undefined; + private _underConstruction: boolean = false; private _lastOwner: PlayerImpl | null = null; private _troops: number; // Number of missiles in cooldown, if empty all missiles are ready. @@ -132,7 +132,7 @@ export class UnitImpl implements Unit { targetable: this._targetable, lastPos: this._lastTile, health: this.hasHealth() ? Number(this._health) : undefined, - constructionType: this._constructionType, + underConstruction: this._underConstruction, targetUnitId: this._targetUnit?.id() ?? undefined, targetTile: this.targetTile() ?? undefined, missileTimerQueue: this._missileTimerQueue, @@ -311,19 +311,15 @@ export class UnitImpl implements Unit { this._retreating = true; } - constructionType(): UnitType | null { - if (this.type() !== UnitType.Construction) { - throw new Error(`Cannot get construction type on ${this.type()}`); - } - return this._constructionType ?? null; + isUnderConstruction(): boolean { + return this._underConstruction; } - setConstructionType(type: UnitType): void { - if (this.type() !== UnitType.Construction) { - throw new Error(`Cannot set construction type on ${this.type()}`); + setUnderConstruction(underConstruction: boolean): void { + if (this._underConstruction !== underConstruction) { + this._underConstruction = underConstruction; + this.mg.addUpdate(this.toUpdate()); } - this._constructionType = type; - this.mg.addUpdate(this.toUpdate()); } hash(): number { diff --git a/tests/PortExecution.test.ts b/tests/PortExecution.test.ts index d38500315..287a0f022 100644 --- a/tests/PortExecution.test.ts +++ b/tests/PortExecution.test.ts @@ -41,7 +41,12 @@ describe("PortExecution", () => { game.config().tradeShipShortRangeDebuff = () => 0; player.conquer(game.ref(7, 10)); - const execution = new PortExecution(player, game.ref(7, 10)); + const spawn = player.canBuild(UnitType.Port, game.ref(7, 10)); + if (spawn === false) { + throw new Error("Unable to build port for test"); + } + const port = player.buildUnit(UnitType.Port, spawn, {}); + const execution = new PortExecution(port); execution.init(game, 0); execution.tick(0); @@ -60,7 +65,12 @@ describe("PortExecution", () => { game.config().tradeShipShortRangeDebuff = () => 0; player.conquer(game.ref(7, 10)); - const execution = new PortExecution(player, game.ref(7, 10)); + const spawn = player.canBuild(UnitType.Port, game.ref(7, 10)); + if (spawn === false) { + throw new Error("Unable to build port for test"); + } + const port = player.buildUnit(UnitType.Port, spawn, {}); + const execution = new PortExecution(port); execution.init(game, 0); execution.tick(0); @@ -78,7 +88,12 @@ describe("PortExecution", () => { game.config().tradeShipShortRangeDebuff = () => 100; player.conquer(game.ref(7, 10)); - const execution = new PortExecution(player, game.ref(7, 10)); + const spawn = player.canBuild(UnitType.Port, game.ref(7, 10)); + if (spawn === false) { + throw new Error("Unable to build port for test"); + } + const port = player.buildUnit(UnitType.Port, spawn, {}); + const execution = new PortExecution(port); execution.init(game, 0); execution.tick(0); diff --git a/tests/ShellRandom.test.ts b/tests/ShellRandom.test.ts index 7466087ad..19ec5ed52 100644 --- a/tests/ShellRandom.test.ts +++ b/tests/ShellRandom.test.ts @@ -145,7 +145,13 @@ describe("Shell Random Damage", () => { }); test("Defense post shell attacks have random damage", () => { - const defensePost = new DefensePostExecution(player1, game.ref(coastX, 5)); + player1.conquer(game.ref(coastX, 5)); + const spawn = player1.canBuild(UnitType.DefensePost, game.ref(coastX, 5)); + if (spawn === false) { + throw new Error("Unable to build defense post for test"); + } + const defensePostUnit = player1.buildUnit(UnitType.DefensePost, spawn, {}); + const defensePost = new DefensePostExecution(defensePostUnit); const target = player2.buildUnit( UnitType.Warship, diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts index c899ca079..3b544ee70 100644 --- a/tests/client/graphics/UILayer.test.ts +++ b/tests/client/graphics/UILayer.test.ts @@ -121,8 +121,8 @@ describe("UILayer", () => { ui.redraw(); const unit = { id: () => 2, - type: () => "Construction", - constructionType: () => "City", + type: () => "City", + isUnderConstruction: () => true, owner: () => ({ id: () => 1 }), tile: () => ({}), isActive: () => true, @@ -141,17 +141,20 @@ describe("UILayer", () => { ui.redraw(); const unit = { id: () => 2, - type: () => "Construction", - constructionType: () => "City", + type: () => "City", + isUnderConstruction: () => true, owner: () => ({ id: () => 1 }), tile: () => ({}), isActive: () => true, createdAt: () => 1, + markedForDeletion: () => false, } as unknown as UnitView; ui.onUnitEvent(unit); expect(ui["allProgressBars"].has(2)).toBe(true); game.ticks = () => 6; // simulate enough ticks for completion + // simulate construction finished + (unit as any).isUnderConstruction = () => false; ui.tick(); expect(ui["allProgressBars"].has(2)).toBe(false); }); diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index 5ed577f44..00810eb89 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -40,6 +40,8 @@ describe("NukeExecution", () => { player = game.player("player_id"); otherPlayer = game.player("other_id"); + + player.conquer(game.ref(1, 1)); }); test("nuke should destroy buildings and redraw out of range buildings", async () => { @@ -76,6 +78,7 @@ describe("NukeExecution", () => { }); test("nuke should only be targetable near src and dst", async () => { + player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {}); const nukeExec = new NukeExecution( UnitType.AtomBomb, player, diff --git a/tests/economy/ConstructionGold.test.ts b/tests/economy/ConstructionGold.test.ts new file mode 100644 index 000000000..b083bc746 --- /dev/null +++ b/tests/economy/ConstructionGold.test.ts @@ -0,0 +1,71 @@ +import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution"; +import { SpawnExecution } from "../../src/core/execution/SpawnExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../../src/core/game/Game"; +import { setup } from "../util/Setup"; + +describe("Construction economy", () => { + let game: Game; + let player: Player; + + beforeEach(async () => { + game = await setup("ocean_and_land", { + infiniteGold: false, + instantBuild: false, + infiniteTroops: true, + }); + const info = new PlayerInfo( + "builder", + PlayerType.Human, + null, + "builder_id", + ); + game.addPlayer(info); + const spawn = game.ref(0, 10); + game.addExecution(new SpawnExecution(info, spawn)); + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + player = game.player(info.id); + }); + + test("City charges gold once and no refund thereafter (allow passive income)", () => { + const target = game.ref(0, 10); + const cost = game.unitInfo(UnitType.City).cost(player); + player.addGold(cost); + expect(player.gold()).toBe(cost); + + const startTick = game.ticks(); + game.addExecution(new ConstructionExecution(player, UnitType.City, target)); + + // First tick usually initializes the execution, second tick performs build and deduction + game.executeNextTick(); + game.executeNextTick(); + const afterBuild = player.gold(); + const ticksAfterBuild = BigInt(game.ticks() - startTick); + const passivePerTick = 100n; // DefaultConfig goldAdditionRate for humans + expect(afterBuild < cost).toBe(true); // cost was deducted + expect(afterBuild <= ticksAfterBuild * passivePerTick).toBe(true); // only passive income allowed + + // Advance through construction duration + const duration = game.unitInfo(UnitType.City).constructionDuration ?? 0; + for (let i = 0; i <= duration + 2; i++) game.executeNextTick(); + + const finalGold = player.gold(); + const ticksElapsed = BigInt(game.ticks() - startTick); + // Ensure no refund equal to cost snuck back in; only passive income accumulated + expect(finalGold < cost).toBe(true); + expect(finalGold <= ticksElapsed * passivePerTick).toBe(true); + + // Structure exists and is active + expect(player.units(UnitType.City)).toHaveLength(1); + expect( + (player.units(UnitType.City)[0] as any).isUnderConstruction?.() ?? false, + ).toBe(false); + }); +}); diff --git a/tests/nukes/HydrogenAndMirv.test.ts b/tests/nukes/HydrogenAndMirv.test.ts new file mode 100644 index 000000000..a70986079 --- /dev/null +++ b/tests/nukes/HydrogenAndMirv.test.ts @@ -0,0 +1,197 @@ +import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution"; +import { SpawnExecution } from "../../src/core/execution/SpawnExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../../src/core/game/Game"; +import { setup } from "../util/Setup"; + +describe("Hydrogen Bomb and MIRV flows", () => { + let game: Game; + let player: Player; + + beforeEach(async () => { + game = await setup("plains", { infiniteGold: true, instantBuild: true }); + const info = new PlayerInfo("p", PlayerType.Human, null, "p"); + game.addPlayer(info); + game.addExecution(new SpawnExecution(info, game.ref(1, 1))); + while (game.inSpawnPhase()) game.executeNextTick(); + player = game.player(info.id); + + player.conquer(game.ref(1, 1)); + }); + + test("Hydrogen bomb launches when silo exists and cannot use silo under construction", () => { + // Build a silo instantly and launch Hydrogen Bomb + game.addExecution( + new ConstructionExecution(player, UnitType.MissileSilo, game.ref(1, 1)), + ); + game.executeNextTick(); + game.executeNextTick(); + expect(player.units(UnitType.MissileSilo)).toHaveLength(1); + + // Launch Hydrogen Bomb + const target = game.ref(7, 7); + game.addExecution( + new ConstructionExecution(player, UnitType.HydrogenBomb, target), + ); + game.executeNextTick(); + game.executeNextTick(); + game.executeNextTick(); + expect(player.units(UnitType.HydrogenBomb).length).toBeGreaterThan(0); + + // Now build another silo with construction time and ensure it won't be used + // Use non-instant config by simulating an under-construction flag on a new silo + // (Use normal construction with default duration in a fresh game instance) + }); + + test("Hydrogen bomb launch fails when silo is under construction and succeeds after completion", async () => { + // Set up a game without instantBuild to test construction duration + const gameWithConstruction = await setup("plains", { + infiniteGold: false, + instantBuild: false, + }); + const info = new PlayerInfo("p", PlayerType.Human, null, "p"); + gameWithConstruction.addPlayer(info); + gameWithConstruction.addExecution( + new SpawnExecution(info, gameWithConstruction.ref(1, 1)), + ); + while (gameWithConstruction.inSpawnPhase()) + gameWithConstruction.executeNextTick(); + const playerWithConstruction = gameWithConstruction.player(info.id); + + playerWithConstruction.conquer(gameWithConstruction.ref(1, 1)); + const siloTile = gameWithConstruction.ref(7, 7); + playerWithConstruction.conquer(siloTile); + + // Capture gold before starting silo construction + const goldBeforeSilo = playerWithConstruction.gold(); + const siloCost = gameWithConstruction + .unitInfo(UnitType.MissileSilo) + .cost(playerWithConstruction); + playerWithConstruction.addGold(siloCost); + + // Start construction of silo + gameWithConstruction.addExecution( + new ConstructionExecution( + playerWithConstruction, + UnitType.MissileSilo, + siloTile, + ), + ); + gameWithConstruction.executeNextTick(); + gameWithConstruction.executeNextTick(); + + // Verify silo exists and is under construction + const silos = playerWithConstruction.units(UnitType.MissileSilo); + expect(silos.length).toBe(1); + const silo = silos[0]; + expect(silo.isUnderConstruction()).toBe(true); + + // Capture gold after construction started + const goldAfterConstruction = playerWithConstruction.gold(); + expect(goldAfterConstruction).toBeLessThan(goldBeforeSilo + siloCost); + + // Attempt to launch HydrogenBomb while silo is under construction + const targetTile = gameWithConstruction.ref(10, 10); + const hydrogenBombCountBefore = playerWithConstruction.units( + UnitType.HydrogenBomb, + ).length; + + const canBuildResult = playerWithConstruction.canBuild( + UnitType.HydrogenBomb, + targetTile, + ); + expect(canBuildResult).toBe(false); // Should fail because silo is under construction + + // Try to add execution - should fail + gameWithConstruction.addExecution( + new ConstructionExecution( + playerWithConstruction, + UnitType.HydrogenBomb, + targetTile, + ), + ); + gameWithConstruction.executeNextTick(); + gameWithConstruction.executeNextTick(); + + // Assert launch does not succeed + const hydrogenBombCountAfter = playerWithConstruction.units( + UnitType.HydrogenBomb, + ).length; + expect(hydrogenBombCountAfter).toBe(hydrogenBombCountBefore); + + // Assert no refunds during construction + const goldDuringConstruction = playerWithConstruction.gold(); + expect(goldDuringConstruction >= goldAfterConstruction).toBe(true); + + // Advance ticks to complete construction + const constructionDuration = + gameWithConstruction.unitInfo(UnitType.MissileSilo) + .constructionDuration ?? 0; + for (let i = 0; i < constructionDuration + 2; i++) { + gameWithConstruction.executeNextTick(); + } + + // Verify silo is complete + const completedSilo = playerWithConstruction.units(UnitType.MissileSilo)[0]; + expect(completedSilo.isUnderConstruction()).toBe(false); + + // Now launch should succeed - ensure we have gold and target is conquered + playerWithConstruction.conquer(targetTile); + const hydrogenBombCost = gameWithConstruction + .unitInfo(UnitType.HydrogenBomb) + .cost(playerWithConstruction); + playerWithConstruction.addGold(hydrogenBombCost); + + const canBuildAfterCompletion = playerWithConstruction.canBuild( + UnitType.HydrogenBomb, + targetTile, + ); + expect(canBuildAfterCompletion).not.toBe(false); + + gameWithConstruction.addExecution( + new ConstructionExecution( + playerWithConstruction, + UnitType.HydrogenBomb, + targetTile, + ), + ); + gameWithConstruction.executeNextTick(); + gameWithConstruction.executeNextTick(); + gameWithConstruction.executeNextTick(); + + // Verify launch succeeded + const hydrogenBombCountAfterSuccess = playerWithConstruction.units( + UnitType.HydrogenBomb, + ).length; + expect(hydrogenBombCountAfterSuccess).toBeGreaterThan( + hydrogenBombCountBefore, + ); + }); + + test("MIRV launches when silo exists and targets player-owned tiles", () => { + // Build a silo instantly + game.addExecution( + new ConstructionExecution(player, UnitType.MissileSilo, game.ref(1, 1)), + ); + game.executeNextTick(); + game.executeNextTick(); + expect(player.units(UnitType.MissileSilo)).toHaveLength(1); + + // Launch MIRV at a player-owned tile (the silo tile) + const target = game.ref(1, 1); + game.addExecution(new ConstructionExecution(player, UnitType.MIRV, target)); + game.executeNextTick(); // init + game.executeNextTick(); // create MIRV unit + game.executeNextTick(); + + // MIRV should appear briefly before separation, otherwise warheads should be queued + const mirvs = player.units(UnitType.MIRV).length; + const warheads = player.units(UnitType.MIRVWarhead).length; + expect(mirvs > 0 || warheads > 0).toBe(true); + }); +});