fertig-classic-games/src/games/mastervega/VegaCombat.js

401 lines
16 KiB
JavaScript

// Master of Vega — tactical space combat.
//
// Headless and deterministic. The battle is a STEPPER: createBattle() sets the
// board up, stepRound() advances exactly one round, and runBattle() just calls
// stepRound() until someone wins. That is deliberate — the playable battle and
// the "auto-resolve" button run literally the same code, so they can never
// disagree, and the verifier asserts it.
//
// Positions are tracked as a column on a gridCols-wide lane. Rows exist only
// for rendering; the sim cares about the distance between two stacks, because
// that is what decides whether beams can reach or only missiles can.
import { designFor } from './VegaShips.js';
// Aggregate rather than per-shot rolls. A late-game battle can involve tens of
// thousands of individual shots; sampling each one would dominate the soak's
// runtime for no extra fidelity. This is a normal approximation to the
// binomial, which is exactly what all those independent rolls converge to.
function sampleHits(rnd, shots, chance) {
if (shots <= 0 || chance <= 0) return 0;
if (chance >= 1) return shots;
const mean = shots * chance;
const sd = Math.sqrt(shots * chance * (1 - chance));
// Two uniforms give a cheap symmetric bell without a Box-Muller transcendental.
const jitter = (rnd() + rnd() - 1) * 1.7320508 * sd;
return Math.max(0, Math.min(shots, Math.round(mean + jitter)));
}
const avgDmg = (w) => (w.min + w.max) / 2;
// One entry per hull type per side. `hpFront` is damage carried on the ship
// currently taking fire, so a stack degrades ship by ship instead of all at once.
function makeStack(rules, design, count, side, idx) {
return {
uid: `${side}-${idx}`,
side,
hullId: design.hullId,
name: design.name,
mark: design.mark,
design,
count,
startCount: count,
hpEach: design.hp,
hpFront: design.hp,
shield: design.shield,
speed: Math.max(1, design.speed || 1),
immobile: design.immobile,
x: 0,
// Missile racks are per-battle, not per-round: this is what makes missiles
// an opening burst rather than a second beam.
salvoesLeft: new Map(design.mounts.filter((m) => m.weapon.kind === 'missile').map((m) => [m.weapon.id, m.weapon.shots])),
retreated: false,
};
}
function sideStacks(rules, empire, fleetShips, side) {
const out = [];
fleetShips.forEach((s, i) => {
if (s.count <= 0) return;
const design = s.design ?? designFor(rules, empire.known, s.hullId, empire.traits ?? {}, s.skills ?? {});
// A ship built at an older Mark keeps that Mark's stats.
const d = s.mark && s.mark !== design.mark ? { ...design, mark: s.mark } : design;
out.push(makeStack(rules, d, s.count, side, i));
});
return out;
}
export function createBattle(rules, opts) {
const {
attacker, defender, colony = null, starIdx = -1, rnd = Math.random,
} = opts;
const C = rules.combat;
const aStacks = sideStacks(rules, attacker.empire, attacker.ships, 'attacker');
const dStacks = sideStacks(rules, defender.empire, defender.ships, 'defender');
for (const s of aStacks) s.x = 0;
for (const s of dStacks) s.x = C.gridCols - 1;
// A defended colony fights as an extra immobile "stack" that cannot be
// boarded — killing it is what clears the way for an invasion.
let planet = null;
if (colony && colony.defenseHp > 0) {
planet = {
uid: 'planet',
side: 'defender',
isPlanet: true,
name: 'Planetary Defences',
count: 1,
hpEach: colony.defenseHp,
hpFront: colony.defenseHp,
shield: colony.shieldBonus ?? 0,
x: C.gridCols - 1,
speed: 0,
immobile: true,
damage: C.planetDefenseBase + colony.defenseHp * 0.05,
salvoesLeft: new Map(),
retreated: false,
};
dStacks.push(planet);
}
return {
rules, C, rnd, starIdx, colony,
attackerIdx: attacker.empireIdx,
defenderIdx: defender.empireIdx,
attackerName: attacker.name ?? 'Attacker',
defenderName: defender.name ?? 'Defender',
attackerTraits: attacker.empire.traits ?? {},
defenderTraits: defender.empire.traits ?? {},
stacks: [...aStacks, ...dStacks],
planet,
pending: new Map(),
round: 0,
done: false,
winner: null,
log: [],
};
}
const living = (b, side) => b.stacks.filter((s) => s.side === side && s.count > 0 && !s.retreated);
function hitChance(C, shooter, target, shooterTraits, targetTraits) {
const targeting = shooter.design?.targeting ?? 0;
const attack = (shooter.design?.attack ?? 0);
const defense = (target.design?.defense ?? 0);
// Stealth Field: a simplified always-on version of MOO1's "evasive unless
// it attacks" cloak — flat accuracy malus against the target. A single hit-
// chance percentage point sounds trivial but a fleet fight is thousands of
// aggregate shots over many rounds (see sampleHits), so even a small,
// *sustained* per-shot edge compounds round over round. Calibrated against
// an isolated same-fleet A/B (tools/verifyMasterOfVega.js section 5): 0.02
// lands around a mild species combat-trait's worth of advantage, well
// short of the >0.8 win rate the suite treats as "decisive".
const cloak = target.design?.cloaked ? C.cloakEvasion : 0;
const chance = C.baseHitChance
+ C.hitPerTargeting * targeting
+ C.hitPerAttack * attack
- C.hitPerDefense * defense
- cloak;
return Math.max(0.05, Math.min(0.95, chance));
}
// Fire everything one stack can bring to bear this round.
function fire(b, shooter, targets, events) {
if (!targets.length) return;
const C = b.C;
// Planetary defences are a flat battery, not a mount list.
if (shooter.isPlanet) {
const target = targets[0];
const dmg = Math.max(0, shooter.damage - target.shield) * (0.75 + b.rnd() * 0.5);
applyDamage(b, target, dmg, events, shooter);
return;
}
for (const m of shooter.design.mounts) {
const w = m.weapon;
const range = w.kind === 'missile' ? C.missileRange : C.beamRange;
// Prefer the closest reachable enemy; missiles reach across the lane,
// beams need the fleets to have closed.
const inRange = targets.filter((t) => Math.abs(t.x - shooter.x) <= range);
if (!inRange.length) continue;
const target = inRange.reduce((best, t) => (Math.abs(t.x - shooter.x) < Math.abs(best.x - shooter.x) ? t : best), inRange[0]);
let shotsPerShip = 1;
if (w.kind === 'missile') {
const left = shooter.salvoesLeft.get(w.id) ?? 0;
if (left <= 0) continue;
shooter.salvoesLeft.set(w.id, left - 1);
shotsPerShip = 1;
}
const totalShots = shooter.count * m.count * shotsPerShip;
const chance = hitChance(C, shooter, target,
shooter.side === 'attacker' ? b.attackerTraits : b.defenderTraits,
target.side === 'attacker' ? b.attackerTraits : b.defenderTraits);
const hits = sampleHits(b.rnd, totalShots, chance);
if (hits <= 0) continue;
// Black Hole Generator: the whole ship's fire punches through deflectors a
// little further, on top of whatever the mounted weapon already pierces.
// Unlike a weapon's own shieldPierce, this rides for free on every mount
// with no hull-space trade-off, so it is kept well under an integer
// shield point (see the cloak comment above for how the calibration was
// done) — otherwise a passive fleet-wide buff would outclass equivalent
// per-weapon pierce tech like Disruptor Cannon or Disintegrator Beam.
const singularityPierce = shooter.design?.singularity ? (C.singularityShieldPierce ?? 0) : 0;
const effShield = Math.max(0, (target.shield ?? 0) - (w.shieldPierce ?? 0) - singularityPierce);
const perHit = Math.max(0, avgDmg(w) - effShield);
if (perHit <= 0) {
events.push({ kind: 'bounce', from: shooter.uid, to: target.uid, weapon: w.name });
continue;
}
applyDamage(b, target, hits * perHit, events, shooter, w);
}
}
// Damage is BANKED, not applied. Everything fires against the board as it
// stood at the start of the round, and the totals land together in
// applyPending(). Without this the sequence of fire decides the battle: a stack
// that shoots first can wipe a target before it ever returns fire, so whichever
// side happens to act first in the round contact is made wins. That bias is
// invisible in a single battle and completely dominates a mirror match.
function applyDamage(b, target, damage, events, shooter, weapon = null) {
b.pending.set(target, (b.pending.get(target) ?? 0) + damage);
events.push({
kind: 'fire',
from: shooter.uid,
to: target.uid,
weapon: weapon?.name ?? 'Planetary Defences',
// Plain data for the render tier to key sound/FX off of (beam vs missile,
// and the firing ship's Mark) — deliberately not a resolved asset key,
// since this file stays headless (no ui/Sounds.js import).
weaponKind: weapon?.kind ?? null,
mark: shooter.mark ?? null,
damage: Math.round(damage),
});
}
function applyPending(b, events) {
for (const [target, damage] of b.pending) {
let left = damage;
let killed = 0;
while (left > 0 && target.count > 0) {
if (left >= target.hpFront) {
left -= target.hpFront;
target.count -= 1;
killed += 1;
target.hpFront = target.hpEach;
} else {
target.hpFront -= left;
left = 0;
}
}
if (target.count <= 0) { target.count = 0; target.hpFront = 0; }
if (killed > 0) events.push({ kind: 'losses', uid: target.uid, killed, left: target.count });
}
b.pending.clear();
}
// orders: { [stackUid]: 'advance' | 'hold' | 'retreat' }. Anything unlisted
// advances, which is what the AI and auto-resolve want.
export function stepRound(b, orders = {}) {
if (b.done) return null;
b.round += 1;
const events = [];
// Retreat resolves before anyone shoots — a stack that withdraws this round
// takes no further fire, which is what makes retreating worth doing.
if (b.round > b.C.retreatAfterRound) {
for (const s of b.stacks) {
if (s.count > 0 && !s.immobile && orders[s.uid] === 'retreat') {
s.retreated = true;
events.push({ kind: 'retreat', uid: s.uid });
}
}
}
// Initiative order still decides who *moves* first (a faster fleet dictates
// the range the battle is fought at), but because damage is banked and
// applied together, it no longer decides who survives to shoot back.
const order = b.stacks
.filter((s) => s.count > 0 && !s.retreated)
.sort((x, y) => (y.design?.initiative ?? 0) - (x.design?.initiative ?? 0) || x.uid.localeCompare(y.uid));
for (const s of order) {
if (s.immobile || orders[s.uid] === 'hold') continue;
const enemies = living(b, s.side === 'attacker' ? 'defender' : 'attacker');
if (!enemies.length) continue;
const nearest = enemies.reduce((best, t) => (Math.abs(t.x - s.x) < Math.abs(best.x - s.x) ? t : best), enemies[0]);
const dir = Math.sign(nearest.x - s.x);
// Close to beam range but never move onto the enemy's own square.
const want = Math.abs(nearest.x - s.x) - b.C.beamRange;
s.x += dir * Math.max(0, Math.min(s.speed, want));
}
for (const s of order) {
const enemies = living(b, s.side === 'attacker' ? 'defender' : 'attacker');
if (!enemies.length) continue;
fire(b, s, enemies, events);
}
applyPending(b, events);
// Between-round repairs (Automated Repair Unit and damage-control crews).
for (const s of b.stacks) {
if (s.count > 0 && s.design?.repairPerRound > 0 && s.hpFront < s.hpEach) {
s.hpFront = Math.min(s.hpEach, s.hpFront + s.hpEach * s.design.repairPerRound);
}
}
const aLeft = living(b, 'attacker');
const dLeft = living(b, 'defender');
const aArmed = aLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet);
const dArmed = dLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet);
if (!aLeft.length) { b.done = true; b.winner = 'defender'; }
else if (!dLeft.length) { b.done = true; b.winner = 'attacker'; }
else if (!aArmed && !dArmed) { b.done = true; b.winner = 'draw'; }
else if (b.round >= b.C.maxRounds) { b.done = true; b.winner = 'defender'; }
b.log.push({ round: b.round, events });
return { round: b.round, events, done: b.done, winner: b.winner };
}
// How badly a side is losing, used by the AI (and by the player's own nerve)
// to decide whether to pull out.
export function sideStrength(b, side) {
return living(b, side).reduce((t, s) => t + s.count * (s.hpEach + (s.design?.damage ?? 0) * 4), 0);
}
// Default orders for a side not under player control.
//
// The disengage rule is what stops battles ending on the round cap. Two evenly
// matched fleets grind each other down ever more slowly — damage falls as ships
// die, so the tail of a symmetric battle is enormously long, and a fixed cap
// turns that tail into an arbitrary "defender wins". Real fleets break off. So
// once a battle has clearly gone on too long, the weaker side withdraws and the
// engagement resolves decisively.
function autoOrders(b) {
const orders = {};
const aStr = sideStrength(b, 'attacker');
const dStr = sideStrength(b, 'defender');
if (b.round <= b.C.retreatAfterRound) return orders;
const withdraw = (side) => { for (const s of living(b, side)) orders[s.uid] = 'retreat'; };
// An attacker who is being beaten badly leaves early rather than feeding the
// whole fleet into a losing action.
if (aStr > 0 && dStr > aStr * 2.5) { withdraw('attacker'); return orders; }
if (b.round >= (b.C.disengageRound ?? 25) && aStr > 0 && dStr > 0) {
// Mirror matches reach this point EXACTLY tied astonishingly often — both
// fleets are the same ships losing hulls in step. Resolving a tie in a
// fixed direction hands one side every drawn battle in the game, so the
// coin has to actually be flipped.
const weaker = aStr === dStr
? (b.rnd() < 0.5 ? 'attacker' : 'defender')
: (aStr < dStr ? 'attacker' : 'defender');
// Immobile planetary defences cannot withdraw, so a colony keeps fighting
// even after its fleet screen breaks off — which is exactly right.
withdraw(weaker);
}
return orders;
}
// Auto-resolve: the same stepper, driven to completion.
export function runBattle(b, { allowRetreat = true } = {}) {
let guard = 0;
while (!b.done && guard < b.C.maxRounds + 2) {
guard += 1;
stepRound(b, allowRetreat ? autoOrders(b, 'defender') : {});
}
if (!b.done) { b.done = true; b.winner = 'defender'; }
return battleResult(b);
}
export function battleResult(b) {
const survivors = (side) => b.stacks
.filter((s) => s.side === side && s.count > 0 && !s.isPlanet)
.map((s) => ({ hullId: s.hullId, mark: s.mark, count: s.count }));
const losses = (side) => b.stacks
.filter((s) => s.side === side && !s.isPlanet)
.map((s) => ({ hullId: s.hullId, mark: s.mark, lost: s.startCount - s.count }))
.filter((s) => s.lost > 0);
return {
winner: b.winner,
rounds: b.round,
starIdx: b.starIdx,
attackerIdx: b.attackerIdx,
defenderIdx: b.defenderIdx,
attackerSurvivors: survivors('attacker'),
defenderSurvivors: survivors('defender'),
attackerLosses: losses('attacker'),
defenderLosses: losses('defender'),
planetDefenseLeft: b.planet ? Math.max(0, b.planet.hpFront) : 0,
planetDestroyed: b.planet ? b.planet.count <= 0 : false,
log: b.log,
};
}
// --------------------------------------------------------------------------
// Ground combat — resolved in one shot after orbit is cleared.
export function resolveInvasion(rules, rnd, attackTroops, attackBonus, colony, defenseBonus, defenderPop) {
const C = rules.combat;
let att = attackTroops;
// Population itself defends: every colony is a militia of last resort.
let def = Math.max(1, Math.round(defenderPop / 8)) + Math.round((colony.groundDefense ?? 0) / 10);
const attOdds = 0.5 + (attackBonus - defenseBonus) * C.groundOddsScale;
const p = Math.max(0.1, Math.min(0.9, attOdds));
let guard = 0;
while (att > 0 && def > 0 && guard < 500) {
guard += 1;
if (rnd() < p) def -= 1;
else att -= 1;
}
return { captured: att > 0, attackersLeft: att, defendersLeft: def };
}