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 14:45:14 -08:00
committed by GitHub
co-authored by Evan
parent c341aafaf9
commit 8f53785a80
28 changed files with 528 additions and 275 deletions
+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) {