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); + }); +});