diff --git a/resources/images/buildings/landMine1.png b/resources/images/buildings/landMine1.png
new file mode 100644
index 0000000000..dc433d6bbd
Binary files /dev/null and b/resources/images/buildings/landMine1.png differ
diff --git a/resources/lang/en.json b/resources/lang/en.json
index da20af0646..7049a0c796 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -380,7 +380,8 @@
"atom_bomb": "Atom Bomb",
"hydrogen_bomb": "Hydrogen Bomb",
"mirv": "MIRV",
- "factory": "Factory"
+ "factory": "Factory",
+ "land_mine": "Land Mine"
},
"user_setting": {
"title": "User Settings",
@@ -425,6 +426,8 @@
"build_factory_desc": "Build a Factory under your cursor.",
"build_defense_post": "Build Defense Post",
"build_defense_post_desc": "Build a Defense Post under your cursor.",
+ "build_land_mine": "Build Land Mine",
+ "build_land_mine_desc": "Build a Land Mine under your cursor.",
"build_port": "Build Port",
"build_port_desc": "Build a Port under your cursor.",
"build_warship": "Build Warship",
@@ -574,7 +577,8 @@
"port": "Sends trade ships to generate gold",
"defense_post": "Increases defenses of nearby borders",
"city": "Increases max population",
- "factory": "Creates railroads and spawns trains"
+ "factory": "Creates railroads and spawns trains",
+ "land_mine": "Explodes upon enemy capture"
},
"not_enough_money": "Not enough money"
},
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index d01798445b..5455397ecb 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -211,6 +211,7 @@ export class InputHandler {
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
+ buildLandMine: "KeyM",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
@@ -402,6 +403,11 @@ export class InputHandler {
this.setGhostStructure(UnitType.DefensePost);
}
+ if (e.code === this.keybinds.buildLandMine) {
+ e.preventDefault();
+ this.setGhostStructure(UnitType.LandMine);
+ }
+
if (e.code === this.keybinds.buildMissileSilo) {
e.preventDefault();
this.setGhostStructure(UnitType.MissileSilo);
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index 39f2967c77..0d007465db 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -484,6 +484,15 @@ export class UserSettingModal extends LitElement {
@change=${this.handleKeybindChange}
>
+
+
> = {
[UnitType.DefensePost]: "octagon",
[UnitType.SAMLauncher]: "square",
[UnitType.MissileSilo]: "triangle",
+ [UnitType.LandMine]: "circle",
[UnitType.Warship]: "cross",
[UnitType.AtomBomb]: "cross",
[UnitType.HydrogenBomb]: "cross",
@@ -62,6 +64,7 @@ export class SpriteFactory {
[UnitType.Port, { iconPath: anchorIcon, image: null }],
[UnitType.MissileSilo, { iconPath: missileSiloIcon, image: null }],
[UnitType.SAMLauncher, { iconPath: SAMMissileIcon, image: null }],
+ [UnitType.LandMine, { iconPath: landMineIcon, image: null }],
]);
constructor(
theme: Theme,
diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts
index 0c896f7176..35e3c08729 100644
--- a/src/client/graphics/layers/StructureIconsLayer.ts
+++ b/src/client/graphics/layers/StructureIconsLayer.ts
@@ -91,6 +91,7 @@ export class StructureIconsLayer implements Layer {
[UnitType.Port, { visible: true }],
[UnitType.MissileSilo, { visible: true }],
[UnitType.SAMLauncher, { visible: true }],
+ [UnitType.LandMine, { visible: true }],
]);
private lastGhostQueryAt: number;
potentialUpgrade: StructureRenderInfo | undefined;
diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts
index 819c4e3f74..0f0d27dbba 100644
--- a/src/client/graphics/layers/StructureLayer.ts
+++ b/src/client/graphics/layers/StructureLayer.ts
@@ -11,6 +11,7 @@ import { GameView, UnitView } from "../../../core/game/GameView";
import cityIcon from "/images/buildings/cityAlt1.png?url";
import factoryIcon from "/images/buildings/factoryAlt1.png?url";
import shieldIcon from "/images/buildings/fortAlt3.png?url";
+import landMineIcon from "/images/buildings/landMine1.png?url";
import anchorIcon from "/images/buildings/port1.png?url";
import missileSiloIcon from "/images/buildings/silo1.png?url";
import SAMMissileIcon from "/images/buildings/silo4.png?url";
@@ -69,6 +70,11 @@ export class StructureLayer implements Layer {
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
},
+ [UnitType.LandMine]: {
+ icon: landMineIcon,
+ borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
+ territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
+ },
};
constructor(
diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts
index 6ea196ec37..f1f5a6edee 100644
--- a/src/client/graphics/layers/UnitDisplay.ts
+++ b/src/client/graphics/layers/UnitDisplay.ts
@@ -12,6 +12,7 @@ import { UIState } from "../UIState";
import { Layer } from "./Layer";
import warshipIcon from "/images/BattleshipIconWhite.svg?url";
import cityIcon from "/images/CityIconWhite.svg?url";
+import landMineIcon from "/images/ExplosionIconWhite.svg?url";
import factoryIcon from "/images/FactoryIconWhite.svg?url";
import mirvIcon from "/images/MIRVIcon.svg?url";
import missileSiloIcon from "/images/MissileSiloIconWhite.svg?url";
@@ -34,6 +35,7 @@ export class UnitDisplay extends LitElement implements Layer {
private _missileSilo = 0;
private _port = 0;
private _defensePost = 0;
+ private _landMine = 0;
private _samLauncher = 0;
private allDisabled = false;
private _hoveredUnit: UnitType | null = null;
@@ -59,6 +61,7 @@ export class UnitDisplay extends LitElement implements Layer {
config.isUnitDisabled(UnitType.Factory) &&
config.isUnitDisabled(UnitType.Port) &&
config.isUnitDisabled(UnitType.DefensePost) &&
+ config.isUnitDisabled(UnitType.LandMine) &&
config.isUnitDisabled(UnitType.MissileSilo) &&
config.isUnitDisabled(UnitType.SAMLauncher) &&
config.isUnitDisabled(UnitType.Warship) &&
@@ -108,6 +111,7 @@ export class UnitDisplay extends LitElement implements Layer {
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
this._port = player.totalUnitLevels(UnitType.Port);
this._defensePost = player.totalUnitLevels(UnitType.DefensePost);
+ this._landMine = player.totalUnitLevels(UnitType.LandMine);
this._samLauncher = player.totalUnitLevels(UnitType.SAMLauncher);
this._factories = player.totalUnitLevels(UnitType.Factory);
this._warships = player.totalUnitLevels(UnitType.Warship);
@@ -162,6 +166,13 @@ export class UnitDisplay extends LitElement implements Layer {
"defense_post",
this.keybinds["buildDefensePost"]?.key ?? "4",
)}
+ ${this.renderUnitItem(
+ landMineIcon,
+ this._landMine,
+ UnitType.LandMine,
+ "land_mine",
+ this.keybinds["buildLandMine"]?.key ?? "M",
+ )}
${this.renderUnitItem(
missileSiloIcon,
this._missileSilo,
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 5e45612ea4..fba2dfafc5 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -75,6 +75,7 @@ export async function createGameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
+ clientID,
);
gr.init();
return gr;
@@ -84,6 +85,7 @@ export class GameRunner {
private turns: Turn[] = [];
private currTurn = 0;
private isExecuting = false;
+ private myPlayer: Player | null = null;
private playerViewData: Record = {};
@@ -91,6 +93,7 @@ export class GameRunner {
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
+ private clientID: ClientID,
) {}
init() {
@@ -169,6 +172,9 @@ export class GameRunner {
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
updates[GameUpdateType.Tile] = [];
+ // Filter out units that should be hidden from this player
+ this.filterHiddenUnits(updates);
+
this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
@@ -179,6 +185,47 @@ export class GameRunner {
this.isExecuting = false;
}
+ /**
+ * Filters out unit updates for units that should be hidden from this client.
+ * Units with visibleToEnemies: false are only visible to their owner and allies.
+ */
+ private filterHiddenUnits(updates: GameUpdates): void {
+ // Lazy-load our player reference
+ this.myPlayer ??= this.game.playerByClientID(this.clientID) ?? null;
+
+ updates[GameUpdateType.Unit] = updates[GameUpdateType.Unit].filter(
+ (unitUpdate) => {
+ const unitInfo = this.game.config().unitInfo(unitUpdate.unitType);
+
+ // If unit is visible to enemies (default), keep it
+ if (unitInfo.visibleToEnemies !== false) {
+ return true;
+ }
+
+ // Unit is hidden from enemies - check if we should see it
+ if (this.myPlayer === null) {
+ // Spectator - can't see hidden enemy units
+ return false;
+ }
+
+ // Check if we own or are allied with the owner
+ const ownerSmallID = unitUpdate.ownerID;
+ if (this.myPlayer.smallID() === ownerSmallID) {
+ return true; // We own it
+ }
+
+ // Check if owner is our ally
+ const owner = this.game.playerBySmallID(ownerSmallID);
+ if (owner.isPlayer() && this.myPlayer.isAlliedWith(owner)) {
+ return true; // Allied with owner
+ }
+
+ // Enemy unit that should be hidden
+ return false;
+ },
+ );
+ }
+
public playerActions(
playerID: PlayerID,
x?: number,
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index e3cc0da2cd..06b44b06f8 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -561,6 +561,16 @@ export class DefaultConfig implements Config {
territoryBound: false,
experimental: true,
};
+ case UnitType.LandMine:
+ return {
+ cost: this.costWrapper(
+ (numUnits: number) => Math.min(250_000, 75_000 + numUnits * 50_000),
+ UnitType.LandMine,
+ ),
+ territoryBound: true,
+ constructionDuration: this.instantBuild() ? 0 : 5 * 10,
+ visibleToEnemies: false,
+ };
default:
assertNever(type);
}
@@ -917,6 +927,8 @@ export class DefaultConfig implements Config {
return { inner: 12, outer: 30 };
case UnitType.HydrogenBomb:
return { inner: 80, outer: 100 };
+ case UnitType.LandMine:
+ return { inner: 6, outer: 15 };
}
throw new Error(`Unknown nuke type: ${unitType}`);
}
diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts
index 2fdd92da1c..041987f337 100644
--- a/src/core/execution/ConstructionExecution.ts
+++ b/src/core/execution/ConstructionExecution.ts
@@ -3,6 +3,7 @@ import { TileRef } from "../game/GameMap";
import { CityExecution } from "./CityExecution";
import { DefensePostExecution } from "./DefensePostExecution";
import { FactoryExecution } from "./FactoryExecution";
+import { LandMineExecution } from "./LandMineExecution";
import { MirvExecution } from "./MIRVExecution";
import { MissileSiloExecution } from "./MissileSiloExecution";
import { NukeExecution } from "./NukeExecution";
@@ -144,6 +145,9 @@ export class ConstructionExecution implements Execution {
case UnitType.Factory:
this.mg.addExecution(new FactoryExecution(this.structure!));
break;
+ case UnitType.LandMine:
+ this.mg.addExecution(new LandMineExecution(this.structure!));
+ break;
default:
console.warn(
`unit type ${this.constructionType} cannot be constructed`,
@@ -160,6 +164,7 @@ export class ConstructionExecution implements Execution {
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
+ case UnitType.LandMine:
return true;
default:
return false;
diff --git a/src/core/execution/LandMineExecution.ts b/src/core/execution/LandMineExecution.ts
new file mode 100644
index 0000000000..3129e5b0f3
--- /dev/null
+++ b/src/core/execution/LandMineExecution.ts
@@ -0,0 +1,181 @@
+import {
+ Execution,
+ Game,
+ isStructureType,
+ MessageType,
+ Player,
+ Unit,
+ UnitType,
+} from "../game/Game";
+import { TileRef } from "../game/GameMap";
+import { PseudoRandom } from "../PseudoRandom";
+
+const SPRITE_RADIUS = 16;
+
+export class LandMineExecution implements Execution {
+ private mg: Game;
+ private active: boolean = true;
+ private originalOwner: Player;
+
+ constructor(private mine: Unit) {
+ this.originalOwner = mine.owner();
+ }
+
+ init(mg: Game, ticks: number): void {
+ this.mg = mg;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+
+ tick(ticks: number): void {
+ if (!this.mine.isActive()) {
+ this.active = false;
+ return;
+ }
+
+ // Do nothing while the structure is under construction
+ if (this.mine.isUnderConstruction()) {
+ return;
+ }
+
+ // Check if the mine's tile has been captured by an enemy
+ const currentOwner = this.mg.owner(this.mine.tile());
+ if (!currentOwner.isPlayer()) {
+ // Tile is now terra nullius, delete the mine
+ this.mine.delete();
+ this.active = false;
+ return;
+ }
+
+ // If the tile is still owned by the original owner, do nothing
+ if (currentOwner === this.originalOwner) {
+ return;
+ }
+
+ // If captured by an ally of the original owner, transfer ownership
+ if (currentOwner.isFriendly(this.originalOwner)) {
+ currentOwner.captureUnit(this.mine);
+ this.originalOwner = currentOwner;
+ return;
+ }
+
+ // An enemy has captured the tile - DETONATE!
+ this.detonate(currentOwner);
+ }
+
+ private tilesToDestroy(attacker: Player): Set {
+ const magnitude = this.mg.config().nukeMagnitudes(UnitType.LandMine);
+ const rand = new PseudoRandom(this.mg.ticks());
+ const inner2 = magnitude.inner * magnitude.inner;
+ const outer2 = magnitude.outer * magnitude.outer;
+ const tile = this.mine.tile();
+
+ // Only include tiles owned by the attacker
+ return this.mg.bfs(tile, (_, n: TileRef) => {
+ const owner = this.mg.owner(n);
+ if (!owner.isPlayer() || owner !== attacker) {
+ return false;
+ }
+ const d2 = this.mg.euclideanDistSquared(tile, n);
+ return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
+ });
+ }
+
+ private detonate(attacker: Player) {
+ const magnitude = this.mg.config().nukeMagnitudes(UnitType.LandMine);
+ const tile = this.mine.tile();
+ const toDestroy = this.tilesToDestroy(attacker);
+
+ // Calculate max troops for death factor
+ const maxTroops = this.mg.config().maxTroops(attacker);
+
+ // Only damage the attacker's territory and troops
+ for (const t of toDestroy) {
+ attacker.relinquish(t);
+ attacker.removeTroops(
+ this.mg.config().nukeDeathFactor(
+ UnitType.AtomBomb, // Use atom bomb death factor calculation
+ attacker.troops(),
+ attacker.numTilesOwned(),
+ maxTroops,
+ ),
+ );
+
+ if (this.mg.isLand(t)) {
+ this.mg.setFallout(t, true);
+ }
+ }
+
+ // Also damage attacker's outgoing attacks
+ attacker.outgoingAttacks().forEach((attack) => {
+ const deaths = this.mg
+ .config()
+ .nukeDeathFactor(
+ UnitType.AtomBomb,
+ attack.troops(),
+ attacker.numTilesOwned(),
+ maxTroops,
+ );
+ attack.setTroops(attack.troops() - deaths);
+ });
+
+ // Destroy attacker's units in blast radius (excluding nukes in flight)
+ const outer2 = magnitude.outer * magnitude.outer;
+ for (const unit of this.mg.units()) {
+ // Skip units not owned by the attacker
+ if (unit.owner() !== attacker) {
+ continue;
+ }
+
+ if (
+ unit.type() !== UnitType.AtomBomb &&
+ unit.type() !== UnitType.HydrogenBomb &&
+ unit.type() !== UnitType.MIRVWarhead &&
+ unit.type() !== UnitType.MIRV
+ ) {
+ if (this.mg.euclideanDistSquared(tile, unit.tile()) < outer2) {
+ unit.delete(true, this.originalOwner);
+ }
+ }
+ }
+
+ // Notify the attacker
+ this.mg.displayMessage(
+ `You triggered a land mine placed by ${this.originalOwner.displayName()}!`,
+ MessageType.NUKE_INBOUND,
+ attacker.id(),
+ );
+
+ // Notify the mine owner
+ this.mg.displayMessage(
+ `Your land mine was triggered by ${attacker.displayName()}!`,
+ MessageType.CAPTURED_ENEMY_UNIT,
+ this.originalOwner.id(),
+ );
+
+ // Redraw buildings in the area
+ this.redrawBuildings(magnitude.outer + SPRITE_RADIUS);
+
+ // Delete the mine
+ this.mine.delete(false);
+ this.active = false;
+ }
+
+ private redrawBuildings(range: number) {
+ const tile = this.mine.tile();
+ const rangeSquared = range * range;
+ for (const unit of this.mg.units()) {
+ if (isStructureType(unit.type())) {
+ if (this.mg.euclideanDistSquared(tile, unit.tile()) < rangeSquared) {
+ unit.touch();
+ }
+ }
+ }
+ }
+
+ isActive(): boolean {
+ return this.active;
+ }
+}
diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts
index c7e452e5d3..4f7752819b 100644
--- a/src/core/execution/PlayerExecution.ts
+++ b/src/core/execution/PlayerExecution.ts
@@ -54,6 +54,10 @@ export class PlayerExecution implements Execution {
if (u.isActive()) {
captor.captureUnit(u);
}
+ } else if (u.type() === UnitType.LandMine) {
+ if (u.isUnderConstruction()) {
+ u.delete();
+ }
} else {
captor.captureUnit(u);
}
diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts
index b01df682a2..70e0f9d8be 100644
--- a/src/core/execution/nation/structureSpawnTileValue.ts
+++ b/src/core/execution/nation/structureSpawnTileValue.ts
@@ -148,6 +148,47 @@ export function structureSpawnTileValue(
return w;
};
}
+ case UnitType.LandMine: {
+ // Land mines should be placed near borders where enemies are likely to attack
+ return (tile) => {
+ let w = 0;
+
+ // Prefer to be close to the border (where enemies will attack)
+ const [closest, closestBorderDist] = closestTile(mg, borderTiles, tile);
+ if (closest !== null) {
+ // Prefer tiles close to the border but not right on it
+ w += Math.max(0, borderSpacing - closestBorderDist);
+
+ // Prefer adjacent players who are hostile
+ const neighbors: Set = new Set();
+ for (const neighborTile of mg.neighbors(closest)) {
+ if (!mg.isLand(neighborTile)) continue;
+ const id = mg.ownerID(neighborTile);
+ if (id === player.smallID()) continue;
+ const neighbor = mg.playerBySmallID(id);
+ if (!neighbor.isPlayer()) continue;
+ neighbors.add(neighbor);
+ }
+ for (const neighbor of neighbors) {
+ w +=
+ borderSpacing * (Relation.Friendly - player.relation(neighbor));
+ }
+ }
+
+ // Prefer to be away from other land mines
+ const otherTiles: Set = new Set(
+ otherUnits.map((u) => u.tile()),
+ );
+ otherTiles.delete(tile);
+ const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
+ if (closestOther !== null) {
+ const d = mg.manhattanDist(closestOther.x, tile);
+ w += Math.min(d, structureSpacing);
+ }
+
+ return w;
+ };
+ }
default:
throw new Error(`Value function not implemented for ${type}`);
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index cefcc90e87..c94a4b445f 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -208,6 +208,7 @@ export interface UnitInfo {
upgradable?: boolean;
canBuildTrainStation?: boolean;
experimental?: boolean;
+ visibleToEnemies?: boolean;
}
export enum UnitType {
@@ -227,6 +228,7 @@ export enum UnitType {
MIRVWarhead = "MIRV Warhead",
Train = "Train",
Factory = "Factory",
+ LandMine = "Land Mine",
}
export enum TrainType {
@@ -242,6 +244,7 @@ const _structureTypes: ReadonlySet = new Set([
UnitType.MissileSilo,
UnitType.Port,
UnitType.Factory,
+ UnitType.LandMine,
]);
export function isStructureType(type: UnitType): boolean {
@@ -310,6 +313,8 @@ export interface UnitParamsMap {
[UnitType.MIRVWarhead]: {
targetTile?: number;
};
+
+ [UnitType.LandMine]: Record;
}
// Type helper to get params type for a specific unit type
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 09c02c5a72..faa802ae04 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -1003,6 +1003,7 @@ export class PlayerImpl implements Player {
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
+ case UnitType.LandMine:
return this.landBasedStructureSpawn(targetTile, validTiles);
default:
assertNever(unitType);
diff --git a/tests/LandMine.test.ts b/tests/LandMine.test.ts
new file mode 100644
index 0000000000..97b8e0c41a
--- /dev/null
+++ b/tests/LandMine.test.ts
@@ -0,0 +1,527 @@
+import { AttackExecution } from "../src/core/execution/AttackExecution";
+import { ConstructionExecution } from "../src/core/execution/ConstructionExecution";
+import { SpawnExecution } from "../src/core/execution/SpawnExecution";
+import {
+ Game,
+ GameUpdates,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../src/core/game/Game";
+import { TileRef } from "../src/core/game/GameMap";
+import { GameUpdateType, UnitUpdate } from "../src/core/game/GameUpdates";
+import { GameID } from "../src/core/Schemas";
+import { setup } from "./util/Setup";
+import { constructionExecution, executeTicks } from "./util/utils";
+
+const gameID: GameID = "game_id";
+let game: Game;
+let defender: Player;
+let attacker: Player;
+let defenderSpawn: TileRef;
+let attackerSpawn: TileRef;
+
+describe("LandMine", () => {
+ beforeEach(async () => {
+ game = await setup("plains", {
+ infiniteGold: true,
+ instantBuild: true,
+ infiniteTroops: true,
+ });
+
+ const defenderInfo = new PlayerInfo(
+ "defender",
+ PlayerType.Human,
+ null,
+ "defender_id",
+ );
+ game.addPlayer(defenderInfo);
+
+ const attackerInfo = new PlayerInfo(
+ "attacker",
+ PlayerType.Human,
+ null,
+ "attacker_id",
+ );
+ game.addPlayer(attackerInfo);
+
+ defenderSpawn = game.ref(10, 10);
+ attackerSpawn = game.ref(20, 10);
+
+ game.addExecution(
+ new SpawnExecution(
+ gameID,
+ game.player(defenderInfo.id).info(),
+ defenderSpawn,
+ ),
+ new SpawnExecution(
+ gameID,
+ game.player(attackerInfo.id).info(),
+ attackerSpawn,
+ ),
+ );
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+
+ defender = game.player(defenderInfo.id);
+ attacker = game.player(attackerInfo.id);
+
+ game.addExecution(
+ new AttackExecution(50, defender, game.terraNullius().id()),
+ );
+ game.addExecution(
+ new AttackExecution(50, attacker, game.terraNullius().id()),
+ );
+
+ for (let i = 0; i < 50; i++) {
+ game.executeNextTick();
+ }
+ });
+
+ test("land mine should be buildable", async () => {
+ constructionExecution(game, defender, 10, 10, UnitType.LandMine);
+ expect(defender.units(UnitType.LandMine)).toHaveLength(1);
+ });
+
+ test("land mine should explode when enemy captures the tile", async () => {
+ constructionExecution(game, defender, 10, 10, UnitType.LandMine);
+ const mine = defender.units(UnitType.LandMine)[0];
+ expect(mine).toBeDefined();
+ expect(mine.isActive()).toBe(true);
+
+ const mineTile = mine.tile();
+
+ game.addExecution(new AttackExecution(100, attacker, defender.id()));
+
+ let ticks = 0;
+ const maxTicks = 500;
+ while (game.owner(mineTile) !== attacker && ticks < maxTicks) {
+ game.executeNextTick();
+ ticks++;
+ }
+
+ executeTicks(game, 5);
+
+ expect(mine.isActive()).toBe(false);
+ });
+
+ test("land mine explosion should only hurt the attacker, not the original owner", async () => {
+ constructionExecution(game, defender, 10, 10, UnitType.LandMine);
+ const mine = defender.units(UnitType.LandMine)[0];
+ const mineTile = mine.tile();
+
+ game.addExecution(new AttackExecution(100, attacker, defender.id()));
+
+ let ticks = 0;
+ const maxTicks = 500;
+ while (game.owner(mineTile) !== attacker && ticks < maxTicks) {
+ game.executeNextTick();
+ ticks++;
+ }
+
+ const defenderTilesAfterCapture = defender.numTilesOwned();
+
+ executeTicks(game, 5);
+
+ expect(defender.numTilesOwned()).toBeGreaterThanOrEqual(
+ defenderTilesAfterCapture - 5,
+ );
+ });
+
+ test("land mine should NOT explode while under construction", async () => {
+ const slowBuildGame = await setup("plains", {
+ infiniteGold: true,
+ instantBuild: false,
+ infiniteTroops: true,
+ });
+
+ const defenderInfo2 = new PlayerInfo(
+ "defender2",
+ PlayerType.Human,
+ null,
+ "defender2_id",
+ );
+ slowBuildGame.addPlayer(defenderInfo2);
+
+ const attackerInfo2 = new PlayerInfo(
+ "attacker2",
+ PlayerType.Human,
+ null,
+ "attacker2_id",
+ );
+ slowBuildGame.addPlayer(attackerInfo2);
+
+ slowBuildGame.addExecution(
+ new SpawnExecution(
+ gameID,
+ slowBuildGame.player(defenderInfo2.id).info(),
+ slowBuildGame.ref(10, 10),
+ ),
+ new SpawnExecution(
+ gameID,
+ slowBuildGame.player(attackerInfo2.id).info(),
+ slowBuildGame.ref(20, 10),
+ ),
+ );
+
+ while (slowBuildGame.inSpawnPhase()) {
+ slowBuildGame.executeNextTick();
+ }
+
+ const defender2 = slowBuildGame.player(defenderInfo2.id);
+ const attacker2 = slowBuildGame.player(attackerInfo2.id);
+
+ slowBuildGame.addExecution(
+ new AttackExecution(50, defender2, slowBuildGame.terraNullius().id()),
+ );
+ slowBuildGame.addExecution(
+ new AttackExecution(50, attacker2, slowBuildGame.terraNullius().id()),
+ );
+
+ for (let i = 0; i < 30; i++) {
+ slowBuildGame.executeNextTick();
+ }
+
+ slowBuildGame.addExecution(
+ new ConstructionExecution(
+ defender2,
+ UnitType.LandMine,
+ slowBuildGame.ref(10, 10),
+ ),
+ );
+
+ slowBuildGame.executeNextTick();
+ slowBuildGame.executeNextTick();
+
+ const mines = defender2.units(UnitType.LandMine);
+ expect(mines).toHaveLength(1);
+ const mine = mines[0];
+ expect(mine.isUnderConstruction()).toBe(true);
+
+ const mineTile = mine.tile();
+
+ const attackerTilesBefore = attacker2.numTilesOwned();
+
+ slowBuildGame.addExecution(
+ new AttackExecution(100, attacker2, defender2.id()),
+ );
+
+ let ticks = 0;
+ const maxTicks = 500;
+ while (slowBuildGame.owner(mineTile) !== attacker2 && ticks < maxTicks) {
+ slowBuildGame.executeNextTick();
+ ticks++;
+ }
+
+ executeTicks(slowBuildGame, 5);
+
+ expect(attacker2.numTilesOwned()).toBeGreaterThan(attackerTilesBefore - 20);
+ });
+
+ test("land mine under construction should be destroyed when captured", async () => {
+ const slowBuildGame = await setup("plains", {
+ infiniteGold: true,
+ instantBuild: false,
+ infiniteTroops: true,
+ });
+
+ const defenderInfo2 = new PlayerInfo(
+ "defender2",
+ PlayerType.Human,
+ null,
+ "defender2_id",
+ );
+ slowBuildGame.addPlayer(defenderInfo2);
+
+ const attackerInfo2 = new PlayerInfo(
+ "attacker2",
+ PlayerType.Human,
+ null,
+ "attacker2_id",
+ );
+ slowBuildGame.addPlayer(attackerInfo2);
+
+ slowBuildGame.addExecution(
+ new SpawnExecution(
+ gameID,
+ slowBuildGame.player(defenderInfo2.id).info(),
+ slowBuildGame.ref(10, 10),
+ ),
+ new SpawnExecution(
+ gameID,
+ slowBuildGame.player(attackerInfo2.id).info(),
+ slowBuildGame.ref(20, 10),
+ ),
+ );
+
+ while (slowBuildGame.inSpawnPhase()) {
+ slowBuildGame.executeNextTick();
+ }
+
+ const defender2 = slowBuildGame.player(defenderInfo2.id);
+ const attacker2 = slowBuildGame.player(attackerInfo2.id);
+
+ slowBuildGame.addExecution(
+ new AttackExecution(50, defender2, slowBuildGame.terraNullius().id()),
+ );
+ slowBuildGame.addExecution(
+ new AttackExecution(50, attacker2, slowBuildGame.terraNullius().id()),
+ );
+
+ for (let i = 0; i < 30; i++) {
+ slowBuildGame.executeNextTick();
+ }
+
+ slowBuildGame.addExecution(
+ new ConstructionExecution(
+ defender2,
+ UnitType.LandMine,
+ slowBuildGame.ref(10, 10),
+ ),
+ );
+
+ slowBuildGame.executeNextTick();
+ slowBuildGame.executeNextTick();
+
+ const mines = defender2.units(UnitType.LandMine);
+ expect(mines).toHaveLength(1);
+ const mine = mines[0];
+ expect(mine.isUnderConstruction()).toBe(true);
+
+ const mineTile = mine.tile();
+
+ slowBuildGame.addExecution(
+ new AttackExecution(100, attacker2, defender2.id()),
+ );
+
+ let ticks = 0;
+ const maxTicks = 500;
+ while (slowBuildGame.owner(mineTile) !== attacker2 && ticks < maxTicks) {
+ slowBuildGame.executeNextTick();
+ ticks++;
+ }
+
+ expect(slowBuildGame.owner(mineTile)).toBe(attacker2);
+
+ executeTicks(slowBuildGame, 5);
+
+ expect(mine.isActive()).toBe(false);
+ expect(attacker2.units(UnitType.LandMine)).toHaveLength(0);
+
+ expect(defender2.units(UnitType.LandMine)).toHaveLength(0);
+ });
+
+ test("land mine should NOT explode when captured by ally", async () => {
+ const allyInfo = new PlayerInfo("ally", PlayerType.Human, null, "ally_id");
+ game.addPlayer(allyInfo);
+
+ game.addExecution(new SpawnExecution(gameID, allyInfo, game.ref(10, 20)));
+
+ for (let i = 0; i < 10; i++) {
+ game.executeNextTick();
+ }
+
+ const ally = game.player(allyInfo.id);
+
+ const allianceRequest = defender.createAllianceRequest(ally);
+ if (allianceRequest) {
+ allianceRequest.accept();
+ }
+
+ expect(defender.isAlliedWith(ally)).toBe(true);
+
+ constructionExecution(game, defender, 10, 10, UnitType.LandMine);
+ const mine = defender.units(UnitType.LandMine)[0];
+ expect(mine).toBeDefined();
+
+ const mineTile = mine.tile();
+
+ ally.conquer(mineTile);
+
+ executeTicks(game, 5);
+
+ const allyTiles = ally.numTilesOwned();
+ expect(allyTiles).toBeGreaterThan(0);
+ });
+
+ test("land mine detonation destroys the mine unit", async () => {
+ constructionExecution(game, defender, 10, 10, UnitType.LandMine);
+ const mine = defender.units(UnitType.LandMine)[0];
+ const mineTile = mine.tile();
+
+ game.addExecution(new AttackExecution(100, attacker, defender.id()));
+
+ let ticks = 0;
+ const maxTicks = 500;
+ while (game.owner(mineTile) !== attacker && ticks < maxTicks) {
+ game.executeNextTick();
+ ticks++;
+ }
+
+ executeTicks(game, 5);
+
+ expect(mine.isActive()).toBe(false);
+ expect(defender.units(UnitType.LandMine)).toHaveLength(0);
+ });
+
+ test("land mine is a territory-bound structure", async () => {
+ constructionExecution(game, defender, 10, 10, UnitType.LandMine);
+ const mine = defender.units(UnitType.LandMine)[0];
+
+ expect(mine.info().territoryBound).toBe(true);
+ });
+
+ test("land mine should not be visible to enemies", async () => {
+ constructionExecution(game, defender, 10, 10, UnitType.LandMine);
+ const mine = defender.units(UnitType.LandMine)[0];
+
+ expect(mine.info().visibleToEnemies).toBe(false);
+ });
+
+ test("server should not send land mine unit updates to enemies", async () => {
+ game.addExecution(
+ new ConstructionExecution(defender, UnitType.LandMine, game.ref(10, 10)),
+ );
+
+ let allUnitUpdates: UnitUpdate[] = [];
+ for (let i = 0; i < 10; i++) {
+ const updates: GameUpdates = game.executeNextTick();
+ allUnitUpdates = allUnitUpdates.concat(updates[GameUpdateType.Unit]);
+ }
+
+ const mine = defender.units(UnitType.LandMine)[0];
+ expect(mine).toBeDefined();
+
+ const landMineUpdates = allUnitUpdates.filter(
+ (u: UnitUpdate) => u.unitType === UnitType.LandMine,
+ );
+ expect(landMineUpdates.length).toBeGreaterThan(0);
+
+ const attackerSmallID = attacker.smallID();
+
+ const filteredForAttacker = allUnitUpdates.filter(
+ (unitUpdate: UnitUpdate) => {
+ const unitInfo = game.config().unitInfo(unitUpdate.unitType);
+
+ if (unitInfo.visibleToEnemies !== false) {
+ return true;
+ }
+
+ if (attackerSmallID === unitUpdate.ownerID) {
+ return true;
+ }
+
+ const owner = game.playerBySmallID(unitUpdate.ownerID);
+ if (owner.isPlayer() && attacker.isAlliedWith(owner)) {
+ return true;
+ }
+
+ return false;
+ },
+ );
+
+ const attackerLandMineUpdates = filteredForAttacker.filter(
+ (u: UnitUpdate) => u.unitType === UnitType.LandMine,
+ );
+ expect(attackerLandMineUpdates).toHaveLength(0);
+
+ const defenderSmallID = defender.smallID();
+
+ const filteredForDefender = allUnitUpdates.filter(
+ (unitUpdate: UnitUpdate) => {
+ const unitInfo = game.config().unitInfo(unitUpdate.unitType);
+
+ if (unitInfo.visibleToEnemies !== false) {
+ return true;
+ }
+
+ if (defenderSmallID === unitUpdate.ownerID) {
+ return true;
+ }
+
+ const owner = game.playerBySmallID(unitUpdate.ownerID);
+ if (owner.isPlayer() && defender.isAlliedWith(owner)) {
+ return true;
+ }
+
+ return false;
+ },
+ );
+
+ const defenderLandMineUpdates = filteredForDefender.filter(
+ (u: UnitUpdate) => u.unitType === UnitType.LandMine,
+ );
+ expect(defenderLandMineUpdates.length).toBeGreaterThan(0);
+ });
+
+ test("allied players should receive land mine updates from allies", async () => {
+ const allyInfo = new PlayerInfo(
+ "ally",
+ PlayerType.Human,
+ "ally_client",
+ "ally_id",
+ );
+ game.addPlayer(allyInfo);
+
+ game.addExecution(new SpawnExecution(gameID, allyInfo, game.ref(10, 20)));
+
+ for (let i = 0; i < 10; i++) {
+ game.executeNextTick();
+ }
+
+ const ally = game.player(allyInfo.id);
+
+ const allianceRequest = defender.createAllianceRequest(ally);
+ if (allianceRequest) {
+ allianceRequest.accept();
+ }
+ expect(defender.isAlliedWith(ally)).toBe(true);
+
+ game.addExecution(
+ new ConstructionExecution(defender, UnitType.LandMine, game.ref(10, 10)),
+ );
+
+ let allUnitUpdates: UnitUpdate[] = [];
+ for (let i = 0; i < 10; i++) {
+ const updates: GameUpdates = game.executeNextTick();
+ allUnitUpdates = allUnitUpdates.concat(updates[GameUpdateType.Unit]);
+ }
+
+ const mine = defender.units(UnitType.LandMine)[0];
+ expect(mine).toBeDefined();
+
+ const landMineUpdates = allUnitUpdates.filter(
+ (u: UnitUpdate) => u.unitType === UnitType.LandMine,
+ );
+ expect(landMineUpdates.length).toBeGreaterThan(0);
+
+ const allySmallID = ally.smallID();
+
+ const filteredForAlly = allUnitUpdates.filter((unitUpdate: UnitUpdate) => {
+ const unitInfo = game.config().unitInfo(unitUpdate.unitType);
+
+ if (unitInfo.visibleToEnemies !== false) {
+ return true;
+ }
+
+ if (allySmallID === unitUpdate.ownerID) {
+ return true;
+ }
+
+ const owner = game.playerBySmallID(unitUpdate.ownerID);
+ if (owner.isPlayer() && ally.isAlliedWith(owner)) {
+ return true;
+ }
+
+ return false;
+ });
+
+ const allyLandMineUpdates = filteredForAlly.filter(
+ (u: UnitUpdate) => u.unitType === UnitType.LandMine,
+ );
+ expect(allyLandMineUpdates.length).toBeGreaterThan(0);
+ });
+});