// Master of Vega — preset ship classes and the Mark auto-refit. // // There is no ship designer. Instead every hull has a MARK that folds in the // best weapons, armour, shields, engines and battle computers the owner has // researched, so the tech tree still visibly changes the fleet. A Cruiser Mark // II and a Cruiser Mark VI are the same hull with six generations between them. // // Headless: no Phaser, no game state. Everything here is a pure function of // (rules, known tech set, species traits, leader skills). import { markNumeral } from './VegaRules.js'; // The five fields that put hardware on a ship. Planetology is deliberately // absent — it changes where you can live, not what you fly. export const COMPONENT_FIELDS = ['weapons', 'construction', 'forcefields', 'propulsion', 'computers']; export const MAX_MARK = 7; // Mark is the AVERAGE tier across all five component fields (see markFor // below), with an unresearched field contributing 0 rather than dragging the // average negative — so a real Mark II/III hull with zero weapons research // is a perfectly reachable state if a player (or the AI) simply prioritises // the other four fields. Without this fallback that ship deals literally 0 // damage forever, silently: Bombard refuses (bestComponents.allWeapons is // empty), and space combat is a no-op. Strictly worse than the tier-0 Laser // Cannon on every axis, so actually researching lasercannon (or anything // else) is still a real upgrade, not a formality made moot by this floor. const BASELINE_WEAPON = { id: 'baselinemassdriver', name: 'Mass Driver', kind: 'beam', min: 1, max: 2, shots: 1, space: 3, cost: 10, }; // Highest tier the empire has reached in each field. The chains are linear, so // "best tier" is all we ever need to know. export function fieldTiers(rules, known) { const tiers = {}; for (const f of Object.keys(rules.techFields)) { let best = -1; for (const t of rules.techsByField[f]) { if (known[t.id] && t.tier > best) best = t.tier; } tiers[f] = best; } return tiers; } // Mark I..VII from the average component tier. Tiers run 0..9 across ten techs // per field, so a fully-teched empire lands exactly on Mark VII. export function markFor(rules, known) { const tiers = fieldTiers(rules, known); let sum = 0; for (const f of COMPONENT_FIELDS) sum += Math.max(0, tiers[f]); const avg = sum / COMPONENT_FIELDS.length; return Math.max(1, Math.min(MAX_MARK, 1 + Math.floor(avg * 0.7))); } // Walk every known tech and keep the best of each component kind. Later techs // in a chain always supersede earlier ones, so a plain tier comparison is // enough — no need to model obsolescence explicitly. export function bestComponents(rules, known) { const out = { weapon: null, secondWeapon: null, armor: { name: 'Titanium', hpMult: 1 }, shield: 0, engine: { name: 'Chemical', speed: 0 }, fuelRange: rules.economy.baseFuelRange ?? 4, targeting: 0, initiative: 0, repairPerRound: 0, refitCostMult: 1, espionage: 0, counterEspionage: 0, planetaryShield: 0, colonizeHostility: 0, maxPopBonus: 0, wasteMult: 1, factoryCostMult: 1, researchMult: 1, scanRange: 0, cloaked: false, singularity: false, redirectInFlight: false, planetCracker: false, }; const weapons = []; for (const t of rules.techList) { if (!known[t.id]) continue; const e = t.effects ?? {}; if (e.weapon) weapons.push(e.weapon); if (e.armor && e.armor.hpMult > out.armor.hpMult) out.armor = e.armor; if (typeof e.shield === 'number' && e.shield > out.shield) out.shield = e.shield; if (e.engine && e.engine.speed > out.engine.speed) out.engine = e.engine; if (typeof e.fuelRange === 'number' && e.fuelRange > out.fuelRange) out.fuelRange = e.fuelRange; if (typeof e.targeting === 'number' && e.targeting > out.targeting) out.targeting = e.targeting; if (typeof e.initiative === 'number') out.initiative += e.initiative; if (typeof e.repairPerRound === 'number' && e.repairPerRound > out.repairPerRound) out.repairPerRound = e.repairPerRound; if (typeof e.refitCostMult === 'number' && e.refitCostMult < out.refitCostMult) out.refitCostMult = e.refitCostMult; if (typeof e.espionage === 'number') out.espionage += e.espionage; if (typeof e.counterEspionage === 'number') out.counterEspionage += e.counterEspionage; if (typeof e.planetaryShield === 'number' && e.planetaryShield > out.planetaryShield) out.planetaryShield = e.planetaryShield; if (typeof e.colonizeHostility === 'number' && e.colonizeHostility > out.colonizeHostility) out.colonizeHostility = e.colonizeHostility; if (typeof e.maxPopBonus === 'number') out.maxPopBonus += e.maxPopBonus; if (typeof e.wasteMult === 'number' && e.wasteMult < out.wasteMult) out.wasteMult = e.wasteMult; if (typeof e.factoryCostMult === 'number' && e.factoryCostMult < out.factoryCostMult) out.factoryCostMult = e.factoryCostMult; if (typeof e.researchMult === 'number' && e.researchMult > out.researchMult) out.researchMult = e.researchMult; if (typeof e.scanRange === 'number') out.scanRange += e.scanRange; if (e.cloaked) out.cloaked = true; if (e.singularity) out.singularity = true; if (e.redirectInFlight) out.redirectInFlight = true; if (e.planetCracker) out.planetCracker = true; } // Beams fire every round forever; missiles fire `shots` salvoes for the whole // battle and then the racks are empty. They are ranked separately because // they are not competing for the same job — see fillMounts. const avg = (w) => (w.min + w.max) / 2; out.beams = weapons.filter((w) => w.kind === 'beam').sort((a, b) => avg(b) / b.space - avg(a) / a.space); out.missiles = weapons.filter((w) => w.kind === 'missile').sort((a, b) => (avg(b) * b.shots) / b.space - (avg(a) * a.shots) / a.space); out.weapon = out.beams[0] ?? out.missiles[0] ?? null; out.allWeapons = weapons; if (!out.allWeapons.length) { out.allWeapons = [BASELINE_WEAPON]; out.beams = [BASELINE_WEAPON]; out.weapon = BASELINE_WEAPON; } return out; } // How much damage a weapon contributes across one battle. Beams keep firing // every round once the fleets close; missiles empty their racks and stop. That // difference is the whole reason both weapon lines exist, and it is the only // place the two are made commensurable. function battleValue(weapon, beamRounds) { const avg = (weapon.min + weapon.max) / 2; return weapon.kind === 'missile' ? avg * weapon.shots : avg * beamRounds; } // Unbounded knapsack: the most battle damage obtainable from `space` tonnage // using `weapons`. Returns [spaceUsed, Map(weaponIndex -> count)]. // // Because researching a weapon only ever ADDS to the candidate set, the optimum // over the larger set is never worse than over the smaller one — so damage is // provably monotonic in tech within a fixed budget. A greedy "best // damage-per-space first" fill has no such property: it mounts one oversized // gun and strands the leftover tonnage. function knapsack(space, weapons, beamRounds) { if (space <= 0 || !weapons.length) return [0, new Map()]; const best = new Float64Array(space + 1); const pick = new Int32Array(space + 1).fill(-1); for (let s = 1; s <= space; s += 1) { best[s] = best[s - 1]; pick[s] = -1; for (let w = 0; w < weapons.length; w += 1) { if (weapons[w].space > s) continue; const cand = best[s - weapons[w].space] + battleValue(weapons[w], beamRounds); if (cand > best[s] + 1e-9) { best[s] = cand; pick[s] = w; } } } const counts = new Map(); let s = space; let used = 0; let guard = 0; while (s > 0 && guard < space + 2) { guard += 1; const w = pick[s]; if (w < 0) { s -= 1; continue; } counts.set(weapons[w], (counts.get(weapons[w]) ?? 0) + 1); used += weapons[w].space; s -= weapons[w].space; } return [used, counts]; } // Fraction of a warship's tonnage reserved for missiles. This is a design rule, // not an optimisation, and it exists to prevent a specific failure: at some tech // tiers missiles genuinely score better damage-per-space than beams, so a pure // knapsack builds an ALL-missile ship. That ship empties its racks in five // rounds and then sits there unarmed until the round cap — every such battle // stalemates. Capping missiles guarantees every warship can still fight on // round forty. const MISSILE_SHARE = 0.4; const MIN_SPLIT_SPACE = 10; function fillMounts(space, comps, beamRounds) { if (space <= 0 || !comps.allWeapons.length) return []; const beams = comps.beams; const missiles = comps.missiles; const counts = new Map(); const merge = (m) => { for (const [w, c] of m) counts.set(w, (counts.get(w) ?? 0) + c); }; // Small hulls have no room to split — a frigate carries one gun and that gun // had better still work late in the fight. const split = space >= MIN_SPLIT_SPACE && beams.length > 0 && missiles.length > 0; if (!split) { const [, m] = knapsack(space, beams.length ? beams : comps.allWeapons, beamRounds); merge(m); } else { const missileBudget = Math.floor(space * MISSILE_SHARE); const [mUsed, mCounts] = knapsack(missileBudget, missiles, beamRounds); merge(mCounts); const [, bCounts] = knapsack(space - mUsed, beams, beamRounds); merge(bCounts); } // Stable order (largest weapon first) so a design always renders and // serialises identically. return [...counts.entries()] .map(([weapon, count]) => ({ weapon, count })) .sort((a, b) => b.weapon.space - a.weapon.space || a.weapon.id.localeCompare(b.weapon.id)); } const sumSkills = (skills, key) => (skills?.[key] ?? 0); // The full derived stat block for one hull at the owner's current tech. // `traits` is the species trait bag; `skills` is an optional merged leader // skill bag (fleet captains for warships, nothing for civilian hulls). export function designFor(rules, known, hullId, traits = {}, skills = {}) { const hull = rules.hulls[hullId]; if (!hull) throw new Error(`unknown hull ${hullId}`); const comps = bestComponents(rules, known); const mark = markFor(rules, known); const beamRounds = rules.combat.beamRounds ?? 5; const mounts = fillMounts(hull.space, comps, beamRounds); const hp = Math.round(hull.baseHp * comps.armor.hpMult); const weaponCost = mounts.reduce((t, m) => t + m.count * m.weapon.cost, 0); const cost = Math.round( hull.baseCost * (1 + 0.15 * (mark - 1)) + weaponCost + comps.shield * 4 + comps.engine.speed * 3, ); const immobile = !!hull.immobile; const speed = immobile ? 0 : comps.engine.speed + (hull.speedBonus ?? 0) + sumSkills(skills, 'speedBonus'); const range = immobile ? 0 : comps.fuelRange + (hull.rangeBonus ?? 0) + sumSkills(skills, 'rangeBonus'); const attack = (traits.shipAttack ?? 0) + sumSkills(skills, 'shipAttack'); const defense = (traits.shipDefense ?? 0) + sumSkills(skills, 'shipDefense'); // Beams are sustained damage per round; missiles are a fixed rack of salvoes. // `damage` is the whole-battle total the knapsack optimised, expressed per // round so it reads as a rate next to beamDamage. const avg = (w) => (w.min + w.max) / 2; const beamDamage = mounts .filter((m) => m.weapon.kind === 'beam') .reduce((t, m) => t + m.count * avg(m.weapon), 0); const missileSalvo = mounts .filter((m) => m.weapon.kind === 'missile') .reduce((t, m) => t + m.count * avg(m.weapon), 0); const missileSalvos = mounts .filter((m) => m.weapon.kind === 'missile') .reduce((t, m) => Math.max(t, m.weapon.shots), 0); const damage = mounts.reduce((t, m) => t + m.count * battleValue(m.weapon, beamRounds), 0) / beamRounds; return { hullId, hull, mark, name: hull.space > 0 || hull.role === 'base' ? `${hull.name} Mark ${markNumeral(mark)}` : hull.name, role: hull.role, cost: Math.max(1, cost), hp, shield: comps.shield, speed, range, attack, defense, targeting: comps.targeting, initiative: comps.initiative + speed + sumSkills(skills, 'initiative'), repairPerRound: Math.max(comps.repairPerRound, sumSkills(skills, 'repairPerRound')), mounts, damage, beamDamage, missileSalvo, missileSalvos, armorName: comps.armor.name, engineName: comps.engine.name, immobile, troops: hull.troops ?? 0, planetCracker: comps.planetCracker && mounts.some((m) => m.weapon.planetCracker), cloaked: comps.cloaked, singularity: comps.singularity, }; } // Every hull the empire can currently build, in the order the build list shows // them. A hull with no weapon tech yet still builds — it is simply unarmed. export function availableDesigns(rules, known, traits = {}, skills = {}) { return rules.hullList.map((h) => designFor(rules, known, h.id, traits, skills)); } // A single scalar for "how dangerous is this stack" — used by the AI to decide // whether to attack, and by the star map to size a fleet marker. Deliberately // crude: hp times damage, so neither a paper battleship nor a toothless brick // scores well. export function stackPower(design, count) { if (design.role !== 'warship' && design.role !== 'base') return 0; const off = design.damage * (1 + 0.06 * design.targeting) * (1 + 0.004 * design.attack); const def = (design.hp + design.shield * 8) * (1 + 0.004 * design.defense); return Math.round(count * Math.sqrt(Math.max(1, off) * Math.max(1, def))); } // Cost to bring an existing stack up to the current Mark. Charged from the // empire reserve when a fleet sits over a friendly colony; if it cannot be // paid the ships simply stay at their old Mark. export function refitCost(rules, known, hullId, fromMark, traits = {}) { const now = designFor(rules, known, hullId, traits); if (fromMark >= now.mark) return 0; const comps = bestComponents(rules, known); const steps = now.mark - fromMark; return Math.max(1, Math.round(now.cost * 0.18 * steps * comps.refitCostMult)); }