troop bonuses and nation caps

This commit is contained in:
bijx
2026-06-15 17:39:15 -04:00
parent 6afc16059f
commit 3aabf16904
4 changed files with 199 additions and 41 deletions
+32 -3
View File
@@ -21,9 +21,11 @@ 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.
// Transport population: starts at 30k, grows with time and difficulty, plus a
// small bump for each successive boat sent (waves get a little stronger).
const TROOPS_START = 30_000;
const TROOPS_GROWTH_PER_MINUTE = 8_000;
const TROOPS_PER_WAVE_BONUS = 600;
const TROOPS_CAP = 350_000;
// Escalation onsets (before difficulty time-shift).
@@ -105,10 +107,15 @@ export function boatIntervalTicks(
return Math.max(BOAT_INTERVAL_FLOOR, scaled);
}
/** Troop count carried by a transport launched at the given elapsed time. */
/**
* Troop count carried by a transport. Grows with elapsed time (scaled by
* difficulty) and gets a small cumulative bump per boat already sent, so each
* successive wave lands slightly more troops.
*/
export function boatTroops(
elapsedTicks: number,
difficulty: Difficulty,
waveIndex = 0,
): number {
const minutesTimesGrowth = Math.floor(
(TROOPS_GROWTH_PER_MINUTE * Math.max(0, elapsedTicks)) / TICKS_PER_MINUTE,
@@ -116,7 +123,29 @@ export function boatTroops(
const scaledGrowth = Math.floor(
(minutesTimesGrowth * difficultyIntensity(difficulty)) / 100,
);
return Math.min(TROOPS_CAP, TROOPS_START + scaledGrowth);
const waveBonus = Math.max(0, waveIndex) * TROOPS_PER_WAVE_BONUS;
return Math.min(TROOPS_CAP, TROOPS_START + scaledGrowth + waveBonus);
}
/**
* Maximum number of concurrent invader nations, capped by difficulty. Keeps the
* total faction count down (each nation can field up to `boatMaxNumber` boats),
* so the invasion arrives as fewer, busier nations rather than a swarm of one-
* boat nations.
*/
export function maxInvaderNations(difficulty: Difficulty): number {
switch (difficulty) {
case Difficulty.Easy:
return 5;
case Difficulty.Medium:
return 10;
case Difficulty.Hard:
return 15;
case Difficulty.Impossible:
return 20;
default:
assertNever(difficulty);
}
}
/**
+108 -38
View File
@@ -24,22 +24,35 @@ import {
boatIntervalTicks,
boatTroops,
bombIntervalTicks,
maxInvaderNations,
nukeTier,
selectInvasionNuke,
warshipCount,
} from "./InvasionConfig";
// Escort warships are released ~0.5s apart so they don't stack on the map.
const WARSHIP_STAGGER_TICKS = 5;
interface PendingEscort {
fireTick: number;
ownerId: PlayerID;
spawnTile: TileRef;
patrolTile: TileRef;
}
/**
* 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.
* launches periodic waves — boats ferried in from a random map-edge water tile
* to a nearby shore. The number of distinct invader `Nation`s (all on the
* shared `Invaders` team) is capped by difficulty; once that cap is reached,
* existing invaders send additional boats (up to `boatMaxNumber` each) instead
* of new nations spawning. Escort warships join from minute 2, and scheduled
* atom/hydrogen/MIRV strikes begin at minutes 4/10/20 (shifted by difficulty).
* Once an invader makes landfall it is handed a stock `NationExecution`, so it
* then builds and attacks like any AI nation at the lobby difficulty.
*
* Determinism: all randomness comes from a single seeded `PseudoRandom`; the
* escalation clock is derived from integer tick counts.
@@ -56,11 +69,13 @@ export class InvasionExecution implements Execution {
private nextWaveTick = -1;
private nextBombTick = -1;
private invaderCounter = 0;
private wavesLaunched = 0;
// Minimum manhattan distance an invader must travel before landfall.
private minLandingDist = 0;
private readonly invaders: PlayerInfo[] = [];
private readonly aiAttached = new Set<PlayerID>();
private readonly pendingEscorts: PendingEscort[] = [];
constructor(private gameID: GameID) {
this.random = new PseudoRandom(simpleHash(gameID) + 7919);
@@ -92,12 +107,15 @@ export class InvasionExecution implements Execution {
// Bring landed invaders to life: per-player upkeep + normal nation AI.
this.activateLandedInvaders();
// Release any escort warships whose staggered launch tick has arrived.
this.processPendingEscorts(ticks);
// Waves.
if (this.nextWaveTick < 0) {
this.nextWaveTick = ticks; // first wave fires immediately
}
if (ticks >= this.nextWaveTick) {
this.launchWave(elapsed, difficulty);
this.launchWave(ticks, elapsed, difficulty);
this.nextWaveTick = ticks + boatIntervalTicks(elapsed, difficulty);
}
@@ -131,37 +149,94 @@ export class InvasionExecution implements Execution {
}
}
private launchWave(elapsed: number, difficulty: Difficulty): void {
private launchWave(
ticks: number,
elapsed: number,
difficulty: Difficulty,
): void {
// Resolve the route before committing a launcher, so a failed geometry
// lookup doesn't leave a freshly-created invader stranded with no boat.
const src = this.pickEdgeWaterTile();
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);
const launcher = this.selectWaveLauncher(difficulty);
if (launcher === null) return;
// 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);
const troops = boatTroops(elapsed, difficulty, this.wavesLaunched);
// The boat's troops are drawn from the player's pool on creation, so top
// the launcher up by exactly that many troops first.
launcher.addTroops(troops);
this.mg.addExecution(
new TransportShipExecution(invader, target, troops, src),
new TransportShipExecution(launcher, target, troops, src),
);
this.wavesLaunched++;
// Escort warships (weighted 0-3, only from minute 2 onward).
// Escort warships (weighted 0-3, only from minute 2 onward). They are
// released ~0.5s apart, each heading to a slightly different point near the
// landing, so they read as an escort rather than a stacked convoy.
if (!this.mg.config().isUnitDisabled(UnitType.Warship)) {
const escorts = warshipCount(elapsed, this.random, difficulty);
const patrol = this.waterNear(target) ?? src;
const ownerId = launcher.id();
for (let i = 0; i < escorts; i++) {
const warship = invader.buildUnit(UnitType.Warship, src, {
patrolTile: patrol,
this.pendingEscorts.push({
fireTick: ticks + i * WARSHIP_STAGGER_TICKS,
ownerId,
spawnTile: this.randomWaterNear(src, 3),
patrolTile: this.randomWaterNear(target, 6),
});
this.mg.addExecution(new WarshipExecution(warship));
}
}
}
/**
* Chooses who launches the next wave: a brand-new invader nation while below
* the difficulty cap, otherwise an existing active invader that still has room
* for another boat. Returns null if every invader is at its boat limit.
*/
private selectWaveLauncher(difficulty: Difficulty): Player | null {
const active = this.invaders.filter((info) => this.isActiveInvader(info));
if (active.length < maxInvaderNations(difficulty)) {
const info = this.createInvaderInfo();
const invader = this.mg.addPlayer(info, ColoredTeams.Invaders);
this.invaders.push(info);
return invader;
}
const boatMax = this.mg.config().boatMaxNumber();
const candidates = active
.map((info) => this.mg.player(info.id))
.filter((p) => p.unitCount(UnitType.TransportShip) < boatMax);
if (candidates.length === 0) return null;
return this.random.randElement(candidates);
}
/** An invader still in play: landed (alive) or with a boat still inbound. */
private isActiveInvader(info: PlayerInfo): boolean {
if (!this.mg.hasPlayer(info.id)) return false;
const player = this.mg.player(info.id);
return player.isAlive() || player.unitCount(UnitType.TransportShip) > 0;
}
private processPendingEscorts(ticks: number): void {
if (this.pendingEscorts.length === 0) return;
if (this.mg.config().isUnitDisabled(UnitType.Warship)) {
this.pendingEscorts.length = 0;
return;
}
for (let i = this.pendingEscorts.length - 1; i >= 0; i--) {
const escort = this.pendingEscorts[i];
if (ticks < escort.fireTick) continue;
this.pendingEscorts.splice(i, 1);
if (!this.mg.hasPlayer(escort.ownerId)) continue;
const owner = this.mg.player(escort.ownerId);
const warship = owner.buildUnit(UnitType.Warship, escort.spawnTile, {
patrolTile: escort.patrolTile,
});
this.mg.addExecution(new WarshipExecution(warship));
}
}
private launchBomb(elapsed: number, difficulty: Difficulty): void {
const choice = selectInvasionNuke(elapsed, this.random, difficulty);
if (choice === null) return;
@@ -303,29 +378,24 @@ export class InvasionExecution implements Execution {
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;
/**
* A water tile randomly offset within `radius` of `center`, so staggered
* escorts spawn and patrol at spread-out points rather than the same tile.
* Falls back to `center` if no nearby water is found.
*/
private randomWaterNear(center: TileRef, radius: number): TileRef {
const cx = this.mg.x(center);
const cy = this.mg.y(center);
for (let i = 0; i < 20; i++) {
const nx = cx + this.random.nextInt(-radius, radius + 1);
const ny = cy + this.random.nextInt(-radius, radius + 1);
if (!this.mg.isValidCoord(nx, ny)) continue;
const ref = this.mg.ref(nx, ny);
if (this.mg.isWater(ref)) {
return ref;
}
}
return null;
return center;
}
private firstOwnedTile(player: Player): TileRef | null {
+27
View File
@@ -2,6 +2,7 @@ import {
boatIntervalTicks,
boatTroops,
bombIntervalTicks,
maxInvaderNations,
nukeTier,
selectInvasionNuke,
warshipCount,
@@ -61,6 +62,32 @@ describe("InvasionConfig.boatTroops", () => {
350_000,
);
});
test("each successive boat carries slightly more troops", () => {
const first = boatTroops(0, Difficulty.Medium, 0);
const later = boatTroops(0, Difficulty.Medium, 10);
expect(later).toBeGreaterThan(first);
// Still bounded by the cap even for huge wave indices.
expect(boatTroops(0, Difficulty.Medium, 100_000)).toBeLessThanOrEqual(
350_000,
);
});
});
describe("InvasionConfig.maxInvaderNations", () => {
test("caps fewer nations on easier difficulties (5 easy, 20 impossible)", () => {
expect(maxInvaderNations(Difficulty.Easy)).toBe(5);
expect(maxInvaderNations(Difficulty.Impossible)).toBe(20);
expect(maxInvaderNations(Difficulty.Easy)).toBeLessThan(
maxInvaderNations(Difficulty.Medium),
);
expect(maxInvaderNations(Difficulty.Medium)).toBeLessThan(
maxInvaderNations(Difficulty.Hard),
);
expect(maxInvaderNations(Difficulty.Hard)).toBeLessThan(
maxInvaderNations(Difficulty.Impossible),
);
});
});
describe("InvasionConfig.warshipCount", () => {
+32
View File
@@ -1,3 +1,4 @@
import { maxInvaderNations } from "../src/core/execution/invasion/InvasionConfig";
import { InvasionExecution } from "../src/core/execution/invasion/InvasionExecution";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import {
@@ -79,6 +80,37 @@ describe("Invasion Mode waves", () => {
.filter((p) => p.team() === ColoredTeams.Invaders);
expect(invaders.length).toBe(0);
});
test("never exceeds the difficulty cap of concurrent invader nations", async () => {
const difficulty = "Impossible";
const game = await setup("ocean_and_land", {
invasionMode: true,
invasionGracePeriod: 0,
difficulty: difficulty as never,
});
game.addExecution(new InvasionExecution(gameID));
const cap = maxInvaderNations(difficulty as never);
let maxConcurrent = 0;
let sawWarship = false;
for (let i = 0; i < 1600; i++) {
game.executeNextTick();
if (game.units(UnitType.Warship).length > 0) sawWarship = true;
const live = game
.players()
.filter(
(p) =>
p.team() === ColoredTeams.Invaders &&
(p.isAlive() || p.unitCount(UnitType.TransportShip) > 0),
).length;
maxConcurrent = Math.max(maxConcurrent, live);
}
expect(maxConcurrent).toBeGreaterThan(0);
expect(maxConcurrent).toBeLessThanOrEqual(cap);
// Escorts arrive once the invasion passes minute 2.
expect(sawWarship).toBe(true);
});
});
describe("Invasion Mode faction", () => {