diff --git a/resources/lang/en.json b/resources/lang/en.json index 5dfce1e70..042f0f18e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index ef67dd2a3..c6fd7dab3 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -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} >`, + html``, ]; 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, diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 994996ddd..322bb2738 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -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} >`, + html``, ]; 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 }, diff --git a/src/client/render/gl/colorblind-theme.json b/src/client/render/gl/colorblind-theme.json index 0d60fbbfd..7f0cc3967 100644 --- a/src/client/render/gl/colorblind-theme.json +++ b/src/client/render/gl/colorblind-theme.json @@ -9,7 +9,8 @@ "Green": "#56b4e9", "Bot": "#d1cdc7", "Humans": "#0072b2", - "Nations": "#d55e00" + "Nations": "#d55e00", + "Invaders": "#a00000" }, "humanColors": [ "#b60056", diff --git a/src/client/render/gl/default-theme.json b/src/client/render/gl/default-theme.json index fd0b97ec9..00da0764d 100644 --- a/src/client/render/gl/default-theme.json +++ b/src/client/render/gl/default-theme.json @@ -9,7 +9,8 @@ "Green": "#41be52", "Bot": "#d1cdc7", "Humans": "#2962ff", - "Nations": "#eb3333" + "Nations": "#eb3333", + "Invaders": "#b71c1c" }, "humanColors": [ "#a3e635", diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 8f931502a..098de1cb0 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -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( diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 68bd7f724..b02a9b720 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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 diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 16d7ebee6..33d98d0d3 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -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; } diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index ccdb792d6..e83b9e7ba 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -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); + } } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 65ceb7ec1..f7e1c1fc6 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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( diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 8b8664353..9f707ad26 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -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; diff --git a/src/core/execution/invasion/InvasionConfig.ts b/src/core/execution/invasion/InvasionConfig.ts new file mode 100644 index 000000000..a2e19cda5 --- /dev/null +++ b/src/core/execution/invasion/InvasionConfig.ts @@ -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)), + ); +} diff --git a/src/core/execution/invasion/InvasionExecution.ts b/src/core/execution/invasion/InvasionExecution.ts new file mode 100644 index 000000000..3dbf6b1de --- /dev/null +++ b/src/core/execution/invasion/InvasionExecution.ts @@ -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(); + + 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; + } +} diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 2fb858944..f3f008149 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -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) => diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index dfb751e0a..26aea320e 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -91,6 +91,7 @@ export const ColoredTeams: Record = { 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; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 5d216f29b..137e5776d 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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. diff --git a/tests/InvasionConfig.test.ts b/tests/InvasionConfig.test.ts new file mode 100644 index 000000000..2b249c7d9 --- /dev/null +++ b/tests/InvasionConfig.test.ts @@ -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); + }); +}); diff --git a/tests/InvasionMode.test.ts b/tests/InvasionMode.test.ts new file mode 100644 index 000000000..ab652c75d --- /dev/null +++ b/tests/InvasionMode.test.ts @@ -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); + }); +}); diff --git a/tests/NationAllianceBehavior.test.ts b/tests/NationAllianceBehavior.test.ts index f7b0ffc0f..6a9d2ef97 100644 --- a/tests/NationAllianceBehavior.test.ts +++ b/tests/NationAllianceBehavior.test.ts @@ -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(