mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user