invasion v1

This commit is contained in:
bijx
2026-06-15 17:38:53 -04:00
parent 5be72db060
commit 6afc16059f
19 changed files with 1026 additions and 5 deletions
+6
View File
@@ -701,6 +701,9 @@
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"instant_build": "Instant build",
"invasion_grace": "Invasion grace period (minutes)",
"invasion_grace_placeholder": "Mins",
"invasion_mode": "Invasion mode",
"leave_confirmation": "Are you sure you want to leave the lobby?",
"max_timer": "Game length (minutes)",
"mins_placeholder": "Mins",
@@ -1188,6 +1191,9 @@
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"instant_build": "Instant build",
"invasion_grace": "Invasion grace period (minutes)",
"invasion_grace_placeholder": "Mins",
"invasion_mode": "Invasion mode",
"max_timer": "Game length (minutes)",
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
"max_timer_placeholder": "Mins",
+48
View File
@@ -81,6 +81,8 @@ export class HostLobbyModal extends BaseModal {
@state() private startingGoldValue: number | undefined = undefined;
@state() private disableAlliances: boolean = false;
@state() private waterNukes: boolean = false;
@state() private invasionMode: boolean = false;
@state() private invasionGracePeriod: number | undefined = undefined;
@state() private lobbyId = "";
@state() private lobbyUrlSuffix = "";
@state() private clients: ClientInfo[] = [];
@@ -288,6 +290,23 @@ export class HostLobbyModal extends BaseModal {
.onChange=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"host_modal.invasion_mode"}
.checked=${this.invasionMode}
.inputId=${"invasion-grace-value"}
.inputMin=${0}
.inputMax=${15}
.inputValue=${this.invasionGracePeriod}
.inputAriaLabel=${translateText("host_modal.invasion_grace")}
.inputPlaceholder=${translateText(
"host_modal.invasion_grace_placeholder",
)}
.defaultInputValue=${2}
.minValidOnEnable=${0}
.onToggle=${this.handleInvasionToggle}
.onInput=${this.handleInvasionGraceChanges}
.onKeyDown=${this.handleInvasionGraceKeyDown}
></toggle-input-card>`,
];
const hostCheatInputCards = [
@@ -584,6 +603,8 @@ export class HostLobbyModal extends BaseModal {
this.startingGoldValue = undefined;
this.disableAlliances = false;
this.waterNukes = false;
this.invasionMode = false;
this.invasionGracePeriod = undefined;
this.hostCheatsEnabled = false;
this.hostCheatInfiniteGold = false;
this.hostCheatInfiniteTroops = false;
@@ -780,6 +801,29 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
};
private handleInvasionToggle = (
checked: boolean,
value: number | string | undefined,
) => {
this.invasionMode = checked;
this.invasionGracePeriod = toOptionalNumber(value);
this.putGameConfig();
};
private handleInvasionGraceKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e", "E", "."]);
};
private handleInvasionGraceChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseBoundedIntegerFromInput(input, { min: 0, max: 15 });
if (value === undefined) {
return;
}
this.invasionGracePeriod = value;
this.putGameConfig();
};
private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e", "E"]);
};
@@ -1037,6 +1081,10 @@ export class HostLobbyModal extends BaseModal {
: null,
disableAlliances: this.disableAlliances || null,
waterNukes: this.waterNukes ? true : null,
invasionMode: this.invasionMode || undefined,
invasionGracePeriod: this.invasionMode
? Math.max(0, Math.min(15, this.invasionGracePeriod ?? 0))
: null,
hostCheats: this.hostCheatsEnabled
? {
infiniteGold: this.hostCheatInfiniteGold || undefined,
+57
View File
@@ -59,6 +59,8 @@ const DEFAULT_OPTIONS = {
disabledUnits: [] as UnitType[],
disableAlliances: false,
waterNukes: false,
invasionMode: false,
invasionGracePeriod: undefined as number | undefined,
} as const;
@customElement("single-player-modal")
@@ -97,6 +99,9 @@ export class SinglePlayerModal extends BaseModal {
];
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
@state() private invasionMode: boolean = DEFAULT_OPTIONS.invasionMode;
@state() private invasionGracePeriod: number | undefined =
DEFAULT_OPTIONS.invasionGracePeriod;
private mapLoader = terrainMapFileLoader;
@@ -251,6 +256,23 @@ export class SinglePlayerModal extends BaseModal {
.onChange=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"single_modal.invasion_mode"}
.checked=${this.invasionMode}
.inputId=${"invasion-grace-value"}
.inputMin=${0}
.inputMax=${15}
.inputValue=${this.invasionGracePeriod}
.inputAriaLabel=${translateText("single_modal.invasion_grace")}
.inputPlaceholder=${translateText(
"single_modal.invasion_grace_placeholder",
)}
.defaultInputValue=${2}
.minValidOnEnable=${0}
.onToggle=${this.handleInvasionToggle}
.onInput=${this.handleInvasionGraceChanges}
.onKeyDown=${this.handleInvasionGraceKeyDown}
></toggle-input-card>`,
];
return html`
@@ -377,6 +399,7 @@ export class SinglePlayerModal extends BaseModal {
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances ||
this.waterNukes !== DEFAULT_OPTIONS.waterNukes ||
this.invasionMode !== DEFAULT_OPTIONS.invasionMode ||
this.disabledUnits.length > 0
);
}
@@ -405,6 +428,8 @@ export class SinglePlayerModal extends BaseModal {
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
this.disableAlliances = DEFAULT_OPTIONS.disableAlliances;
this.waterNukes = DEFAULT_OPTIONS.waterNukes;
this.invasionMode = DEFAULT_OPTIONS.invasionMode;
this.invasionGracePeriod = DEFAULT_OPTIONS.invasionGracePeriod;
}
protected onOpen(): void {
@@ -547,6 +572,29 @@ export class SinglePlayerModal extends BaseModal {
this.startingGoldValue = toOptionalNumber(value);
};
private handleInvasionToggle = (
checked: boolean,
value: number | string | undefined,
) => {
this.invasionMode = checked;
this.invasionGracePeriod = toOptionalNumber(value);
};
private handleInvasionGraceKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e", "E", "."]);
};
private handleInvasionGraceChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseBoundedIntegerFromInput(input, {
min: 0,
max: 15,
stripPattern: /[e+\-.]/gi,
});
this.invasionGracePeriod = value;
};
private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e"]);
};
@@ -698,6 +746,15 @@ export class SinglePlayerModal extends BaseModal {
: {}),
...(this.disableAlliances ? { disableAlliances: true } : {}),
...(this.waterNukes ? { waterNukes: true } : {}),
...(this.invasionMode
? {
invasionMode: true,
invasionGracePeriod: Math.max(
0,
Math.min(15, this.invasionGracePeriod ?? 0),
),
}
: {}),
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
},
+2 -1
View File
@@ -9,7 +9,8 @@
"Green": "#56b4e9",
"Bot": "#d1cdc7",
"Humans": "#0072b2",
"Nations": "#d55e00"
"Nations": "#d55e00",
"Invaders": "#a00000"
},
"humanColors": [
"#b60056",
+2 -1
View File
@@ -9,7 +9,8 @@
"Green": "#41be52",
"Bot": "#d1cdc7",
"Humans": "#2962ff",
"Nations": "#eb3333"
"Nations": "#eb3333",
"Invaders": "#b71c1c"
},
"humanColors": [
"#a3e635",
+3
View File
@@ -111,6 +111,9 @@ export class GameRunner {
...this.execManager.spawnTribes(this.game.config().bots()),
);
}
if (this.game.config().invasionMode()) {
this.game.addExecution(this.execManager.invasionExecution());
}
this.game.addExecution(new WinCheckExecution());
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
this.game.addExecution(
+2
View File
@@ -281,6 +281,8 @@ export const GameConfigSchema = z.object({
disableClanTags: z.boolean().optional(),
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
invasionMode: z.boolean().optional(),
invasionGracePeriod: z.number().int().min(0).max(15).nullable().optional(), // In minutes
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
startDelay: z.number().int().min(0).max(600).nullable().optional(), // In seconds
+6
View File
@@ -185,6 +185,12 @@ export class Config {
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
invasionMode(): boolean {
return this._gameConfig.invasionMode ?? false;
}
invasionGracePeriodTicks(): Tick {
return (this._gameConfig.invasionGracePeriod ?? 0) * 60 * 10;
}
infiniteGold(): boolean {
return this._gameConfig.infiniteGold;
}
+5
View File
@@ -15,6 +15,7 @@ import { DonateTroopsExecution } from "./DonateTroopExecution";
import { EmbargoAllExecution } from "./EmbargoAllExecution";
import { EmbargoExecution } from "./EmbargoExecution";
import { EmojiExecution } from "./EmojiExecution";
import { InvasionExecution } from "./invasion/InvasionExecution";
import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NationExecution } from "./NationExecution";
@@ -141,4 +142,8 @@ export class Executor {
}
return execs;
}
invasionExecution(): Execution {
return new InvasionExecution(this.gameID);
}
}
+7 -1
View File
@@ -44,6 +44,10 @@ export class TransportShipExecution implements Execution {
private attacker: Player,
private ref: TileRef,
private troops: number,
// Optional explicit launch tile (water). Used by Invasion Mode to launch
// a boat from a map-edge water tile for an attacker that owns no shore.
// When undefined, the source is resolved normally via canBuild().
private srcOverride?: TileRef,
) {
this.originalOwner = this.attacker;
}
@@ -116,7 +120,9 @@ export class TransportShipExecution implements Execution {
return;
}
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
const src =
this.srcOverride ??
this.attacker.canBuild(UnitType.TransportShip, this.dst);
if (src === false) {
console.warn(
+2 -1
View File
@@ -109,7 +109,8 @@ export class WinCheckExecution implements Execution {
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) ||
timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS
) {
if (max[0] === ColoredTeams.Bot) return;
if (max[0] === ColoredTeams.Bot || max[0] === ColoredTeams.Invaders)
return;
this.mg.setWinner(max[0], this.mg.stats().stats());
console.log(`${max[0]} has won the game`);
this.active = false;
@@ -0,0 +1,204 @@
import { Difficulty } 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.
*/
const TICKS_PER_MINUTE = 600;
// 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.
const TROOPS_START = 30_000;
const TROOPS_GROWTH_PER_MINUTE = 8_000;
const TROOPS_CAP = 350_000;
// 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
// Scheduled bombardment cadence once nukes are unlocked.
const BOMB_INTERVAL_START = 350; // 35s
const BOMB_INTERVAL_FLOOR = 120; // 12s
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,
): 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);
}
/** Troop count carried by a transport launched at the given elapsed time. */
export function boatTroops(
elapsedTicks: number,
difficulty: Difficulty,
): number {
const minutesTimesGrowth = Math.floor(
(TROOPS_GROWTH_PER_MINUTE * Math.max(0, elapsedTicks)) / TICKS_PER_MINUTE,
);
const scaledGrowth = Math.floor(
(minutesTimesGrowth * difficultyIntensity(difficulty)) / 100,
);
return Math.min(TROOPS_CAP, TROOPS_START + scaledGrowth);
}
/**
* 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.
*/
export function warshipCount(
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;
}
/** 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);
}
}
/** 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)),
);
}
@@ -0,0 +1,345 @@
import {
ColoredTeams,
Difficulty,
Execution,
Game,
Nation,
Player,
PlayerID,
PlayerInfo,
PlayerType,
UnitType,
} from "../../game/Game";
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";
import { TransportShipExecution } from "../TransportShipExecution";
import { WarshipExecution } from "../WarshipExecution";
import {
boatIntervalTicks,
boatTroops,
bombIntervalTicks,
nukeTier,
selectInvasionNuke,
warshipCount,
} from "./InvasionConfig";
/**
* 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 each a fresh invader `Nation` on the shared
* `Invaders` team, ferried in from a random map-edge water tile to a nearby
* shore. 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.
*
* 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;
private mg: Game;
private random: PseudoRandom;
// Tick (absolute) at which the invasion clock starts, i.e. the first
// post-spawn-phase tick. The grace period is measured from here.
private startTick = -1;
private graceTicks = 0;
private nextWaveTick = -1;
private nextBombTick = -1;
private invaderCounter = 0;
// Minimum manhattan distance an invader must travel before landfall.
private minLandingDist = 0;
private readonly invaders: PlayerInfo[] = [];
private readonly aiAttached = new Set<PlayerID>();
constructor(private gameID: GameID) {
this.random = new PseudoRandom(simpleHash(gameID) + 7919);
}
init(mg: Game, ticks: number): void {
this.mg = mg;
this.graceTicks = mg.config().invasionGracePeriodTicks();
// Far enough that the boat must visibly travel, scaled to the map but
// capped so huge maps don't send boats on endless voyages.
this.minLandingDist = Math.max(
8,
Math.min(400, Math.floor(Math.min(mg.width(), mg.height()) * 0.18)),
);
}
tick(ticks: number): void {
if (this.startTick < 0) {
this.startTick = ticks;
}
const elapsed = ticks - this.startTick - this.graceTicks;
if (elapsed < 0) {
// Still within the grace period.
return;
}
const difficulty = this.mg.config().gameConfig().difficulty;
// Bring landed invaders to life: per-player upkeep + normal nation AI.
this.activateLandedInvaders();
// Waves.
if (this.nextWaveTick < 0) {
this.nextWaveTick = ticks; // first wave fires immediately
}
if (ticks >= this.nextWaveTick) {
this.launchWave(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);
}
}
}
private activateLandedInvaders(): void {
for (const info of this.invaders) {
if (this.aiAttached.has(info.id)) continue;
if (!this.mg.hasPlayer(info.id)) continue;
const player = this.mg.player(info.id);
// Wait for landfall: PlayerExecution removes a landless player at once,
// and NationExecution self-deactivates post-spawn without territory.
if (!player.isAlive()) continue;
// PlayerExecution drives upkeep (income, troop growth, death handling)
// — normally added by SpawnExecution, which invaders never go through.
this.mg.addExecution(new PlayerExecution(player));
this.mg.addExecution(
new NationExecution(this.gameID, new Nation(undefined, info)),
);
this.aiAttached.add(info.id);
}
}
private launchWave(elapsed: number, difficulty: Difficulty): void {
const src = this.pickEdgeWaterTile();
if (src === null) return;
const target = this.pickLandingShore(src);
if (target === null) return;
const troops = boatTroops(elapsed, difficulty);
const info = this.createInvaderInfo();
const invader = this.mg.addPlayer(info, ColoredTeams.Invaders);
this.invaders.push(info);
// The boat's troops are drawn from the player's pool on creation, so seed
// the brand-new invader with exactly that many troops first.
invader.addTroops(troops);
this.mg.addExecution(
new TransportShipExecution(invader, target, troops, src),
);
// Escort warships (weighted 0-3, only from minute 2 onward).
if (!this.mg.config().isUnitDisabled(UnitType.Warship)) {
const escorts = warshipCount(elapsed, this.random, difficulty);
const patrol = this.waterNear(target) ?? src;
for (let i = 0; i < escorts; i++) {
const warship = invader.buildUnit(UnitType.Warship, src, {
patrolTile: patrol,
});
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);
}
private createInvaderInfo(): PlayerInfo {
this.invaderCounter++;
return new PlayerInfo(
`Invader ${this.invaderCounter}`,
PlayerType.Nation,
null,
this.random.nextID(),
);
}
/** Random map-edge tile that is water. */
private pickEdgeWaterTile(): TileRef | null {
const w = this.mg.width();
const h = this.mg.height();
for (let i = 0; i < 500; i++) {
let x: number;
let y: number;
switch (this.random.nextInt(0, 4)) {
case 0:
x = this.random.nextInt(0, w);
y = 0;
break;
case 1:
x = this.random.nextInt(0, w);
y = h - 1;
break;
case 2:
x = 0;
y = this.random.nextInt(0, h);
break;
default:
x = w - 1;
y = this.random.nextInt(0, h);
break;
}
const ref = this.mg.ref(x, y);
if (this.mg.isWater(ref)) {
return ref;
}
}
return null;
}
/**
* Nearest ocean shore beyond `minLandingDist` from `src`, sampled randomly
* "closest, but not immediately near it" so the boat must travel first.
*/
private pickLandingShore(src: TileRef): TileRef | null {
let best: TileRef | null = null;
let bestDist = Number.MAX_SAFE_INTEGER;
let found = 0;
for (let i = 0; i < 800 && found < 40; i++) {
const x = this.random.nextInt(0, this.mg.width());
const y = this.random.nextInt(0, this.mg.height());
const ref = this.mg.ref(x, y);
if (!this.mg.isOceanShore(ref)) continue;
const dist = this.mg.manhattanDist(src, ref);
if (dist < this.minLandingDist) continue;
found++;
if (dist < bestDist) {
bestDist = dist;
best = ref;
}
}
return best;
}
private pickEnemyLandTarget(): TileRef | null {
for (let i = 0; i < 400; i++) {
const x = this.random.nextInt(0, this.mg.width());
const y = this.random.nextInt(0, this.mg.height());
const ref = this.mg.ref(x, y);
if (!this.mg.isLand(ref)) continue;
const owner = this.mg.owner(ref);
if (!owner.isPlayer()) continue;
if ((owner as Player).team() === ColoredTeams.Invaders) continue;
return ref;
}
return null;
}
private waterNear(tile: TileRef): TileRef | null {
const x = this.mg.x(tile);
const y = this.mg.y(tile);
const offsets = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
[2, 0],
[-2, 0],
[0, 2],
[0, -2],
];
for (const [dx, dy] of offsets) {
const nx = x + dx;
const ny = y + dy;
if (!this.mg.isValidCoord(nx, ny)) continue;
const ref = this.mg.ref(nx, ny);
if (this.mg.isWater(ref)) {
return ref;
}
}
return null;
}
private firstOwnedTile(player: Player): TileRef | null {
for (const tile of player.tiles()) {
return tile;
}
return null;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -1,4 +1,5 @@
import {
ColoredTeams,
Difficulty,
Game,
GameMode,
@@ -26,9 +27,23 @@ export class NationAllianceBehavior {
private emojiBehavior: NationEmojiBehavior,
) {}
private isInvader(): boolean {
// Invaders are a hostile horde faction: they never form, accept, or
// request alliances. Inert when no invader player exists.
return this.player.team() === ColoredTeams.Invaders;
}
handleAllianceRequests() {
if (this.game.config().disableAlliances()) return;
// Invaders reject every incoming request outright.
if (this.isInvader()) {
for (const req of this.player.incomingAllianceRequests()) {
req.reject();
}
return;
}
for (const req of this.player.incomingAllianceRequests()) {
// Alliance Request intents created during the spawn phase are executed on
// the first tick post-spawn phase. With the following condition we reject
@@ -47,6 +62,7 @@ export class NationAllianceBehavior {
handleAllianceExtensionRequests() {
if (this.game.config().disableAlliances()) return;
if (this.isInvader()) return;
for (const alliance of this.player.alliances()) {
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
@@ -64,6 +80,7 @@ export class NationAllianceBehavior {
maybeSendAllianceRequests(borderingEnemies: Player[]) {
if (this.game.config().disableAlliances()) return;
if (this.isInvader()) return;
// Only easy nations are allowed to send alliance requests to bots
const isAcceptablePlayerType = (p: Player) =>
+2 -1
View File
@@ -91,6 +91,7 @@ export const ColoredTeams: Record<string, Team> = {
Bot: "Bot",
Humans: "Humans",
Nations: "Nations",
Invaders: "Invaders",
} as const;
// GameMapType and the maps list are generated from
@@ -700,7 +701,7 @@ export interface Game extends GameMap {
playerByClientID(id: ClientID): Player | null;
playerBySmallID(id: number): Player | TerraNullius;
hasPlayer(id: PlayerID): boolean;
addPlayer(playerInfo: PlayerInfo): Player;
addPlayer(playerInfo: PlayerInfo, team?: Team | null): Player;
terraNullius(): TerraNullius;
owner(ref: TileRef): Player | TerraNullius;
+7
View File
@@ -668,6 +668,13 @@ export class PlayerImpl implements Player {
if (other === this) {
return false;
}
// Invaders (hostile horde faction) can neither send nor receive alliances.
if (
this.team() === ColoredTeams.Invaders ||
other.team() === ColoredTeams.Invaders
) {
return false;
}
if (this.isDisconnected() || other.isDisconnected()) {
// Disconnected players are marked as not-friendly even if they are allies,
// so we need to return early if either player is disconnected.
+151
View File
@@ -0,0 +1,151 @@
import {
boatIntervalTicks,
boatTroops,
bombIntervalTicks,
nukeTier,
selectInvasionNuke,
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", () => {
let prev = Infinity;
for (let m = 0; m <= 20; m++) {
const interval = boatIntervalTicks(m * MIN, Difficulty.Medium);
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);
});
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);
});
});
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("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,
);
});
});
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", () => {
const rng = new PseudoRandom(42);
for (let i = 0; i < 200; i++) {
const n = warshipCount(10 * MIN, rng, Difficulty.Hard);
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));
});
});
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", () => {
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");
}
});
test("MIRVs appear (~10%) only once the mirv tier is reached", () => {
const rng = new PseudoRandom(99);
let mirvs = 0;
let total = 0;
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 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);
});
});
+159
View File
@@ -0,0 +1,159 @@
import { InvasionExecution } from "../src/core/execution/invasion/InvasionExecution";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import {
ColoredTeams,
Game,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
const gameID: GameID = "invasion_test_game";
describe("Invasion Mode config", () => {
test("getters default to disabled / zero grace", async () => {
const game = await setup("half_land_half_ocean");
expect(game.config().invasionMode()).toBe(false);
expect(game.config().invasionGracePeriodTicks()).toBe(0);
});
test("grace period converts minutes to ticks (10 ticks/sec)", async () => {
const game = await setup("half_land_half_ocean", {
invasionMode: true,
invasionGracePeriod: 3,
});
expect(game.config().invasionMode()).toBe(true);
expect(game.config().invasionGracePeriodTicks()).toBe(3 * 60 * 10);
});
});
describe("Invasion Mode waves", () => {
test("launches an invader nation by sea from a water edge", async () => {
const game = await setup("half_land_half_ocean", {
invasionMode: true,
invasionGracePeriod: 0,
});
game.addExecution(new InvasionExecution(gameID));
// Transports land quickly on the tiny test map, so watch every tick.
let sawInvaderTransport = false;
for (let i = 0; i < 250; i++) {
game.executeNextTick();
for (const unit of game.units(UnitType.TransportShip)) {
if (unit.owner().team() === ColoredTeams.Invaders) {
sawInvaderTransport = true;
}
}
}
const invaders = game
.players()
.filter((p) => p.team() === ColoredTeams.Invaders);
expect(invaders.length).toBeGreaterThan(0);
expect(sawInvaderTransport).toBe(true);
const invader = invaders[0];
expect(invader.type()).toBe(PlayerType.Nation);
expect(invader.name().startsWith("Invader")).toBe(true);
// At least one invader has made landfall and now holds territory.
expect(invaders.some((p) => p.isAlive() && p.numTilesOwned() > 0)).toBe(
true,
);
});
test("respects the grace period before sending any waves", async () => {
const game = await setup("half_land_half_ocean", {
invasionMode: true,
invasionGracePeriod: 1, // 600 ticks
});
game.addExecution(new InvasionExecution(gameID));
for (let i = 0; i < 100; i++) game.executeNextTick();
const invaders = game
.players()
.filter((p) => p.team() === ColoredTeams.Invaders);
expect(invaders.length).toBe(0);
});
});
describe("Invasion Mode faction", () => {
test("invaders cannot form alliances with anyone", async () => {
const game = await setup("half_land_half_ocean");
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");
expect(invader.team()).toBe(ColoredTeams.Invaders);
expect(human.canSendAllianceRequest(invader)).toBe(false);
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 () => {
const game = await setup("half_land_half_ocean");
const invaderInfo = new PlayerInfo(
"Invader 1",
PlayerType.Nation,
null,
"invader",
);
game.addPlayer(invaderInfo, ColoredTeams.Invaders);
const victimInfo = new PlayerInfo("victim", PlayerType.Human, null, "vic");
game.addPlayer(victimInfo);
const invader = game.player("invader");
const victim = game.player("vic");
const land: number[] = [];
for (let x = 2; x < 14; x++) {
for (let y = 2; y < 14; y++) {
const r = game.ref(x, y);
if (game.isLand(r)) land.push(r);
}
}
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]));
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;
}
expect(sawAtomBomb).toBe(true);
expect(victim.numTilesOwned()).toBeLessThan(tilesBefore);
});
});
describe("Invasion Mode is inert when disabled", () => {
test("no invaders appear without the execution", async () => {
const game: Game = await setup("half_land_half_ocean");
for (let i = 0; i < 250; i++) game.executeNextTick();
const invaders = game
.players()
.filter((p) => p.team() === ColoredTeams.Invaders);
expect(invaders.length).toBe(0);
expect(game.units(UnitType.TransportShip).length).toBe(0);
});
});
+1
View File
@@ -176,6 +176,7 @@ describe("AllianceBehavior.handleAllianceExtensionRequests", () => {
relation: vi.fn(),
id: vi.fn(() => "bot_id"),
type: vi.fn(() => PlayerType.Nation),
team: vi.fn(() => null),
};
allianceBehavior = new NationAllianceBehavior(