This commit is contained in:
bijx
2026-06-18 20:54:27 -04:00
parent 0532f54723
commit 24eb8ec974
4 changed files with 55 additions and 84 deletions
+1 -7
View File
@@ -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;
+20 -37
View File
@@ -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<InvasionNuke>(hydrogenCount(elapsedTicks)).fill(
"hydrogen",
@@ -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;
+26 -26
View File
@@ -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);
}
}
});
});