mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
invasion v1
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"Green": "#56b4e9",
|
||||
"Bot": "#d1cdc7",
|
||||
"Humans": "#0072b2",
|
||||
"Nations": "#d55e00"
|
||||
"Nations": "#d55e00",
|
||||
"Invaders": "#a00000"
|
||||
},
|
||||
"humanColors": [
|
||||
"#b60056",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"Green": "#41be52",
|
||||
"Bot": "#d1cdc7",
|
||||
"Humans": "#2962ff",
|
||||
"Nations": "#eb3333"
|
||||
"Nations": "#eb3333",
|
||||
"Invaders": "#b71c1c"
|
||||
},
|
||||
"humanColors": [
|
||||
"#a3e635",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user