From 24eb8ec9745ff767556708d987ec4726ce649d1f Mon Sep 17 00:00:00 2001 From: bijx Date: Thu, 18 Jun 2026 20:54:27 -0400 Subject: [PATCH] balances --- src/core/execution/MIRVExecution.ts | 8 +-- src/core/execution/invasion/InvasionConfig.ts | 57 +++++++------------ .../execution/invasion/InvasionExecution.ts | 22 +++---- tests/InvasionConfig.test.ts | 52 ++++++++--------- 4 files changed, 55 insertions(+), 84 deletions(-) diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index 1beafc770..d06d9e23c 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -44,11 +44,6 @@ export class MirvExecution implements Execution { constructor( private player: Player, private dst: TileRef, - // When provided, launch directly from this tile and skip the usual - // silo/ownership checks in `canBuild`. Used by Invasion Mode to fire from an - // open-water spawn tile. Undefined for every existing caller, leaving their - // behavior unchanged. - private srcOverride?: TileRef | null, ) {} init(mg: Game, ticks: number): void { @@ -75,8 +70,7 @@ export class MirvExecution implements Execution { tick(ticks: number): void { if (this.nuke === null) { - const spawn = - this.srcOverride ?? this.player.canBuild(UnitType.MIRV, this.dst); + const spawn = this.player.canBuild(UnitType.MIRV, this.dst); if (spawn === false) { console.warn(`cannot build MIRV`); this.active = false; diff --git a/src/core/execution/invasion/InvasionConfig.ts b/src/core/execution/invasion/InvasionConfig.ts index 0afc8dc67..c04574e34 100644 --- a/src/core/execution/invasion/InvasionConfig.ts +++ b/src/core/execution/invasion/InvasionConfig.ts @@ -18,7 +18,7 @@ const TICKS_PER_MINUTE = 600; // Up to this many distinct invader nations exist at once; once the cap is hit // they reuse existing nations (each fielding up to INVADER_BOAT_MAX boats) // rather than spawning more. -export const MAX_INVADER_NATIONS = 10; +export const MAX_INVADER_NATIONS = 12; export const INVADER_BOAT_MAX = 3; // Boat cadence: ~1 every 15s early, ramping down to the 2s floor by 20 min. @@ -36,26 +36,26 @@ const TROOPS_PEAK_TICKS = 20 * TICKS_PER_MINUTE; const TROOPS_LINEAR_PER_MINUTE = 10_000; // growth past minute 20 const TROOPS_PER_WAVE_BONUS = 200; // each successive boat lands a touch more -// Per-nation starting gold follows a saturating (plateau) curve: a few thousand -// early, asymptotically approaching the 1m cap. Captured wholesale when the -// nation is conquered, so later invaders are richer prizes. -const GOLD_START = 3_000; -const GOLD_CAP = 1_000_000; -const GOLD_HALF_TICKS = 6 * TICKS_PER_MINUTE; // ~half the cap reached by min 6 +// Per-nation starting gold follows a saturating (plateau) curve, independent of +// the lobby "starting gold" setting: a flat 250k early, climbing toward the 2m +// cap (~1m by minute 10). Captured wholesale when the nation is conquered, so +// later invaders are far richer prizes. +const GOLD_START = 250_000; +const GOLD_CAP = 2_000_000; +const GOLD_HALF_TICKS = 10 * TICKS_PER_MINUTE; // ~half the cap reached by min 10 // Missile escalation. Missiles begin one minute into the invasion and a single // strike "package" (the highest tier that passes its roll) is launched per // boat. Chances/counts ramp early then plateau; the relentless troop growth is -// what keeps the late game lethal. +// what keeps the late game lethal. MIRVs are deliberately excluded — atom and +// hydrogen barrages alone carry the bombardment. const STRIKE_START_TICKS = 1 * TICKS_PER_MINUTE; const ATOM_CHANCE = 50; // constant % once strikes begin const HYDROGEN_MAX_CHANCE = 12; const HYDROGEN_MAX_COUNT = 3; const ATOM_MAX_COUNT = 8; -const MIRV_START_TICKS = 4 * TICKS_PER_MINUTE; // MIRVs only after minute 4 -const MIRV_MAX_CHANCE = 5; -export type InvasionNuke = "atom" | "hydrogen" | "mirv"; +export type InvasionNuke = "atom" | "hydrogen"; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); @@ -122,14 +122,15 @@ export function invaderStartingGold(elapsedTicks: number): Gold { /** * Number of escort warships (0-3) accompanying a wave. Time-independent and - * weighted heavily toward 0 and 1 so most waves arrive lightly escorted. + * weighted very heavily toward 0 and 1 — 2 is uncommon and 3 is rare — so the + * map is never flooded with warships, especially early on. */ export function warshipCount(random: PseudoRandom): number { const roll = random.nextInt(0, 100); - if (roll < 45) return 0; // 45% - if (roll < 75) return 1; // 30% - if (roll < 92) return 2; // 17% - return 3; // 8% + if (roll < 55) return 0; // 55% + if (roll < 88) return 1; // 33% + if (roll < 97) return 2; // 9% + return 3; // 3% } function atomCount(elapsedTicks: number): number { @@ -171,25 +172,11 @@ function hydrogenCount(elapsedTicks: number): number { return clamp(c, 1, HYDROGEN_MAX_COUNT); } -function mirvChance(elapsedTicks: number): number { - // 0% until minute 4, ramping to 3% at minute 10, then up to the cap. - if (elapsedTicks < MIRV_START_TICKS) return 0; - let c = ramp(elapsedTicks, MIRV_START_TICKS, 0, 10 * TICKS_PER_MINUTE, 3); - if (elapsedTicks > 10 * TICKS_PER_MINUTE) { - c = - 3 + - Math.floor( - (elapsedTicks - 10 * TICKS_PER_MINUTE) / (10 * TICKS_PER_MINUTE), - ); - } - return clamp(c, 0, MIRV_MAX_CHANCE); -} - /** * The missile package launched by a single boat as it spawns, or an empty list - * if it carries none. The highest tier that passes its roll wins: a rare MIRV, - * else a small hydrogen salvo, else an atom barrage. Returns concrete warhead - * types so the caller can fire one execution each from the spawn tile. + * if it carries none. The higher tier that passes its roll wins: a small + * hydrogen salvo, else an atom barrage. Returns concrete warhead types so the + * caller can fire one execution each from the spawn tile. */ export function selectInvasionStrike( elapsedTicks: number, @@ -197,10 +184,6 @@ export function selectInvasionStrike( ): InvasionNuke[] { if (elapsedTicks < STRIKE_START_TICKS) return []; - const mc = mirvChance(elapsedTicks); - if (mc > 0 && random.nextInt(0, 100) < mc) { - return ["mirv"]; - } if (random.nextInt(0, 100) < hydrogenChance(elapsedTicks)) { return new Array(hydrogenCount(elapsedTicks)).fill( "hydrogen", diff --git a/src/core/execution/invasion/InvasionExecution.ts b/src/core/execution/invasion/InvasionExecution.ts index 75e90affa..2123c61a9 100644 --- a/src/core/execution/invasion/InvasionExecution.ts +++ b/src/core/execution/invasion/InvasionExecution.ts @@ -13,7 +13,6 @@ import { TileRef } from "../../game/GameMap"; import { PseudoRandom } from "../../PseudoRandom"; import { GameID } from "../../Schemas"; import { simpleHash } from "../../Util"; -import { MirvExecution } from "../MIRVExecution"; import { NationExecution } from "../NationExecution"; import { NukeExecution } from "../NukeExecution"; import { PlayerExecution } from "../PlayerExecution"; @@ -298,21 +297,14 @@ export class InvasionExecution implements Execution { strike: InvasionNuke[], ): void { for (const nuke of strike) { + const type = nuke === "atom" ? UnitType.AtomBomb : UnitType.HydrogenBomb; + if (this.mg.config().isUnitDisabled(type)) continue; const dst = this.pickEnemyLandTarget(); if (dst === null) return; - if (nuke === "mirv") { - if (this.mg.config().isUnitDisabled(UnitType.MIRV)) continue; - owner.addGold(this.mg.unitInfo(UnitType.MIRV).cost(this.mg, owner)); - this.mg.addExecution(new MirvExecution(owner, dst, srcTile)); - } else { - const type = - nuke === "atom" ? UnitType.AtomBomb : UnitType.HydrogenBomb; - if (this.mg.config().isUnitDisabled(type)) continue; - owner.addGold(this.mg.unitInfo(type).cost(this.mg, owner)); - this.mg.addExecution( - new NukeExecution(type, owner, dst, srcTile, -1, 0, true, true), - ); - } + owner.addGold(this.mg.unitInfo(type).cost(this.mg, owner)); + this.mg.addExecution( + new NukeExecution(type, owner, dst, srcTile, -1, 0, true, true), + ); } } @@ -326,6 +318,8 @@ export class InvasionExecution implements Execution { if (active.length < MAX_INVADER_NATIONS) { const info = this.createInvaderInfo(); const invader = this.mg.addPlayer(info, ColoredTeams.Invaders); + // Set the prize pot precisely, independent of the lobby "starting gold". + invader.removeGold(invader.gold()); invader.addGold(invaderStartingGold(elapsed)); this.invaders.push(info); return invader; diff --git a/tests/InvasionConfig.test.ts b/tests/InvasionConfig.test.ts index 44d36ea40..27e784abf 100644 --- a/tests/InvasionConfig.test.ts +++ b/tests/InvasionConfig.test.ts @@ -50,12 +50,13 @@ describe("InvasionConfig.boatTroops", () => { }); describe("InvasionConfig.invaderStartingGold", () => { - test("starts at a few thousand, rises, and is capped at 1m", () => { - expect(invaderStartingGold(0)).toBe(3_000n); + test("starts at 250k, rises past 1m, and is capped at 2m", () => { + expect(invaderStartingGold(0)).toBe(250_000n); + expect(invaderStartingGold(10 * MIN)).toBeGreaterThanOrEqual(1_000_000n); expect(invaderStartingGold(10 * MIN)).toBeGreaterThan( invaderStartingGold(1 * MIN), ); - expect(invaderStartingGold(10_000 * MIN)).toBeLessThanOrEqual(1_000_000n); + expect(invaderStartingGold(10_000 * MIN)).toBeLessThanOrEqual(2_000_000n); }); test("rises monotonically toward the cap", () => { @@ -69,8 +70,8 @@ describe("InvasionConfig.invaderStartingGold", () => { }); describe("InvasionConfig.maxima", () => { - test("ten nations, three boats each", () => { - expect(MAX_INVADER_NATIONS).toBe(10); + test("twelve nations, three boats each", () => { + expect(MAX_INVADER_NATIONS).toBe(12); expect(INVADER_BOAT_MAX).toBe(3); }); }); @@ -85,18 +86,21 @@ describe("InvasionConfig.warshipCount", () => { } }); - test("is weighted toward 0 and 1", () => { + test("is weighted very heavily toward 0 and 1; 2 and 3 are rare", () => { const rng = new PseudoRandom(7); const counts = [0, 0, 0, 0]; const samples = 4000; for (let i = 0; i < samples; i++) { counts[warshipCount(rng)]++; } - // Most waves should arrive with 0 or 1 escorts. - expect((counts[0] + counts[1]) / samples).toBeGreaterThan(0.6); - expect(counts[0]).toBeGreaterThan(counts[3]); + // The vast majority of waves arrive with 0 or 1 escorts. + expect((counts[0] + counts[1]) / samples).toBeGreaterThan(0.8); + // 2 and 3 escorts together stay uncommon, with 3 rarest of all. + expect((counts[2] + counts[3]) / samples).toBeLessThan(0.2); + expect(counts[1]).toBeGreaterThan(counts[2]); + expect(counts[2]).toBeGreaterThan(counts[3]); const avg = (counts[1] + 2 * counts[2] + 3 * counts[3]) / samples; - expect(avg).toBeLessThan(1.5); + expect(avg).toBeLessThan(1); }); }); @@ -109,36 +113,35 @@ describe("InvasionConfig.selectInvasionStrike", () => { } }); - test("at minute 1: only single atoms/hydrogens, never a MIRV", () => { + test("at minute 1: only single atoms or hydrogens", () => { const rng = new PseudoRandom(11); for (let i = 0; i < 2000; i++) { const strike = selectInvasionStrike(1 * MIN, rng); - expect(strike).not.toContain("mirv"); if (strike[0] === "atom") expect(strike.length).toBe(1); if (strike[0] === "hydrogen") expect(strike.length).toBe(1); } }); - test("MIRVs never appear before minute 4", () => { + test("only ever launches atoms or hydrogens (no MIRVs)", () => { const rng = new PseudoRandom(99); - for (let i = 0; i < 3000; i++) { - expect(selectInvasionStrike(3 * MIN, rng)).not.toContain("mirv"); + for (const m of [1, 4, 10, 20, 60, 200]) { + for (let i = 0; i < 1000; i++) { + for (const nuke of selectInvasionStrike(m * MIN, rng)) { + expect(["atom", "hydrogen"]).toContain(nuke); + } + } } }); - test("by minute 10: atoms barrage (5), hydrogens (2), rare MIRVs appear", () => { + test("by minute 10: atom barrages (5) and hydrogen salvos (2) appear", () => { const rng = new PseudoRandom(123); - let mirvs = 0; let sawHydrogen2 = false; let sawAtom5 = false; const samples = 8000; for (let i = 0; i < samples; i++) { const strike = selectInvasionStrike(10 * MIN, rng); if (strike.length === 0) continue; - if (strike[0] === "mirv") { - expect(strike.length).toBe(1); - mirvs++; - } else if (strike[0] === "hydrogen") { + if (strike[0] === "hydrogen") { expect(strike.length).toBe(2); sawHydrogen2 = true; } else { @@ -148,9 +151,6 @@ describe("InvasionConfig.selectInvasionStrike", () => { } expect(sawAtom5).toBe(true); expect(sawHydrogen2).toBe(true); - // ~3% of boats fire a MIRV once the tier is reached. - expect(mirvs).toBeGreaterThan(0); - expect(mirvs / samples).toBeLessThan(0.1); }); test("missile counts never exceed their caps deep into the game", () => { @@ -158,9 +158,9 @@ describe("InvasionConfig.selectInvasionStrike", () => { for (let i = 0; i < 4000; i++) { const strike = selectInvasionStrike(120 * MIN, rng); if (strike[0] === "atom") expect(strike.length).toBeLessThanOrEqual(8); - if (strike[0] === "hydrogen") + if (strike[0] === "hydrogen") { expect(strike.length).toBeLessThanOrEqual(3); - if (strike[0] === "mirv") expect(strike.length).toBe(1); + } } }); });