From 71cf3092524e4a1f55d9930b86f9b1311fe51f9c Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 14 Dec 2025 19:52:54 -0800 Subject: [PATCH] increase mirv price with total number of merged launched (#2621) ## Description: To prevent MAD stalemates, have the price of MIRVs increase after each launch. This will encourage players to launch a MIRV once they have enough money for it. Also reduce the price of the first MIRV to 25 million to reduce snowballing, each subsequent MIRV cost an extra 15 million: 1. 25 million 2. 40 million 3. 60 million 4. etc ## 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: evan --- src/core/configuration/DefaultConfig.ts | 16 ++++-- src/core/execution/FakeHumanExecution.ts | 2 +- src/core/game/Game.ts | 3 +- src/core/game/PlayerImpl.ts | 12 +++-- src/core/game/Stats.ts | 2 + src/core/game/StatsImpl.ts | 11 ++++- tests/FakeHumanMIRV.test.ts | 5 +- tests/economy/ConstructionGold.test.ts | 62 ++++++++++++++++++------ tests/nukes/HydrogenAndMirv.test.ts | 4 +- 9 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index b646d7024..0ffe3626b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -487,7 +487,12 @@ export class DefaultConfig implements Config { }; case UnitType.MIRV: return { - cost: this.costWrapper(() => 35_000_000, UnitType.MIRV), + cost: (game: Game, player: Player) => { + if (player.type() === PlayerType.Human && this.infiniteGold()) { + return 0n; + } + return 25_000_000n + game.stats().numMirvsLaunched() * 15_000_000n; + }, territoryBound: false, }; case UnitType.MIRVWarhead: @@ -567,14 +572,15 @@ export class DefaultConfig implements Config { private costWrapper( costFn: (units: number) => number, ...types: UnitType[] - ): (p: Player) => bigint { - return (p: Player) => { - if (p.type() === PlayerType.Human && this.infiniteGold()) { + ): (g: Game, p: Player) => bigint { + return (game: Game, player: Player) => { + if (player.type() === PlayerType.Human && this.infiniteGold()) { return 0n; } const numUnits = types.reduce( (acc, type) => - acc + Math.min(p.unitsOwned(type), p.unitsConstructed(type)), + acc + + Math.min(player.unitsOwned(type), player.unitsConstructed(type)), 0, ); return BigInt(costFn(numUnits)); diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index b82d2f707..7e3b64401 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -562,7 +562,7 @@ export class FakeHumanExecution implements Execution { private cost(type: UnitType): Gold { if (this.player === null) throw new Error("not initialized"); - return this.mg.unitInfo(type).cost(this.player); + return this.mg.unitInfo(type).cost(this.mg, this.player); } sendBoatRandomly(borderingEnemies: Player[] = []) { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 41de01d98..746fd3cdb 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -175,7 +175,7 @@ export enum GameMapSize { } export interface UnitInfo { - cost: (player: Player) => Gold; + cost: (game: Game, player: Player) => Gold; // Determines if its owner changes when its tile is conquered. territoryBound: boolean; maxHealth?: number; @@ -753,7 +753,6 @@ export interface Game extends GameMap { nations(): Nation[]; numTilesWithFallout(): number; - // Optional as it's not initialized before the end of spawn phase stats(): Stats; addUpdate(update: GameUpdate): void; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index bfbbeb0fc..856dfa77a 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -864,7 +864,7 @@ export class PlayerImpl implements Player { ); } - const cost = this.mg.unitInfo(type).cost(this); + const cost = this.mg.unitInfo(type).cost(this.mg, this); const b = new UnitImpl( type, this.mg, @@ -911,7 +911,9 @@ export class PlayerImpl implements Player { if (this.mg.config().isUnitDisabled(unit.type())) { return false; } - if (this._gold < this.mg.config().unitInfo(unit.type()).cost(this)) { + if ( + this._gold < this.mg.config().unitInfo(unit.type()).cost(this.mg, this) + ) { return false; } if (unit.owner() !== this) { @@ -921,7 +923,7 @@ export class PlayerImpl implements Player { } upgradeUnit(unit: Unit) { - const cost = this.mg.unitInfo(unit.type()).cost(this); + const cost = this.mg.unitInfo(unit.type()).cost(this.mg, this); this.removeGold(cost); unit.increaseLevel(); this.recordUnitConstructed(unit.type()); @@ -944,7 +946,7 @@ export class PlayerImpl implements Player { ? false : this.canBuild(u, tile, validTiles), canUpgrade: canUpgrade, - cost: this.mg.config().unitInfo(u).cost(this), + cost: this.mg.config().unitInfo(u).cost(this.mg, this), } as BuildableUnit; }); } @@ -958,7 +960,7 @@ export class PlayerImpl implements Player { return false; } - const cost = this.mg.unitInfo(unitType).cost(this); + const cost = this.mg.unitInfo(unitType).cost(this.mg, this); if (!this.isAlive() || this.gold() < cost) { return false; } diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 39400087d..3dc644f2d 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -6,6 +6,8 @@ export interface Stats { getPlayerStats(player: Player): PlayerStats | null; stats(): AllPlayersStats; + numMirvsLaunched(): bigint; + // Player attacks target attack( player: Player, diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index aa48b1b7f..b62956108 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -26,7 +26,7 @@ import { unitTypeToBombUnit, unitTypeToOtherUnit, } from "../StatsSchemas"; -import { Player, TerraNullius } from "./Game"; +import { Player, TerraNullius, UnitType } from "./Game"; import { Stats } from "./Stats"; type BigIntLike = bigint | number; @@ -42,6 +42,12 @@ function _bigint(value: BigIntLike): bigint { export class StatsImpl implements Stats { private readonly data: AllPlayersStats = {}; + private _numMirvLaunched: bigint = 0n; + + numMirvsLaunched(): bigint { + return this._numMirvLaunched; + } + getPlayerStats(player: Player): PlayerStats { const clientID = player.clientID(); if (clientID === null) return undefined; @@ -217,6 +223,9 @@ export class StatsImpl implements Stats { target: Player | TerraNullius, type: NukeType, ): void { + if (type === UnitType.MIRV) { + this._numMirvLaunched++; + } this._addBomb(player, type, BOMB_INDEX_LAUNCH, 1); } diff --git a/tests/FakeHumanMIRV.test.ts b/tests/FakeHumanMIRV.test.ts index 11cad91fd..1981bcf53 100644 --- a/tests/FakeHumanMIRV.test.ts +++ b/tests/FakeHumanMIRV.test.ts @@ -66,9 +66,8 @@ describe("FakeHuman MIRV Retaliation", () => { fakehuman.buildUnit(UnitType.MissileSilo, game.ref(50, 50), {}); // Give both players enough gold for MIRVs - attacker.addGold(100_000_000n); - fakehuman.addGold(100_000_000n); - + attacker.addGold(1_000_000_000n); + fakehuman.addGold(1_000_000_000n); // Verify preconditions expect(attacker.units(UnitType.MissileSilo)).toHaveLength(1); expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1); diff --git a/tests/economy/ConstructionGold.test.ts b/tests/economy/ConstructionGold.test.ts index b083bc746..d763aeecf 100644 --- a/tests/economy/ConstructionGold.test.ts +++ b/tests/economy/ConstructionGold.test.ts @@ -1,4 +1,5 @@ import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution"; +import { NukeExecution } from "../../src/core/execution/NukeExecution"; import { SpawnExecution } from "../../src/core/execution/SpawnExecution"; import { Game, @@ -12,31 +13,38 @@ import { setup } from "../util/Setup"; describe("Construction economy", () => { let game: Game; let player: Player; + let other: Player; + const builderInfo = new PlayerInfo( + "builder", + PlayerType.Human, + null, + "builder_id", + ); + const otherInfo = new PlayerInfo("other", PlayerType.Human, null, "other_id"); 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 = await setup( + "plains", + { + infiniteGold: false, + instantBuild: false, + infiniteTroops: true, + }, + [builderInfo, otherInfo], ); - game.addPlayer(info); const spawn = game.ref(0, 10); - game.addExecution(new SpawnExecution(info, spawn)); + game.addExecution(new SpawnExecution(builderInfo, spawn)); + game.addExecution(new SpawnExecution(otherInfo, spawn)); while (game.inSpawnPhase()) { game.executeNextTick(); } - player = game.player(info.id); + player = game.player(builderInfo.id); + other = game.player(otherInfo.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); + const cost = game.unitInfo(UnitType.City).cost(game, player); player.addGold(cost); expect(player.gold()).toBe(cost); @@ -68,4 +76,30 @@ describe("Construction economy", () => { (player.units(UnitType.City)[0] as any).isUnderConstruction?.() ?? false, ).toBe(false); }); + + test("MIRV gets more expensive with each launch", () => { + expect(game.config().unitInfo(UnitType.MIRV).cost(game, other)).toBe( + 25_000_000n, + ); + + player.addGold(100_000_000n); + + player.conquer(game.ref(1, 1)); + player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {}); + + other.conquer(game.ref(10, 10)); + game.addExecution( + new NukeExecution(UnitType.MIRV, player, game.ref(10, 10)), + ); + game.executeNextTick(); // init + game.executeNextTick(); // create MIRV unit + game.executeNextTick(); + + expect(player.units(UnitType.MIRV)).toHaveLength(1); + + // Price of the MIRV increases for everyone with each launch. + expect(game.config().unitInfo(UnitType.MIRV).cost(game, other)).toBe( + 40_000_000n, + ); + }); }); diff --git a/tests/nukes/HydrogenAndMirv.test.ts b/tests/nukes/HydrogenAndMirv.test.ts index a70986079..edfca7f67 100644 --- a/tests/nukes/HydrogenAndMirv.test.ts +++ b/tests/nukes/HydrogenAndMirv.test.ts @@ -71,7 +71,7 @@ describe("Hydrogen Bomb and MIRV flows", () => { const goldBeforeSilo = playerWithConstruction.gold(); const siloCost = gameWithConstruction .unitInfo(UnitType.MissileSilo) - .cost(playerWithConstruction); + .cost(gameWithConstruction, playerWithConstruction); playerWithConstruction.addGold(siloCost); // Start construction of silo @@ -144,7 +144,7 @@ describe("Hydrogen Bomb and MIRV flows", () => { playerWithConstruction.conquer(targetTile); const hydrogenBombCost = gameWithConstruction .unitInfo(UnitType.HydrogenBomb) - .cost(playerWithConstruction); + .cost(gameWithConstruction, playerWithConstruction); playerWithConstruction.addGold(hydrogenBombCost); const canBuildAfterCompletion = playerWithConstruction.canBuild(