mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:00:44 +00:00
troop bonuses and nation caps
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user