// 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 }; }