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

317 lines
14 KiB
JavaScript

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