diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 29e5bd379f..7eeb88f29d 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 09ac75814b..9414027e4a 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 3504eed052..1458e7affd 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 f0c0d6c5e5..55ec7dd89b 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 84a523ecfd..26c9fd4d4e 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -89,6 +89,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 43092a6427..5cd90abb5b 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -103,16 +103,15 @@ export class UILayer implements Layer { } onUnitEvent(unit: UnitView) { + const underConst = + ( + unit as unknown as { isUnderConstruction?: () => boolean } + ).isUnderConstruction?.() ?? false; + 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 +317,23 @@ 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 as unknown as { isUnderConstruction?: () => boolean } + ).isUnderConstruction?.() ?? false; + 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 2a4f1329e4..76c3585dd4 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -543,11 +543,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 be24d1bef1..7e028cd814 100644 --- a/src/core/execution/CityExecution.ts +++ b/src/core/execution/CityExecution.ts @@ -1,4 +1,4 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType, isUnit } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { TrainStationExecution } from "./TrainStationExecution"; @@ -7,33 +7,47 @@ export class CityExecution implements Execution { private city: Unit | null = null; private active: boolean = true; + constructor(playerOrUnit: Unit); + constructor(playerOrUnit: Player, tile: TileRef); + constructor( - private player: Player, - private tile: TileRef, - ) {} + private playerOrUnit: Player | Unit, + private tile?: TileRef, + ) { + if (!isUnit(playerOrUnit) && tile === undefined) { + throw new Error("tile is required when playerOrUnit is a Player"); + } + } 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; + if (!this.city) { + if (isUnit(this.playerOrUnit)) { + this.city = this.playerOrUnit; + this.createStation(); + } else { + const spawnTile = this.playerOrUnit.canBuild(UnitType.City, this.tile!); + if (spawnTile === false) { + console.warn("cannot build city"); + this.active = false; + return; + } + this.city = this.playerOrUnit.buildUnit(UnitType.City, spawnTile, {}); + this.createStation(); } - this.city = this.player.buildUnit(UnitType.City, spawnTile, {}); - this.createStation(); } if (!this.city.isActive()) { this.active = false; return; } - if (this.player !== this.city.owner()) { - this.player = this.city.owner(); + if (!isUnit(this.playerOrUnit)) { + if (this.playerOrUnit !== this.city.owner()) { + this.playerOrUnit = this.city.owner(); + } } } diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 8217f497da..799556fe06 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; + 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.construction.isActive()) { + if (!this.structure.isActive()) { this.active = false; return; } - if (this.player !== this.construction.owner()) { - this.player = this.construction.owner(); + 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 ab36f81ae3..c02c2aae9f 100644 --- a/src/core/execution/DefensePostExecution.ts +++ b/src/core/execution/DefensePostExecution.ts @@ -1,4 +1,4 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType, isUnit } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { ShellExecution } from "./ShellExecution"; @@ -12,10 +12,17 @@ export class DefensePostExecution implements Execution { private alreadySentShell = new Set(); + constructor(playerOrUnit: Unit); + constructor(playerOrUnit: Player, tile: TileRef); + constructor( - private player: Player, - private tile: TileRef, - ) {} + private playerOrUnit: Player | Unit, + private tile?: TileRef, + ) { + if (!isUnit(playerOrUnit) && tile === undefined) { + throw new Error("tile is required when playerOrUnit is a Player"); + } + } init(mg: Game, ticks: number): void { this.mg = mg; @@ -46,21 +53,39 @@ 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; + if (isUnit(this.playerOrUnit)) { + this.post = this.playerOrUnit; + } else { + const spawnTile = this.playerOrUnit.canBuild( + UnitType.DefensePost, + this.tile!, + ); + if (spawnTile === false) { + console.warn("cannot build Defense Post"); + this.active = false; + return; + } + this.post = this.playerOrUnit.buildUnit( + UnitType.DefensePost, + spawnTile, + {}, + ); } - 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 (!isUnit(this.playerOrUnit)) { + if (this.playerOrUnit !== this.post.owner()) { + this.playerOrUnit = this.post.owner(); + } } if (this.target !== null && !this.target.isActive()) { diff --git a/src/core/execution/FactoryExecution.ts b/src/core/execution/FactoryExecution.ts index fd24de674d..db620697e6 100644 --- a/src/core/execution/FactoryExecution.ts +++ b/src/core/execution/FactoryExecution.ts @@ -1,4 +1,4 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType, isUnit } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { TrainStationExecution } from "./TrainStationExecution"; @@ -6,10 +6,18 @@ export class FactoryExecution implements Execution { private factory: Unit | null = null; private active: boolean = true; private game: Game; + + constructor(playerOrUnit: Unit); + constructor(playerOrUnit: Player, tile: TileRef); + constructor( - private player: Player, - private tile: TileRef, - ) {} + private playerOrUnit: Player | Unit, + private tile?: TileRef, + ) { + if (!isUnit(playerOrUnit) && tile === undefined) { + throw new Error("tile is required when playerOrUnit is a Player"); + } + } init(mg: Game, ticks: number): void { this.game = mg; @@ -17,22 +25,36 @@ export class FactoryExecution implements Execution { 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; + if (isUnit(this.playerOrUnit)) { + this.factory = this.playerOrUnit; + this.createStation(); + } else { + const spawnTile = this.playerOrUnit.canBuild( + UnitType.Factory, + this.tile!, + ); + if (spawnTile === false) { + console.warn("cannot build factory"); + this.active = false; + return; + } + this.factory = this.playerOrUnit.buildUnit( + UnitType.Factory, + spawnTile, + {}, + ); + this.createStation(); } - this.factory = this.player.buildUnit(UnitType.Factory, spawnTile, {}); - this.createStation(); } if (!this.factory.isActive()) { this.active = false; return; } - if (this.player !== this.factory.owner()) { - this.player = this.factory.owner(); + if (!isUnit(this.playerOrUnit)) { + if (this.playerOrUnit !== this.factory.owner()) { + this.playerOrUnit = this.factory.owner(); + } } } diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index a7cd2bb6c0..fb58a33047 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -1,4 +1,4 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType, isUnit } from "../game/Game"; import { TileRef } from "../game/GameMap"; export class MissileSiloExecution implements Execution { @@ -6,10 +6,17 @@ export class MissileSiloExecution implements Execution { private mg: Game; private silo: Unit | null = null; + constructor(playerOrUnit: Unit); + constructor(playerOrUnit: Player, tile: TileRef); + constructor( - private player: Player, - private tile: TileRef, - ) {} + private playerOrUnit: Player | Unit, + private tile?: TileRef, + ) { + if (!isUnit(playerOrUnit) && tile === undefined) { + throw new Error("tile is required when playerOrUnit is a Player"); + } + } init(mg: Game, ticks: number): void { this.mg = mg; @@ -17,21 +24,36 @@ export class MissileSiloExecution implements Execution { 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}`, + if (isUnit(this.playerOrUnit)) { + this.silo = this.playerOrUnit; + } else { + const spawn = this.playerOrUnit.canBuild( + UnitType.MissileSilo, + this.tile!, + ); + if (spawn === false) { + console.warn( + `player ${this.playerOrUnit} cannot build missile silo at ${this.tile}`, + ); + this.active = false; + return; + } + this.silo = this.playerOrUnit.buildUnit( + UnitType.MissileSilo, + spawn, + {}, ); - 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.playerOrUnit !== this.silo.owner()) { + this.playerOrUnit = this.silo.owner(); + } } } + if (this.silo.isUnderConstruction()) { + return; + } + // frontTime is the time the earliest missile fired. const frontTime = this.silo.missileTimerQueue()[0]; if (frontTime === undefined) { diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 1fd9222bb0..923535f1fe 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 de7b707521..cbd8ccd227 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -1,4 +1,4 @@ -import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType, isUnit } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { TradeShipExecution } from "./TradeShipExecution"; @@ -11,10 +11,17 @@ export class PortExecution implements Execution { private random: PseudoRandom; private checkOffset: number; + constructor(playerOrUnit: Unit); + constructor(playerOrUnit: Player, tile: TileRef); + constructor( - private player: Player, - private tile: TileRef, - ) {} + private playerOrUnit: Player | Unit, + private tile?: TileRef, + ) { + if (!isUnit(playerOrUnit) && tile === undefined) { + throw new Error("tile is required when playerOrUnit is a Player"); + } + } init(mg: Game, ticks: number): void { this.mg = mg; @@ -27,17 +34,20 @@ export class PortExecution implements Execution { 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; + if (isUnit(this.playerOrUnit)) { + this.port = this.playerOrUnit; + } else { + const tile = this.tile!; + const spawn = this.playerOrUnit.canBuild(UnitType.Port, tile); + if (spawn === false) { + console.warn( + `player ${this.playerOrUnit.id()} cannot build port at ${this.tile}`, + ); + this.active = false; + return; + } + this.port = this.playerOrUnit.buildUnit(UnitType.Port, spawn, {}); } - this.port = this.player.buildUnit(UnitType.Port, spawn, {}); - this.createStation(); } if (!this.port.isActive()) { @@ -45,8 +55,12 @@ export class PortExecution implements Execution { 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 +79,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 +94,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); diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 33b909b108..c669b42fd2 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 37914f63d3..12aecd28d1 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 5854a1e5b1..2c23ad8641 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 706be36d8a..6adb476e4d 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 ccceacef9c..62bcc36845 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 f85cc7aab2..ee5be7a383 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -228,8 +228,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; @@ -252,12 +252,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; } @@ -984,7 +984,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); @@ -998,10 +997,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) { diff --git a/src/core/game/UnitGrid.ts b/src/core/game/UnitGrid.ts index 7d1a1b7386..68b20fac52 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 917d0af671..15f5f88f8b 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -25,7 +25,7 @@ export class UnitImpl implements Unit { private _targetedBySAM = false; private _reachedTarget = 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. @@ -131,7 +131,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, @@ -302,19 +302,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/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts index c899ca0799..3b544ee700 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 5ed577f447..00810eb89e 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 0000000000..b083bc746a --- /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 0000000000..a70986079e --- /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); + }); +});