401 lines
16 KiB
JavaScript
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 };
|
|
}
|