317 lines
14 KiB
JavaScript
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));
|
|
}
|