mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:00:44 +00:00
make the game more fun, balances
This commit is contained in:
@@ -301,7 +301,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
.inputPlaceholder=${translateText(
|
||||
"host_modal.invasion_grace_placeholder",
|
||||
)}
|
||||
.defaultInputValue=${2}
|
||||
.defaultInputValue=${5}
|
||||
.minValidOnEnable=${0}
|
||||
.onToggle=${this.handleInvasionToggle}
|
||||
.onInput=${this.handleInvasionGraceChanges}
|
||||
@@ -1083,7 +1083,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
waterNukes: this.waterNukes ? true : null,
|
||||
invasionMode: this.invasionMode || undefined,
|
||||
invasionGracePeriod: this.invasionMode
|
||||
? Math.max(0, Math.min(15, this.invasionGracePeriod ?? 0))
|
||||
? Math.max(0, Math.min(15, this.invasionGracePeriod ?? 5))
|
||||
: null,
|
||||
hostCheats: this.hostCheatsEnabled
|
||||
? {
|
||||
|
||||
@@ -267,7 +267,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
.inputPlaceholder=${translateText(
|
||||
"single_modal.invasion_grace_placeholder",
|
||||
)}
|
||||
.defaultInputValue=${2}
|
||||
.defaultInputValue=${5}
|
||||
.minValidOnEnable=${0}
|
||||
.onToggle=${this.handleInvasionToggle}
|
||||
.onInput=${this.handleInvasionGraceChanges}
|
||||
@@ -751,7 +751,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
invasionMode: true,
|
||||
invasionGracePeriod: Math.max(
|
||||
0,
|
||||
Math.min(15, this.invasionGracePeriod ?? 0),
|
||||
Math.min(15, this.invasionGracePeriod ?? 5),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -44,6 +44,11 @@ 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 {
|
||||
@@ -70,7 +75,8 @@ export class MirvExecution implements Execution {
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.nuke === null) {
|
||||
const spawn = this.player.canBuild(UnitType.MIRV, this.dst);
|
||||
const spawn =
|
||||
this.srcOverride ?? this.player.canBuild(UnitType.MIRV, this.dst);
|
||||
if (spawn === false) {
|
||||
console.warn(`cannot build MIRV`);
|
||||
this.active = false;
|
||||
|
||||
@@ -34,6 +34,11 @@ export class NukeExecution implements Execution {
|
||||
private speed: number = -1,
|
||||
private waitTicks = 0,
|
||||
private rocketDirectionUp: boolean = true,
|
||||
// When true, launch directly from `src` and skip the usual silo/ownership
|
||||
// checks in `canBuild`. Used by Invasion Mode to fire from an open-water
|
||||
// spawn tile (the invader owns no silo and no land). Defaults to false so
|
||||
// every existing caller is byte-for-byte unchanged.
|
||||
private forceSrc: boolean = false,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
@@ -177,7 +182,10 @@ export class NukeExecution implements Execution {
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.nuke === null) {
|
||||
const spawn = this.player.canBuild(this.nukeType, this.dst);
|
||||
const spawn =
|
||||
this.forceSrc && this.src !== undefined && this.src !== null
|
||||
? this.src
|
||||
: this.player.canBuild(this.nukeType, this.dst);
|
||||
if (spawn === false) {
|
||||
console.warn(`cannot build Nuke`);
|
||||
this.active = false;
|
||||
|
||||
@@ -74,6 +74,13 @@ export class WinCheckExecution implements Execution {
|
||||
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) ||
|
||||
timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS
|
||||
) {
|
||||
// Invasion Mode: a player can only win once no other player is left
|
||||
// alive. The invader horde respawns forever, so this never happens — the
|
||||
// "You Won" screen is unreachable while "You Died" (per-player, handled
|
||||
// by WinModal) still fires when the human is wiped out.
|
||||
if (this.mg.config().invasionMode() && this.mg.players().length > 1) {
|
||||
return;
|
||||
}
|
||||
this.mg.setWinner(max, this.mg.stats().stats());
|
||||
console.log(`${max.name()} has won the game`);
|
||||
this.active = false;
|
||||
@@ -111,6 +118,12 @@ export class WinCheckExecution implements Execution {
|
||||
) {
|
||||
if (max[0] === ColoredTeams.Bot || max[0] === ColoredTeams.Invaders)
|
||||
return;
|
||||
// Invasion Mode: only declare a winning team once it is the sole team
|
||||
// left alive. The invaders are always present, so the win never lands —
|
||||
// see checkWinnerFFA for the rationale.
|
||||
if (this.mg.config().invasionMode() && sorted.length > 1) {
|
||||
return;
|
||||
}
|
||||
this.mg.setWinner(max[0], this.mg.stats().stats());
|
||||
console.log(`${max[0]} has won the game`);
|
||||
this.active = false;
|
||||
|
||||
@@ -1,233 +1,213 @@
|
||||
import { Difficulty } from "../../game/Game";
|
||||
import { Gold } from "../../game/Game";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever } from "../../Util";
|
||||
|
||||
/**
|
||||
* Pure, deterministic tuning curves for Invasion Mode.
|
||||
*
|
||||
* All inputs are integer tick counts (10 ticks = 1 second, 600 ticks = 1
|
||||
* minute) measured from the moment the invasion begins (i.e. after the grace
|
||||
* period). Intensity escalates with time and is further scaled by the lobby
|
||||
* `Difficulty`. Every function is side-effect free so it can be unit tested in
|
||||
* isolation, and any randomness is taken from a caller-provided seeded
|
||||
* `PseudoRandom` to preserve simulation determinism.
|
||||
* period). There is a single, ever-escalating difficulty curve — the lobby
|
||||
* `Difficulty` setting deliberately does NOT influence the invasion. Every
|
||||
* function is side-effect free so it can be unit tested in isolation, and any
|
||||
* randomness is taken from a caller-provided seeded `PseudoRandom` to preserve
|
||||
* simulation determinism.
|
||||
*/
|
||||
|
||||
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 INVADER_BOAT_MAX = 3;
|
||||
|
||||
// Boat cadence: ~1 every 15s early, ramping down to the 2s floor by 20 min.
|
||||
const BOAT_INTERVAL_START = 150; // 15s
|
||||
const BOAT_INTERVAL_FLOOR = 20; // 2s — the hard cap from the spec
|
||||
const BOAT_RAMP_TICKS = 20 * TICKS_PER_MINUTE; // reaches the floor at 20 min
|
||||
const BOAT_RAMP_DELTA = BOAT_INTERVAL_START - BOAT_INTERVAL_FLOOR;
|
||||
|
||||
// Transport population: starts at 30k, grows with time and difficulty, plus a
|
||||
// small bump for each successive boat sent (waves get a little stronger).
|
||||
const TROOPS_START = 30_000;
|
||||
const TROOPS_GROWTH_PER_MINUTE = 8_000;
|
||||
const TROOPS_PER_WAVE_BONUS = 600;
|
||||
const TROOPS_CAP = 350_000;
|
||||
// Transport population follows a growth curve: a few thousand early,
|
||||
// accelerating to ~350k by minute 20, then a slow linear climb forever after
|
||||
// (the game never plateaus — it always gets harder).
|
||||
const TROOPS_START = 3_000;
|
||||
const TROOPS_AT_PEAK = 350_000; // reached at minute 20
|
||||
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
|
||||
|
||||
// Escalation onsets (before difficulty time-shift).
|
||||
const WARSHIP_START_TICKS = 2 * TICKS_PER_MINUTE; // minute 2
|
||||
const ATOM_TICKS = 4 * TICKS_PER_MINUTE; // minute 4
|
||||
const HYDROGEN_TICKS = 10 * TICKS_PER_MINUTE; // minute 10
|
||||
const MIRV_TICKS = 20 * TICKS_PER_MINUTE; // minute 20
|
||||
const WARSHIP_PRESSURE_SPAN = 13 * TICKS_PER_MINUTE; // ramps from min 2 to 15
|
||||
const MIRV_CHANCE_ODDS = 10; // 10% per eligible bomb
|
||||
// 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
|
||||
|
||||
// Scheduled bombardment cadence once nukes are unlocked.
|
||||
const BOMB_INTERVAL_START = 350; // 35s
|
||||
const BOMB_INTERVAL_FLOOR = 120; // 12s
|
||||
// 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.
|
||||
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 InvasionNukeTier = "none" | "atom" | "hydrogen" | "mirv";
|
||||
export type InvasionNuke = "atom" | "hydrogen" | "mirv";
|
||||
|
||||
/** Intensity multiplier (percent) applied to wave size/cadence by difficulty. */
|
||||
function difficultyIntensity(difficulty: Difficulty): number {
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 70;
|
||||
case Difficulty.Medium:
|
||||
return 100;
|
||||
case Difficulty.Hard:
|
||||
return 130;
|
||||
case Difficulty.Impossible:
|
||||
return 160;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-trial warship probability bonus (percentage points) by difficulty. */
|
||||
function difficultyWarshipBonus(difficulty: Difficulty): number {
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return -10;
|
||||
case Difficulty.Medium:
|
||||
return 0;
|
||||
case Difficulty.Hard:
|
||||
return 10;
|
||||
case Difficulty.Impossible:
|
||||
return 20;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
/** How much earlier (in ticks) nukes unlock on higher difficulties. */
|
||||
function difficultyTimeShiftTicks(difficulty: Difficulty): number {
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return TICKS_PER_MINUTE; // 1 min later
|
||||
case Difficulty.Medium:
|
||||
return 0;
|
||||
case Difficulty.Hard:
|
||||
return -TICKS_PER_MINUTE; // 1 min earlier
|
||||
case Difficulty.Impossible:
|
||||
return -2 * TICKS_PER_MINUTE; // 2 min earlier
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** Ticks between transport launches at the given elapsed time/difficulty. */
|
||||
export function boatIntervalTicks(
|
||||
elapsedTicks: number,
|
||||
difficulty: Difficulty,
|
||||
/**
|
||||
* Integer linear interpolation of `v0`→`v1` as `elapsed` moves `t0`→`t1`,
|
||||
* clamped to the endpoints outside that window. Used to build the escalation
|
||||
* ramps without floating-point state.
|
||||
*/
|
||||
function ramp(
|
||||
elapsed: number,
|
||||
t0: number,
|
||||
v0: number,
|
||||
t1: number,
|
||||
v1: number,
|
||||
): number {
|
||||
if (elapsed <= t0) return v0;
|
||||
if (elapsed >= t1) return v1;
|
||||
return v0 + Math.floor(((v1 - v0) * (elapsed - t0)) / (t1 - t0));
|
||||
}
|
||||
|
||||
/** Ticks between transport launches at the given elapsed time. */
|
||||
export function boatIntervalTicks(elapsedTicks: number): number {
|
||||
const t = clamp(elapsedTicks, 0, BOAT_RAMP_TICKS);
|
||||
const base =
|
||||
BOAT_INTERVAL_START - Math.floor((BOAT_RAMP_DELTA * t) / BOAT_RAMP_TICKS);
|
||||
const scaled = Math.round((base * 100) / difficultyIntensity(difficulty));
|
||||
return Math.max(BOAT_INTERVAL_FLOOR, scaled);
|
||||
return (
|
||||
BOAT_INTERVAL_START - Math.floor((BOAT_RAMP_DELTA * t) / BOAT_RAMP_TICKS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Troop count carried by a transport. Grows with elapsed time (scaled by
|
||||
* difficulty) and gets a small cumulative bump per boat already sent, so each
|
||||
* successive wave lands slightly more troops.
|
||||
* Troop count carried by a transport. Accelerates from a few thousand to ~350k
|
||||
* over the first 20 minutes, then grows linearly forever. A small cumulative
|
||||
* bump per boat already sent makes each successive wave land slightly more.
|
||||
*/
|
||||
export function boatTroops(
|
||||
elapsedTicks: number,
|
||||
difficulty: Difficulty,
|
||||
waveIndex = 0,
|
||||
): number {
|
||||
const minutesTimesGrowth = Math.floor(
|
||||
(TROOPS_GROWTH_PER_MINUTE * Math.max(0, elapsedTicks)) / TICKS_PER_MINUTE,
|
||||
);
|
||||
const scaledGrowth = Math.floor(
|
||||
(minutesTimesGrowth * difficultyIntensity(difficulty)) / 100,
|
||||
);
|
||||
const waveBonus = Math.max(0, waveIndex) * TROOPS_PER_WAVE_BONUS;
|
||||
return Math.min(TROOPS_CAP, TROOPS_START + scaledGrowth + waveBonus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of concurrent invader nations, capped by difficulty. Keeps the
|
||||
* total faction count down (each nation can field up to `boatMaxNumber` boats),
|
||||
* so the invasion arrives as fewer, busier nations rather than a swarm of one-
|
||||
* boat nations.
|
||||
*/
|
||||
export function maxInvaderNations(difficulty: Difficulty): number {
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 5;
|
||||
case Difficulty.Medium:
|
||||
return 10;
|
||||
case Difficulty.Hard:
|
||||
return 15;
|
||||
case Difficulty.Impossible:
|
||||
return 20;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
export function boatTroops(elapsedTicks: number, waveIndex = 0): number {
|
||||
const t = Math.max(0, elapsedTicks);
|
||||
let base: number;
|
||||
if (t <= TROOPS_PEAK_TICKS) {
|
||||
// Quadratic ease-in: slow at first, steepening toward the peak.
|
||||
base =
|
||||
TROOPS_START +
|
||||
Math.floor(
|
||||
((TROOPS_AT_PEAK - TROOPS_START) * t * t) /
|
||||
(TROOPS_PEAK_TICKS * TROOPS_PEAK_TICKS),
|
||||
);
|
||||
} else {
|
||||
base =
|
||||
TROOPS_AT_PEAK +
|
||||
Math.floor(
|
||||
(TROOPS_LINEAR_PER_MINUTE * (t - TROOPS_PEAK_TICKS)) / TICKS_PER_MINUTE,
|
||||
);
|
||||
}
|
||||
return base + Math.max(0, waveIndex) * TROOPS_PER_WAVE_BONUS;
|
||||
}
|
||||
|
||||
/** Starting gold handed to a freshly spawned invader nation. */
|
||||
export function invaderStartingGold(elapsedTicks: number): Gold {
|
||||
const t = Math.max(0, elapsedTicks);
|
||||
// Saturating curve gold = cap * t / (t + k): GOLD_START at t=0, → GOLD_CAP.
|
||||
const value = Math.floor((GOLD_CAP * t) / (t + GOLD_HALF_TICKS));
|
||||
return BigInt(Math.max(GOLD_START, Math.min(GOLD_CAP, value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of escort warships (0-3) accompanying a wave. Always 0 before minute
|
||||
* 2; afterward the count is weighted toward fewer early and toward 3 later,
|
||||
* shifted by difficulty.
|
||||
* Number of escort warships (0-3) accompanying a wave. Time-independent and
|
||||
* weighted heavily toward 0 and 1 so most waves arrive lightly escorted.
|
||||
*/
|
||||
export function warshipCount(
|
||||
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%
|
||||
}
|
||||
|
||||
function atomCount(elapsedTicks: number): number {
|
||||
// 1 at minute 1 → 5 at minute 10, then +1 per 4 min, capped.
|
||||
let c = ramp(elapsedTicks, STRIKE_START_TICKS, 1, 10 * TICKS_PER_MINUTE, 5);
|
||||
if (elapsedTicks > 10 * TICKS_PER_MINUTE) {
|
||||
c =
|
||||
5 +
|
||||
Math.floor(
|
||||
(elapsedTicks - 10 * TICKS_PER_MINUTE) / (4 * TICKS_PER_MINUTE),
|
||||
);
|
||||
}
|
||||
return clamp(c, 1, ATOM_MAX_COUNT);
|
||||
}
|
||||
|
||||
function hydrogenChance(elapsedTicks: number): number {
|
||||
// 5% at minute 1 → 10% at minute 10, then drifting up to the cap.
|
||||
let c = ramp(elapsedTicks, STRIKE_START_TICKS, 5, 10 * TICKS_PER_MINUTE, 10);
|
||||
if (elapsedTicks > 10 * TICKS_PER_MINUTE) {
|
||||
c =
|
||||
10 +
|
||||
Math.floor(
|
||||
(elapsedTicks - 10 * TICKS_PER_MINUTE) / (5 * TICKS_PER_MINUTE),
|
||||
);
|
||||
}
|
||||
return clamp(c, 0, HYDROGEN_MAX_CHANCE);
|
||||
}
|
||||
|
||||
function hydrogenCount(elapsedTicks: number): number {
|
||||
// 1 at minute 1 → 2 at minute 10 → 3 by minute 20, capped.
|
||||
let c = ramp(elapsedTicks, STRIKE_START_TICKS, 1, 10 * TICKS_PER_MINUTE, 2);
|
||||
if (elapsedTicks > 10 * TICKS_PER_MINUTE) {
|
||||
c =
|
||||
2 +
|
||||
Math.floor(
|
||||
(elapsedTicks - 10 * TICKS_PER_MINUTE) / (10 * TICKS_PER_MINUTE),
|
||||
);
|
||||
}
|
||||
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.
|
||||
*/
|
||||
export function selectInvasionStrike(
|
||||
elapsedTicks: number,
|
||||
random: PseudoRandom,
|
||||
difficulty: Difficulty,
|
||||
): number {
|
||||
if (elapsedTicks < WARSHIP_START_TICKS) {
|
||||
return 0;
|
||||
}
|
||||
const pressure = clamp(
|
||||
Math.floor(
|
||||
((elapsedTicks - WARSHIP_START_TICKS) * 90) / WARSHIP_PRESSURE_SPAN,
|
||||
),
|
||||
0,
|
||||
90,
|
||||
);
|
||||
const threshold = clamp(pressure + difficultyWarshipBonus(difficulty), 5, 95);
|
||||
let count = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (random.nextInt(0, 100) < threshold) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
): InvasionNuke[] {
|
||||
if (elapsedTicks < STRIKE_START_TICKS) return [];
|
||||
|
||||
/** Highest weapon tier unlocked at the given elapsed time/difficulty. */
|
||||
export function nukeTier(
|
||||
elapsedTicks: number,
|
||||
difficulty: Difficulty,
|
||||
): InvasionNukeTier {
|
||||
const shift = difficultyTimeShiftTicks(difficulty);
|
||||
if (elapsedTicks >= MIRV_TICKS + shift) return "mirv";
|
||||
if (elapsedTicks >= HYDROGEN_TICKS + shift) return "hydrogen";
|
||||
if (elapsedTicks >= ATOM_TICKS + shift) return "atom";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the concrete weapon to launch for a scheduled bombardment, or null if
|
||||
* nukes are not yet unlocked. MIRVs are a 10% roll once eligible; otherwise a
|
||||
* hydrogen/atom mix weighted to the unlocked tier.
|
||||
*/
|
||||
export function selectInvasionNuke(
|
||||
elapsedTicks: number,
|
||||
random: PseudoRandom,
|
||||
difficulty: Difficulty,
|
||||
): InvasionNuke | null {
|
||||
const tier = nukeTier(elapsedTicks, difficulty);
|
||||
switch (tier) {
|
||||
case "none":
|
||||
return null;
|
||||
case "atom":
|
||||
return "atom";
|
||||
case "hydrogen":
|
||||
return random.chance(3) ? "atom" : "hydrogen";
|
||||
case "mirv":
|
||||
if (random.chance(MIRV_CHANCE_ODDS)) return "mirv";
|
||||
return random.chance(3) ? "atom" : "hydrogen";
|
||||
default:
|
||||
assertNever(tier);
|
||||
const mc = mirvChance(elapsedTicks);
|
||||
if (mc > 0 && random.nextInt(0, 100) < mc) {
|
||||
return ["mirv"];
|
||||
}
|
||||
}
|
||||
|
||||
/** Ticks between scheduled bombardments once nukes are unlocked. */
|
||||
export function bombIntervalTicks(
|
||||
elapsedTicks: number,
|
||||
difficulty: Difficulty,
|
||||
): number {
|
||||
const minutes = Math.floor(elapsedTicks / TICKS_PER_MINUTE);
|
||||
const base = Math.max(
|
||||
BOMB_INTERVAL_FLOOR,
|
||||
BOMB_INTERVAL_START - 10 * minutes,
|
||||
);
|
||||
return Math.max(
|
||||
BOMB_INTERVAL_FLOOR,
|
||||
Math.round((base * 100) / difficultyIntensity(difficulty)),
|
||||
);
|
||||
if (random.nextInt(0, 100) < hydrogenChance(elapsedTicks)) {
|
||||
return new Array<InvasionNuke>(hydrogenCount(elapsedTicks)).fill(
|
||||
"hydrogen",
|
||||
);
|
||||
}
|
||||
if (random.nextInt(0, 100) < ATOM_CHANCE) {
|
||||
return new Array<InvasionNuke>(atomCount(elapsedTicks)).fill("atom");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ColoredTeams,
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
Nation,
|
||||
@@ -23,39 +22,64 @@ import { WarshipExecution } from "../WarshipExecution";
|
||||
import {
|
||||
boatIntervalTicks,
|
||||
boatTroops,
|
||||
bombIntervalTicks,
|
||||
maxInvaderNations,
|
||||
nukeTier,
|
||||
selectInvasionNuke,
|
||||
INVADER_BOAT_MAX,
|
||||
invaderStartingGold,
|
||||
InvasionNuke,
|
||||
MAX_INVADER_NATIONS,
|
||||
selectInvasionStrike,
|
||||
warshipCount,
|
||||
} from "./InvasionConfig";
|
||||
|
||||
// Escort warships are released ~0.5s apart so they don't stack on the map.
|
||||
const WARSHIP_STAGGER_TICKS = 5;
|
||||
// Formation geometry (tiles), measured from the transport's spawn tile toward
|
||||
// the landing shore. The lead escort sits ahead; flankers sit out to either
|
||||
// side and slightly forward, so a wave reads as an arrowhead.
|
||||
const FRONT_DIST = 6;
|
||||
const FLANK_SIDE = 4;
|
||||
const FLANK_FORWARD = 2;
|
||||
|
||||
interface PendingEscort {
|
||||
// Formation timing (ticks; 10 ticks = 1s). Escorts deploy first and the
|
||||
// transport follows so the wave assembles into formation before advancing.
|
||||
const FLANK_DELAY = 10; // 1s after the lead escort (3-warship waves)
|
||||
const TRANSPORT_DELAY_ESCORTED = 20; // 2s after escorts (1- and 2-warship waves)
|
||||
const TRANSPORT_DELAY_TRIPLE = 30; // 1s + 2s after the lead (3-warship waves)
|
||||
|
||||
interface PendingWarship {
|
||||
kind: "warship";
|
||||
fireTick: number;
|
||||
ownerId: PlayerID;
|
||||
spawnTile: TileRef;
|
||||
patrolTile: TileRef;
|
||||
}
|
||||
|
||||
interface PendingTransport {
|
||||
kind: "transport";
|
||||
fireTick: number;
|
||||
ownerId: PlayerID;
|
||||
spawnTile: TileRef;
|
||||
target: TileRef;
|
||||
troops: number;
|
||||
strike: InvasionNuke[];
|
||||
}
|
||||
|
||||
type PendingSpawn = PendingWarship | PendingTransport;
|
||||
|
||||
/**
|
||||
* Drives "Invasion Mode": an escalating hostile horde that arrives by sea.
|
||||
*
|
||||
* A single long-lived execution (added once in `GameRunner.init()` when
|
||||
* `config.invasionMode()` is set). After the configured grace period it
|
||||
* launches periodic waves — boats ferried in from a random map-edge water tile
|
||||
* to a nearby shore. The number of distinct invader `Nation`s (all on the
|
||||
* shared `Invaders` team) is capped by difficulty; once that cap is reached,
|
||||
* existing invaders send additional boats (up to `boatMaxNumber` each) instead
|
||||
* of new nations spawning. Escort warships join from minute 2, and scheduled
|
||||
* atom/hydrogen/MIRV strikes begin at minutes 4/10/20 (shifted by difficulty).
|
||||
* Once an invader makes landfall it is handed a stock `NationExecution`, so it
|
||||
* then builds and attacks like any AI nation at the lobby difficulty.
|
||||
* `config.invasionMode()` is set). After the grace period it launches periodic
|
||||
* waves — boats ferried from a random map-edge water tile to a nearby shore,
|
||||
* arriving in an escorted formation. Up to `MAX_INVADER_NATIONS` distinct
|
||||
* invader `Nation`s (all on the shared `Invaders` team) exist at once; beyond
|
||||
* that cap existing invaders send extra boats (up to `INVADER_BOAT_MAX` each).
|
||||
* Every boat may launch a missile strike straight from its open-water spawn
|
||||
* tile the instant it appears, with intensity rising over time. Once an invader
|
||||
* makes landfall it is handed a stock `NationExecution`, so it then builds and
|
||||
* attacks like any AI nation.
|
||||
*
|
||||
* Determinism: all randomness comes from a single seeded `PseudoRandom`; the
|
||||
* escalation clock is derived from integer tick counts.
|
||||
* The invasion ignores the lobby `Difficulty` entirely — there is one ever-
|
||||
* escalating curve. Determinism: all randomness comes from a single seeded
|
||||
* `PseudoRandom`; the escalation clock is derived from integer tick counts.
|
||||
*/
|
||||
export class InvasionExecution implements Execution {
|
||||
private active = true;
|
||||
@@ -67,7 +91,6 @@ export class InvasionExecution implements Execution {
|
||||
private startTick = -1;
|
||||
private graceTicks = 0;
|
||||
private nextWaveTick = -1;
|
||||
private nextBombTick = -1;
|
||||
private invaderCounter = 0;
|
||||
private wavesLaunched = 0;
|
||||
// Minimum manhattan distance an invader must travel before landfall.
|
||||
@@ -75,7 +98,7 @@ export class InvasionExecution implements Execution {
|
||||
|
||||
private readonly invaders: PlayerInfo[] = [];
|
||||
private readonly aiAttached = new Set<PlayerID>();
|
||||
private readonly pendingEscorts: PendingEscort[] = [];
|
||||
private readonly pendingSpawns: PendingSpawn[] = [];
|
||||
|
||||
constructor(private gameID: GameID) {
|
||||
this.random = new PseudoRandom(simpleHash(gameID) + 7919);
|
||||
@@ -102,32 +125,19 @@ export class InvasionExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const difficulty = this.mg.config().gameConfig().difficulty;
|
||||
|
||||
// Bring landed invaders to life: per-player upkeep + normal nation AI.
|
||||
this.activateLandedInvaders();
|
||||
|
||||
// Release any escort warships whose staggered launch tick has arrived.
|
||||
this.processPendingEscorts(ticks);
|
||||
// Release any formation spawns whose scheduled tick has arrived.
|
||||
this.processPendingSpawns(ticks);
|
||||
|
||||
// Waves.
|
||||
if (this.nextWaveTick < 0) {
|
||||
this.nextWaveTick = ticks; // first wave fires immediately
|
||||
}
|
||||
if (ticks >= this.nextWaveTick) {
|
||||
this.launchWave(ticks, elapsed, difficulty);
|
||||
this.nextWaveTick = ticks + boatIntervalTicks(elapsed, difficulty);
|
||||
}
|
||||
|
||||
// Scheduled bombardment, once the tier is unlocked.
|
||||
if (nukeTier(elapsed, difficulty) !== "none") {
|
||||
if (this.nextBombTick < 0) {
|
||||
this.nextBombTick = ticks; // first strike as soon as unlocked
|
||||
}
|
||||
if (ticks >= this.nextBombTick) {
|
||||
this.launchBomb(elapsed, difficulty);
|
||||
this.nextBombTick = ticks + bombIntervalTicks(elapsed, difficulty);
|
||||
}
|
||||
this.launchWave(ticks, elapsed);
|
||||
this.nextWaveTick = ticks + boatIntervalTicks(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,11 +159,7 @@ export class InvasionExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private launchWave(
|
||||
ticks: number,
|
||||
elapsed: number,
|
||||
difficulty: Difficulty,
|
||||
): void {
|
||||
private launchWave(ticks: number, elapsed: number): void {
|
||||
// Resolve the route before committing a launcher, so a failed geometry
|
||||
// lookup doesn't leave a freshly-created invader stranded with no boat.
|
||||
const src = this.pickEdgeWaterTile();
|
||||
@@ -161,140 +167,196 @@ export class InvasionExecution implements Execution {
|
||||
const target = this.pickLandingShore(src);
|
||||
if (target === null) return;
|
||||
|
||||
const launcher = this.selectWaveLauncher(difficulty);
|
||||
const launcher = this.selectWaveLauncher(elapsed);
|
||||
if (launcher === null) return;
|
||||
const ownerId = launcher.id();
|
||||
|
||||
const troops = boatTroops(elapsed, difficulty, this.wavesLaunched);
|
||||
// The boat's troops are drawn from the player's pool on creation, so top
|
||||
// the launcher up by exactly that many troops first.
|
||||
launcher.addTroops(troops);
|
||||
this.mg.addExecution(
|
||||
new TransportShipExecution(launcher, target, troops, src),
|
||||
const warships = this.mg.config().isUnitDisabled(UnitType.Warship)
|
||||
? 0
|
||||
: warshipCount(this.random);
|
||||
|
||||
// Schedule the escort formation, then the transport behind it. Each boat
|
||||
// rolls its own missile package, fired from its spawn tile on arrival.
|
||||
const transportDelay = this.scheduleEscorts(
|
||||
ticks,
|
||||
ownerId,
|
||||
src,
|
||||
target,
|
||||
warships,
|
||||
);
|
||||
this.pendingSpawns.push({
|
||||
kind: "transport",
|
||||
fireTick: ticks + transportDelay,
|
||||
ownerId,
|
||||
spawnTile: src,
|
||||
target,
|
||||
troops: boatTroops(elapsed, this.wavesLaunched),
|
||||
strike: this.mg.config().isUnitDisabled(UnitType.AtomBomb)
|
||||
? []
|
||||
: selectInvasionStrike(elapsed, this.random),
|
||||
});
|
||||
this.wavesLaunched++;
|
||||
}
|
||||
|
||||
// Escort warships (weighted 0-3, only from minute 2 onward). They are
|
||||
// released ~0.5s apart, each heading to a slightly different point near the
|
||||
// landing, so they read as an escort rather than a stacked convoy.
|
||||
if (!this.mg.config().isUnitDisabled(UnitType.Warship)) {
|
||||
const escorts = warshipCount(elapsed, this.random, difficulty);
|
||||
const ownerId = launcher.id();
|
||||
for (let i = 0; i < escorts; i++) {
|
||||
this.pendingEscorts.push({
|
||||
fireTick: ticks + i * WARSHIP_STAGGER_TICKS,
|
||||
ownerId,
|
||||
spawnTile: this.randomWaterNear(src, 3),
|
||||
patrolTile: this.randomWaterNear(target, 6),
|
||||
/**
|
||||
* Queues the escort warships for a wave and returns how long (ticks) to delay
|
||||
* the transport so it slots in behind the assembled formation.
|
||||
*
|
||||
* - 1 escort: a single lead boat out front.
|
||||
* - 2 escorts: a flanker on each side of the transport's spawn.
|
||||
* - 3 escorts: a lead boat, then the two flankers 1s later.
|
||||
*/
|
||||
private scheduleEscorts(
|
||||
ticks: number,
|
||||
ownerId: PlayerID,
|
||||
src: TileRef,
|
||||
target: TileRef,
|
||||
warships: number,
|
||||
): number {
|
||||
const escort = (fireTick: number, spawnTile: TileRef) =>
|
||||
this.pendingSpawns.push({
|
||||
kind: "warship",
|
||||
fireTick,
|
||||
ownerId,
|
||||
spawnTile,
|
||||
patrolTile: this.randomWaterNear(target, 6),
|
||||
});
|
||||
|
||||
switch (warships) {
|
||||
case 0:
|
||||
return 0;
|
||||
case 1:
|
||||
escort(ticks, this.formationTile(src, target, FRONT_DIST, 0));
|
||||
return TRANSPORT_DELAY_ESCORTED;
|
||||
case 2:
|
||||
escort(
|
||||
ticks,
|
||||
this.formationTile(src, target, FLANK_FORWARD, FLANK_SIDE),
|
||||
);
|
||||
escort(
|
||||
ticks,
|
||||
this.formationTile(src, target, FLANK_FORWARD, -FLANK_SIDE),
|
||||
);
|
||||
return TRANSPORT_DELAY_ESCORTED;
|
||||
default: {
|
||||
escort(ticks, this.formationTile(src, target, FRONT_DIST, 0));
|
||||
const flankTick = ticks + FLANK_DELAY;
|
||||
escort(
|
||||
flankTick,
|
||||
this.formationTile(src, target, FLANK_FORWARD, FLANK_SIDE),
|
||||
);
|
||||
escort(
|
||||
flankTick,
|
||||
this.formationTile(src, target, FLANK_FORWARD, -FLANK_SIDE),
|
||||
);
|
||||
return TRANSPORT_DELAY_TRIPLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processPendingSpawns(ticks: number): void {
|
||||
if (this.pendingSpawns.length === 0) return;
|
||||
for (let i = this.pendingSpawns.length - 1; i >= 0; i--) {
|
||||
const spawn = this.pendingSpawns[i];
|
||||
if (ticks < spawn.fireTick) continue;
|
||||
this.pendingSpawns.splice(i, 1);
|
||||
if (!this.mg.hasPlayer(spawn.ownerId)) continue;
|
||||
const owner = this.mg.player(spawn.ownerId);
|
||||
if (spawn.kind === "warship") {
|
||||
if (this.mg.config().isUnitDisabled(UnitType.Warship)) continue;
|
||||
const warship = owner.buildUnit(UnitType.Warship, spawn.spawnTile, {
|
||||
patrolTile: spawn.patrolTile,
|
||||
});
|
||||
this.mg.addExecution(new WarshipExecution(warship));
|
||||
} else {
|
||||
// The boat's troops are drawn from the player's pool on creation, so top
|
||||
// the launcher up by exactly that many troops first.
|
||||
owner.addTroops(spawn.troops);
|
||||
this.mg.addExecution(
|
||||
new TransportShipExecution(
|
||||
owner,
|
||||
spawn.target,
|
||||
spawn.troops,
|
||||
spawn.spawnTile,
|
||||
),
|
||||
);
|
||||
// Missiles launch from the very tile the boat spawned at.
|
||||
this.launchStrike(owner, spawn.spawnTile, spawn.strike);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a boat's missile package from its open-water spawn tile. Each warhead
|
||||
* targets independently so an atom barrage rains across enemy territory. The
|
||||
* launcher is funded exactly for each warhead (its starting gold — the prize
|
||||
* for conquering it — is left intact).
|
||||
*/
|
||||
private launchStrike(
|
||||
owner: Player,
|
||||
srcTile: TileRef,
|
||||
strike: InvasionNuke[],
|
||||
): void {
|
||||
for (const nuke of strike) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses who launches the next wave: a brand-new invader nation while below
|
||||
* the difficulty cap, otherwise an existing active invader that still has room
|
||||
* for another boat. Returns null if every invader is at its boat limit.
|
||||
* the cap, otherwise an existing active invader that still has room for
|
||||
* another boat. Returns null if every invader is at its boat limit.
|
||||
*/
|
||||
private selectWaveLauncher(difficulty: Difficulty): Player | null {
|
||||
private selectWaveLauncher(elapsed: number): Player | null {
|
||||
const active = this.invaders.filter((info) => this.isActiveInvader(info));
|
||||
if (active.length < maxInvaderNations(difficulty)) {
|
||||
if (active.length < MAX_INVADER_NATIONS) {
|
||||
const info = this.createInvaderInfo();
|
||||
const invader = this.mg.addPlayer(info, ColoredTeams.Invaders);
|
||||
invader.addGold(invaderStartingGold(elapsed));
|
||||
this.invaders.push(info);
|
||||
return invader;
|
||||
}
|
||||
const boatMax = this.mg.config().boatMaxNumber();
|
||||
const candidates = active
|
||||
.map((info) => this.mg.player(info.id))
|
||||
.filter((p) => p.unitCount(UnitType.TransportShip) < boatMax);
|
||||
.filter((p) => this.boatLoad(p) < INVADER_BOAT_MAX);
|
||||
if (candidates.length === 0) return null;
|
||||
return this.random.randElement(candidates);
|
||||
}
|
||||
|
||||
/** An invader still in play: landed (alive) or with a boat still inbound. */
|
||||
/** Boats a player currently has in play: in flight plus scheduled to spawn. */
|
||||
private boatLoad(player: Player): number {
|
||||
let pending = 0;
|
||||
for (const spawn of this.pendingSpawns) {
|
||||
if (spawn.kind === "transport" && spawn.ownerId === player.id())
|
||||
pending++;
|
||||
}
|
||||
return player.unitCount(UnitType.TransportShip) + pending;
|
||||
}
|
||||
|
||||
/** An invader still in play: landed (alive), boat inbound, or boat pending. */
|
||||
private isActiveInvader(info: PlayerInfo): boolean {
|
||||
if (!this.mg.hasPlayer(info.id)) return false;
|
||||
const player = this.mg.player(info.id);
|
||||
return player.isAlive() || player.unitCount(UnitType.TransportShip) > 0;
|
||||
}
|
||||
|
||||
private processPendingEscorts(ticks: number): void {
|
||||
if (this.pendingEscorts.length === 0) return;
|
||||
if (this.mg.config().isUnitDisabled(UnitType.Warship)) {
|
||||
this.pendingEscorts.length = 0;
|
||||
return;
|
||||
if (player.isAlive() || player.unitCount(UnitType.TransportShip) > 0) {
|
||||
return true;
|
||||
}
|
||||
for (let i = this.pendingEscorts.length - 1; i >= 0; i--) {
|
||||
const escort = this.pendingEscorts[i];
|
||||
if (ticks < escort.fireTick) continue;
|
||||
this.pendingEscorts.splice(i, 1);
|
||||
if (!this.mg.hasPlayer(escort.ownerId)) continue;
|
||||
const owner = this.mg.player(escort.ownerId);
|
||||
const warship = owner.buildUnit(UnitType.Warship, escort.spawnTile, {
|
||||
patrolTile: escort.patrolTile,
|
||||
});
|
||||
this.mg.addExecution(new WarshipExecution(warship));
|
||||
}
|
||||
}
|
||||
|
||||
private launchBomb(elapsed: number, difficulty: Difficulty): void {
|
||||
const choice = selectInvasionNuke(elapsed, this.random, difficulty);
|
||||
if (choice === null) return;
|
||||
|
||||
const nukeType =
|
||||
choice === "atom"
|
||||
? UnitType.AtomBomb
|
||||
: choice === "hydrogen"
|
||||
? UnitType.HydrogenBomb
|
||||
: UnitType.MIRV;
|
||||
if (this.mg.config().isUnitDisabled(nukeType)) return;
|
||||
|
||||
const launcher = this.pickLaunchInvader();
|
||||
if (launcher === null) return;
|
||||
const dst = this.pickEnemyLandTarget();
|
||||
if (dst === null) return;
|
||||
|
||||
// A launch needs a usable missile silo and enough gold for the warhead.
|
||||
// Gold is clamped to >= 0 on spend, so fund the strike explicitly.
|
||||
if (!this.ensureSilo(launcher)) return;
|
||||
launcher.addGold(this.mg.unitInfo(nukeType).cost(this.mg, launcher));
|
||||
|
||||
if (choice === "mirv") {
|
||||
this.mg.addExecution(new MirvExecution(launcher, dst));
|
||||
} else {
|
||||
this.mg.addExecution(new NukeExecution(nukeType, launcher, dst));
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the launcher has an immediately-usable missile silo. */
|
||||
private ensureSilo(player: Player): boolean {
|
||||
if (this.mg.config().isUnitDisabled(UnitType.MissileSilo)) {
|
||||
return false;
|
||||
}
|
||||
const hasUsable = player
|
||||
.units(UnitType.MissileSilo)
|
||||
.some(
|
||||
(s) => s.isActive() && !s.isInCooldown() && !s.isUnderConstruction(),
|
||||
);
|
||||
if (hasUsable) return true;
|
||||
const tile = this.firstOwnedTile(player);
|
||||
if (tile === null) return false;
|
||||
// Built directly (not via ConstructionExecution) so it is usable at once.
|
||||
player.buildUnit(UnitType.MissileSilo, tile, {});
|
||||
return true;
|
||||
}
|
||||
|
||||
private pickLaunchInvader(): Player | null {
|
||||
const candidates: Player[] = [];
|
||||
for (const info of this.invaders) {
|
||||
if (!this.mg.hasPlayer(info.id)) continue;
|
||||
const player = this.mg.player(info.id);
|
||||
if (player.isAlive() && player.tiles().size > 0) {
|
||||
candidates.push(player);
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
return this.random.randElement(candidates);
|
||||
return this.pendingSpawns.some(
|
||||
(s) => s.kind === "transport" && s.ownerId === info.id,
|
||||
);
|
||||
}
|
||||
|
||||
private createInvaderInfo(): PlayerInfo {
|
||||
@@ -379,9 +441,56 @@ export class InvasionExecution implements Execution {
|
||||
}
|
||||
|
||||
/**
|
||||
* A water tile randomly offset within `radius` of `center`, so staggered
|
||||
* escorts spawn and patrol at spread-out points rather than the same tile.
|
||||
* Falls back to `center` if no nearby water is found.
|
||||
* A water tile `forward` tiles toward `target` and `side` tiles perpendicular
|
||||
* (positive = left of the heading) from `src`, snapped to the nearest water.
|
||||
* Used to place escorts into an arrowhead formation around the transport.
|
||||
*/
|
||||
private formationTile(
|
||||
src: TileRef,
|
||||
target: TileRef,
|
||||
forward: number,
|
||||
side: number,
|
||||
): TileRef {
|
||||
const sx = this.mg.x(src);
|
||||
const sy = this.mg.y(src);
|
||||
let ux = this.mg.x(target) - sx;
|
||||
let uy = this.mg.y(target) - sy;
|
||||
const len = Math.sqrt(ux * ux + uy * uy);
|
||||
if (len > 0) {
|
||||
ux /= len;
|
||||
uy /= len;
|
||||
} else {
|
||||
ux = 0;
|
||||
uy = -1;
|
||||
}
|
||||
// Perpendicular (rotate the heading 90°): left of travel.
|
||||
const px = -uy;
|
||||
const py = ux;
|
||||
const tx = Math.round(sx + ux * forward + px * side);
|
||||
const ty = Math.round(sy + uy * forward + py * side);
|
||||
return this.nearestWater(tx, ty, src);
|
||||
}
|
||||
|
||||
/** Nearest water tile to (cx, cy) via an expanding ring search. */
|
||||
private nearestWater(cx: number, cy: number, fallback: TileRef): TileRef {
|
||||
for (let r = 0; r <= 6; r++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
||||
const nx = cx + dx;
|
||||
const ny = cy + dy;
|
||||
if (!this.mg.isValidCoord(nx, ny)) continue;
|
||||
const ref = this.mg.ref(nx, ny);
|
||||
if (this.mg.isWater(ref)) return ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A water tile randomly offset within `radius` of `center`, so escorts patrol
|
||||
* at spread-out points rather than the same tile. Falls back to `center`.
|
||||
*/
|
||||
private randomWaterNear(center: TileRef, radius: number): TileRef {
|
||||
const cx = this.mg.x(center);
|
||||
@@ -398,13 +507,6 @@ export class InvasionExecution implements Execution {
|
||||
return center;
|
||||
}
|
||||
|
||||
private firstOwnedTile(player: Player): TileRef | null {
|
||||
for (const tile of player.tiles()) {
|
||||
return tile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
+111
-123
@@ -1,178 +1,166 @@
|
||||
import {
|
||||
boatIntervalTicks,
|
||||
boatTroops,
|
||||
bombIntervalTicks,
|
||||
maxInvaderNations,
|
||||
nukeTier,
|
||||
selectInvasionNuke,
|
||||
INVADER_BOAT_MAX,
|
||||
invaderStartingGold,
|
||||
MAX_INVADER_NATIONS,
|
||||
selectInvasionStrike,
|
||||
warshipCount,
|
||||
} from "../src/core/execution/invasion/InvasionConfig";
|
||||
import { Difficulty } from "../src/core/game/Game";
|
||||
import { PseudoRandom } from "../src/core/PseudoRandom";
|
||||
|
||||
const MIN = 600; // ticks per minute
|
||||
|
||||
describe("InvasionConfig.boatIntervalTicks", () => {
|
||||
test("starts around 15s and decreases monotonically to the 2s floor", () => {
|
||||
test("starts at 15s and decreases monotonically to the 2s floor", () => {
|
||||
let prev = Infinity;
|
||||
for (let m = 0; m <= 20; m++) {
|
||||
const interval = boatIntervalTicks(m * MIN, Difficulty.Medium);
|
||||
const interval = boatIntervalTicks(m * MIN);
|
||||
expect(interval).toBeLessThanOrEqual(prev);
|
||||
expect(interval).toBeGreaterThanOrEqual(20);
|
||||
prev = interval;
|
||||
}
|
||||
expect(boatIntervalTicks(0, Difficulty.Medium)).toBe(150);
|
||||
expect(boatIntervalTicks(20 * MIN, Difficulty.Medium)).toBe(20);
|
||||
expect(boatIntervalTicks(0)).toBe(150);
|
||||
expect(boatIntervalTicks(20 * MIN)).toBe(20);
|
||||
});
|
||||
|
||||
test("never drops below the 2s (20 tick) floor, even past 20 min", () => {
|
||||
expect(
|
||||
boatIntervalTicks(40 * MIN, Difficulty.Impossible),
|
||||
).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
test("higher difficulty sends boats at least as often", () => {
|
||||
const easy = boatIntervalTicks(5 * MIN, Difficulty.Easy);
|
||||
const impossible = boatIntervalTicks(5 * MIN, Difficulty.Impossible);
|
||||
expect(impossible).toBeLessThan(easy);
|
||||
expect(boatIntervalTicks(40 * MIN)).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvasionConfig.boatTroops", () => {
|
||||
test("starts at 30k for every difficulty and grows over time", () => {
|
||||
for (const d of [
|
||||
Difficulty.Easy,
|
||||
Difficulty.Medium,
|
||||
Difficulty.Hard,
|
||||
Difficulty.Impossible,
|
||||
]) {
|
||||
expect(boatTroops(0, d)).toBe(30_000);
|
||||
}
|
||||
expect(boatTroops(5 * MIN, Difficulty.Medium)).toBeGreaterThan(30_000);
|
||||
expect(boatTroops(10 * MIN, Difficulty.Medium)).toBeGreaterThan(
|
||||
boatTroops(5 * MIN, Difficulty.Medium),
|
||||
);
|
||||
test("starts at a few thousand and accelerates to ~350k by minute 20", () => {
|
||||
expect(boatTroops(0)).toBe(3_000);
|
||||
expect(boatTroops(5 * MIN)).toBeGreaterThan(boatTroops(0));
|
||||
expect(boatTroops(10 * MIN)).toBeGreaterThan(boatTroops(5 * MIN));
|
||||
const peak = boatTroops(20 * MIN);
|
||||
expect(peak).toBeGreaterThanOrEqual(300_000);
|
||||
expect(peak).toBeLessThanOrEqual(400_000);
|
||||
});
|
||||
|
||||
test("harder difficulties field larger waves and the count is capped", () => {
|
||||
expect(boatTroops(10 * MIN, Difficulty.Impossible)).toBeGreaterThan(
|
||||
boatTroops(10 * MIN, Difficulty.Easy),
|
||||
);
|
||||
expect(boatTroops(1000 * MIN, Difficulty.Impossible)).toBeLessThanOrEqual(
|
||||
350_000,
|
||||
);
|
||||
test("keeps growing linearly past minute 20 (never plateaus)", () => {
|
||||
expect(boatTroops(30 * MIN)).toBeGreaterThan(boatTroops(20 * MIN));
|
||||
expect(boatTroops(60 * MIN)).toBeGreaterThan(boatTroops(30 * MIN));
|
||||
});
|
||||
|
||||
test("each successive boat carries slightly more troops", () => {
|
||||
const first = boatTroops(0, Difficulty.Medium, 0);
|
||||
const later = boatTroops(0, Difficulty.Medium, 10);
|
||||
expect(later).toBeGreaterThan(first);
|
||||
// Still bounded by the cap even for huge wave indices.
|
||||
expect(boatTroops(0, Difficulty.Medium, 100_000)).toBeLessThanOrEqual(
|
||||
350_000,
|
||||
);
|
||||
expect(boatTroops(5 * MIN, 10)).toBeGreaterThan(boatTroops(5 * MIN, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvasionConfig.maxInvaderNations", () => {
|
||||
test("caps fewer nations on easier difficulties (5 easy, 20 impossible)", () => {
|
||||
expect(maxInvaderNations(Difficulty.Easy)).toBe(5);
|
||||
expect(maxInvaderNations(Difficulty.Impossible)).toBe(20);
|
||||
expect(maxInvaderNations(Difficulty.Easy)).toBeLessThan(
|
||||
maxInvaderNations(Difficulty.Medium),
|
||||
);
|
||||
expect(maxInvaderNations(Difficulty.Medium)).toBeLessThan(
|
||||
maxInvaderNations(Difficulty.Hard),
|
||||
);
|
||||
expect(maxInvaderNations(Difficulty.Hard)).toBeLessThan(
|
||||
maxInvaderNations(Difficulty.Impossible),
|
||||
describe("InvasionConfig.invaderStartingGold", () => {
|
||||
test("starts at a few thousand, rises, and is capped at 1m", () => {
|
||||
expect(invaderStartingGold(0)).toBe(3_000n);
|
||||
expect(invaderStartingGold(10 * MIN)).toBeGreaterThan(
|
||||
invaderStartingGold(1 * MIN),
|
||||
);
|
||||
expect(invaderStartingGold(10_000 * MIN)).toBeLessThanOrEqual(1_000_000n);
|
||||
});
|
||||
|
||||
test("rises monotonically toward the cap", () => {
|
||||
let prev = -1n;
|
||||
for (let m = 0; m <= 60; m += 5) {
|
||||
const gold = invaderStartingGold(m * MIN);
|
||||
expect(gold).toBeGreaterThanOrEqual(prev);
|
||||
prev = gold;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvasionConfig.maxima", () => {
|
||||
test("ten nations, three boats each", () => {
|
||||
expect(MAX_INVADER_NATIONS).toBe(10);
|
||||
expect(INVADER_BOAT_MAX).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvasionConfig.warshipCount", () => {
|
||||
test("is always 0 before minute 2", () => {
|
||||
const rng = new PseudoRandom(1);
|
||||
for (let m = 0; m < 2; m++) {
|
||||
expect(warshipCount(m * MIN, rng, Difficulty.Medium)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("stays within 0-3 once active", () => {
|
||||
test("stays within 0-3", () => {
|
||||
const rng = new PseudoRandom(42);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const n = warshipCount(10 * MIN, rng, Difficulty.Hard);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const n = warshipCount(rng);
|
||||
expect(n).toBeGreaterThanOrEqual(0);
|
||||
expect(n).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
test("averages higher later in the game (weighted toward 3)", () => {
|
||||
const avg = (elapsed: number) => {
|
||||
const rng = new PseudoRandom(7);
|
||||
let sum = 0;
|
||||
const samples = 400;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
sum += warshipCount(elapsed, rng, Difficulty.Medium);
|
||||
}
|
||||
return sum / samples;
|
||||
};
|
||||
expect(avg(15 * MIN)).toBeGreaterThan(avg(3 * MIN));
|
||||
test("is weighted toward 0 and 1", () => {
|
||||
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]);
|
||||
const avg = (counts[1] + 2 * counts[2] + 3 * counts[3]) / samples;
|
||||
expect(avg).toBeLessThan(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvasionConfig.nukeTier", () => {
|
||||
test("unlocks atom/hydrogen/mirv at 4/10/20 min on Medium", () => {
|
||||
expect(nukeTier(3 * MIN, Difficulty.Medium)).toBe("none");
|
||||
expect(nukeTier(4 * MIN, Difficulty.Medium)).toBe("atom");
|
||||
expect(nukeTier(9 * MIN, Difficulty.Medium)).toBe("atom");
|
||||
expect(nukeTier(10 * MIN, Difficulty.Medium)).toBe("hydrogen");
|
||||
expect(nukeTier(20 * MIN, Difficulty.Medium)).toBe("mirv");
|
||||
});
|
||||
|
||||
test("higher difficulty unlocks tiers earlier", () => {
|
||||
// Atom unlocks at minute 4 on Medium, minute 2 on Impossible.
|
||||
expect(nukeTier(2 * MIN, Difficulty.Medium)).toBe("none");
|
||||
expect(nukeTier(2 * MIN, Difficulty.Impossible)).toBe("atom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvasionConfig.selectInvasionNuke", () => {
|
||||
test("returns null before nukes unlock", () => {
|
||||
describe("InvasionConfig.selectInvasionStrike", () => {
|
||||
test("launches nothing in the first minute", () => {
|
||||
const rng = new PseudoRandom(3);
|
||||
expect(selectInvasionNuke(0, rng, Difficulty.Medium)).toBeNull();
|
||||
});
|
||||
|
||||
test("only atoms in the atom tier", () => {
|
||||
const rng = new PseudoRandom(3);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(selectInvasionNuke(5 * MIN, rng, Difficulty.Medium)).toBe("atom");
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(selectInvasionStrike(0, rng)).toEqual([]);
|
||||
expect(selectInvasionStrike(30 * 10, rng)).toEqual([]); // 30s
|
||||
}
|
||||
});
|
||||
|
||||
test("MIRVs appear (~10%) only once the mirv tier is reached", () => {
|
||||
const rng = new PseudoRandom(99);
|
||||
let mirvs = 0;
|
||||
let total = 0;
|
||||
test("at minute 1: only single atoms/hydrogens, never a MIRV", () => {
|
||||
const rng = new PseudoRandom(11);
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const pick = selectInvasionNuke(20 * MIN, rng, Difficulty.Medium);
|
||||
expect(pick).not.toBeNull();
|
||||
if (pick === "mirv") mirvs++;
|
||||
total++;
|
||||
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);
|
||||
}
|
||||
const ratio = mirvs / total;
|
||||
expect(ratio).toBeGreaterThan(0.03);
|
||||
expect(ratio).toBeLessThan(0.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvasionConfig.bombIntervalTicks", () => {
|
||||
test("ramps down with time and difficulty but respects the floor", () => {
|
||||
expect(bombIntervalTicks(20 * MIN, Difficulty.Medium)).toBeLessThan(
|
||||
bombIntervalTicks(4 * MIN, Difficulty.Medium),
|
||||
);
|
||||
expect(
|
||||
bombIntervalTicks(20 * MIN, Difficulty.Impossible),
|
||||
).toBeGreaterThanOrEqual(120);
|
||||
test("MIRVs never appear before minute 4", () => {
|
||||
const rng = new PseudoRandom(99);
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
expect(selectInvasionStrike(3 * MIN, rng)).not.toContain("mirv");
|
||||
}
|
||||
});
|
||||
|
||||
test("by minute 10: atoms barrage (5), hydrogens (2), rare MIRVs 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") {
|
||||
expect(strike.length).toBe(2);
|
||||
sawHydrogen2 = true;
|
||||
} else {
|
||||
expect(strike.length).toBe(5);
|
||||
sawAtom5 = true;
|
||||
}
|
||||
}
|
||||
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", () => {
|
||||
const rng = new PseudoRandom(55);
|
||||
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")
|
||||
expect(strike.length).toBeLessThanOrEqual(3);
|
||||
if (strike[0] === "mirv") expect(strike.length).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+85
-26
@@ -1,6 +1,7 @@
|
||||
import { maxInvaderNations } from "../src/core/execution/invasion/InvasionConfig";
|
||||
import { MAX_INVADER_NATIONS } from "../src/core/execution/invasion/InvasionConfig";
|
||||
import { InvasionExecution } from "../src/core/execution/invasion/InvasionExecution";
|
||||
import { NukeExecution } from "../src/core/execution/NukeExecution";
|
||||
import { WinCheckExecution } from "../src/core/execution/WinCheckExecution";
|
||||
import {
|
||||
ColoredTeams,
|
||||
Game,
|
||||
@@ -81,16 +82,13 @@ describe("Invasion Mode waves", () => {
|
||||
expect(invaders.length).toBe(0);
|
||||
});
|
||||
|
||||
test("never exceeds the difficulty cap of concurrent invader nations", async () => {
|
||||
const difficulty = "Impossible";
|
||||
test("never exceeds the cap of concurrent invader nations and escorts arrive", async () => {
|
||||
const game = await setup("ocean_and_land", {
|
||||
invasionMode: true,
|
||||
invasionGracePeriod: 0,
|
||||
difficulty: difficulty as never,
|
||||
});
|
||||
game.addExecution(new InvasionExecution(gameID));
|
||||
|
||||
const cap = maxInvaderNations(difficulty as never);
|
||||
let maxConcurrent = 0;
|
||||
let sawWarship = false;
|
||||
for (let i = 0; i < 1600; i++) {
|
||||
@@ -107,8 +105,7 @@ describe("Invasion Mode waves", () => {
|
||||
}
|
||||
|
||||
expect(maxConcurrent).toBeGreaterThan(0);
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(cap);
|
||||
// Escorts arrive once the invasion passes minute 2.
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(MAX_INVADER_NATIONS);
|
||||
expect(sawWarship).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -134,10 +131,10 @@ describe("Invasion Mode faction", () => {
|
||||
expect(invader.canSendAllianceRequest(human)).toBe(false);
|
||||
});
|
||||
|
||||
// Locks the assumption InvasionExecution.launchBomb relies on: a missile silo
|
||||
// built directly via buildUnit is immediately usable, so a (normally silo-less)
|
||||
// invader can be funded and fire a scheduled strike.
|
||||
test("an invader can build a silo and launch a strike", async () => {
|
||||
// The core of the new "missiles from the ocean" mechanic: a landless invader
|
||||
// (owns no tiles, no silo, not even "alive") can still fire a nuke straight
|
||||
// from an open-water spawn tile via NukeExecution's forced-src launch.
|
||||
test("a landless invader launches a nuke from open water", async () => {
|
||||
const game = await setup("half_land_half_ocean");
|
||||
const invaderInfo = new PlayerInfo(
|
||||
"Invader 1",
|
||||
@@ -152,29 +149,91 @@ describe("Invasion Mode faction", () => {
|
||||
const victim = game.player("vic");
|
||||
|
||||
const land: number[] = [];
|
||||
for (let x = 2; x < 14; x++) {
|
||||
for (let y = 2; y < 14; y++) {
|
||||
let water = -1;
|
||||
for (let x = 0; x < game.width(); x++) {
|
||||
for (let y = 0; y < game.height(); y++) {
|
||||
const r = game.ref(x, y);
|
||||
if (game.isLand(r)) land.push(r);
|
||||
else if (game.isWater(r) && water < 0) water = r;
|
||||
}
|
||||
}
|
||||
expect(water).toBeGreaterThanOrEqual(0);
|
||||
expect(land.length).toBeGreaterThan(10);
|
||||
for (let i = 0; i < 10; i++) victim.conquer(land[i]);
|
||||
|
||||
// The invader holds no territory — the normal canBuild path would refuse.
|
||||
expect(invader.isAlive()).toBe(false);
|
||||
|
||||
invader.addGold(game.unitInfo(UnitType.AtomBomb).cost(game, invader));
|
||||
game.addExecution(
|
||||
new NukeExecution(
|
||||
UnitType.AtomBomb,
|
||||
invader,
|
||||
land[5], // target a victim tile
|
||||
water, // launch from open water
|
||||
-1,
|
||||
0,
|
||||
true,
|
||||
true, // forceSrc
|
||||
),
|
||||
);
|
||||
|
||||
const tilesBefore = victim.numTilesOwned();
|
||||
let sawAtomBomb = false;
|
||||
let bombStartedFromWater = false;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
game.executeNextTick();
|
||||
for (const bomb of game.units(UnitType.AtomBomb)) {
|
||||
sawAtomBomb = true;
|
||||
if (game.isWater(bomb.tile())) bombStartedFromWater = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(sawAtomBomb).toBe(true);
|
||||
expect(bombStartedFromWater).toBe(true);
|
||||
expect(victim.numTilesOwned()).toBeLessThan(tilesBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Invasion Mode win conditions", () => {
|
||||
test("no player can win while the invader horde remains", async () => {
|
||||
const game = await setup("half_land_half_ocean", {
|
||||
invasionMode: true,
|
||||
maxTimerValue: 1, // game-length win condition fires after 60s
|
||||
});
|
||||
const humanInfo = new PlayerInfo("human", PlayerType.Human, null, "human");
|
||||
game.addPlayer(humanInfo);
|
||||
const invaderInfo = new PlayerInfo(
|
||||
"Invader 1",
|
||||
PlayerType.Nation,
|
||||
null,
|
||||
"invader",
|
||||
);
|
||||
game.addPlayer(invaderInfo, ColoredTeams.Invaders);
|
||||
const human = game.player("human");
|
||||
const invader = game.player("invader");
|
||||
|
||||
const land: number[] = [];
|
||||
for (let x = 0; x < game.width(); x++) {
|
||||
for (let y = 0; y < game.height(); y++) {
|
||||
const r = game.ref(x, y);
|
||||
if (game.isLand(r)) land.push(r);
|
||||
}
|
||||
}
|
||||
expect(land.length).toBeGreaterThan(1);
|
||||
// Human dominates the map; a single invader tile keeps the horde "alive".
|
||||
for (let i = 1; i < land.length; i++) human.conquer(land[i]);
|
||||
invader.conquer(land[0]);
|
||||
for (let i = 1; i < 6; i++) victim.conquer(land[i]);
|
||||
|
||||
// Mirror launchBomb: free instant silo, fund the warhead, fire.
|
||||
invader.buildUnit(UnitType.MissileSilo, land[0], {});
|
||||
invader.addGold(game.unitInfo(UnitType.AtomBomb).cost(game, invader));
|
||||
game.addExecution(new NukeExecution(UnitType.AtomBomb, invader, land[3]));
|
||||
game.addExecution(new WinCheckExecution());
|
||||
|
||||
const tilesBefore = victim.numTilesOwned();
|
||||
let sawAtomBomb = false;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
game.executeNextTick();
|
||||
if (game.units(UnitType.AtomBomb).length > 0) sawAtomBomb = true;
|
||||
}
|
||||
// Run well past the 60s game-length condition.
|
||||
for (let i = 0; i < 700; i++) game.executeNextTick();
|
||||
|
||||
expect(sawAtomBomb).toBe(true);
|
||||
expect(victim.numTilesOwned()).toBeLessThan(tilesBefore);
|
||||
expect(human.isAlive()).toBe(true);
|
||||
expect(invader.isAlive()).toBe(true);
|
||||
// The "You Won" path never triggers: an opposing (invader) player remains.
|
||||
expect(game.getWinner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user