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 <evanpelle@gmail.com>
This commit is contained in:
CrackeRR11
2025-11-26 23:45:14 +01:00
committed by GitHub
parent c341aafaf9
commit 8f53785a80
28 changed files with 528 additions and 275 deletions
@@ -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,
@@ -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(),
@@ -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);
+4 -4
View File
@@ -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;
}
@@ -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()))
+18 -24
View File
@@ -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()
-5
View File
@@ -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,
+13 -29
View File
@@ -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));
}
}
}
+52 -36
View File
@@ -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;
}
+5 -19
View File
@@ -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<Unit>();
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()) {
+16 -31
View File
@@ -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));
}
}
}
+7 -21
View File
@@ -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.
+1 -1
View File
@@ -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;
+25 -34
View File
@@ -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));
}
}
@@ -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) {
+5 -7
View File
@@ -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<UnitType> = 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<string, never>;
}
// 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;
+10 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
+20 -12
View File
@@ -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 &&
+21 -1
View File
@@ -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;
}
}
+8 -12
View File
@@ -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 {
+18 -3
View File
@@ -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);
+7 -1
View File
@@ -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,
+7 -4
View File
@@ -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);
});
@@ -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,
+71
View File
@@ -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);
});
});
+197
View File
@@ -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);
});
});