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

313 lines
16 KiB
JavaScript

// Master of Vega — rules compiler. Pure data module, no Phaser imports, so it
// runs headless in Node (tools/verifyMasterOfVega.js) and in the browser scene.
//
// compileRules(json) validates data/mastervega-rules.json and returns an
// indexed, derived rule set that the engine, the AI and the UI all consume.
export function compileRules(json) {
const errors = [];
const need = (cond, msg) => { if (!cond) errors.push(msg); };
const required = ['techFields', 'techs', 'hulls', 'buildings', 'planetTypes', 'planetSizes',
'mineralRichness', 'gravity', 'starClasses', 'species', 'leaders', 'galaxySizes',
'galaxyShapes', 'difficulties'];
for (const key of required) {
need(Array.isArray(json[key]) && json[key].length > 0, `${key} missing or empty`);
}
need(json.economy && typeof json.economy === 'object', 'economy block missing');
need(json.combat && typeof json.combat === 'object', 'combat block missing');
need(json.combatV2 && typeof json.combatV2 === 'object', 'combatV2 block missing');
need(json.council && typeof json.council === 'object', 'council block missing');
need(json.diplomacy && typeof json.diplomacy === 'object', 'diplomacy block missing');
need(json.leaderHiring && typeof json.leaderHiring === 'object', 'leaderHiring block missing');
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
const byId = (list, label) => {
const map = {};
for (const item of list) {
need(typeof item.id === 'string' && item.id.length > 0, `${label} entry missing id`);
need(!map[item.id], `${label} duplicate id ${item.id}`);
map[item.id] = item;
}
return map;
};
const techFields = byId(json.techFields, 'techField');
const techs = byId(json.techs, 'tech');
const hulls = byId(json.hulls, 'hull');
const buildings = byId(json.buildings, 'building');
const planetTypes = byId(json.planetTypes, 'planetType');
const planetSizes = byId(json.planetSizes, 'planetSize');
const richness = byId(json.mineralRichness, 'mineralRichness');
const gravity = byId(json.gravity, 'gravity');
const starClasses = byId(json.starClasses, 'starClass');
const species = byId(json.species, 'species');
const leaders = byId(json.leaders, 'leader');
const galaxySizes = byId(json.galaxySizes, 'galaxySize');
const galaxyShapes = byId(json.galaxyShapes, 'galaxyShape');
const difficulties = byId(json.difficulties, 'difficulty');
// --- tech graph: each tech belongs to a field, has 0-1 prereqs, and that
// prereq must be in the SAME field. Six independent chains keeps research
// pricing per field honest and makes the tree trivially renderable.
for (const t of json.techs) {
need(!!techFields[t.field], `tech ${t.id} unknown field ${t.field}`);
need(Array.isArray(t.prereqs) && t.prereqs.length <= 1, `tech ${t.id} needs 0 or 1 prereqs`);
for (const p of t.prereqs) {
need(!!techs[p], `tech ${t.id} prereq ${p} unknown`);
if (techs[p]) need(techs[p].field === t.field, `tech ${t.id} prereq ${p} is in a different field`);
}
need(typeof t.cost === 'number' && t.cost > 0, `tech ${t.id} needs a positive cost`);
need(Number.isInteger(t.tier) && t.tier >= 0, `tech ${t.id} bad tier`);
need(typeof t.name === 'string' && t.name.length > 0, `tech ${t.id} missing name`);
need(typeof t.desc === 'string' && t.desc.length > 0, `tech ${t.id} missing desc`);
need(Number.isInteger(t.iconFrame) && t.iconFrame >= 0, `tech ${t.id} bad iconFrame`);
need(t.gnnHeadline === undefined || typeof t.gnnHeadline === 'boolean', `tech ${t.id} gnnHeadline must be boolean`);
}
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
// Topological rank; also proves acyclicity and full reachability. An
// unrankable tech means a cycle or a dangling prereq.
const rank = {};
let assigned = 0;
let progress = true;
while (progress) {
progress = false;
for (const t of json.techs) {
if (rank[t.id] !== undefined) continue;
if (t.prereqs.every((p) => rank[p] !== undefined)) {
rank[t.id] = t.prereqs.length ? 1 + Math.max(...t.prereqs.map((p) => rank[p])) : 0;
assigned += 1;
progress = true;
}
}
}
need(assigned === json.techs.length,
`tech graph has a cycle or unreachable techs (${json.techs.length - assigned} unranked)`);
// tier is authored by hand and read by the UI as a column index; if it ever
// disagrees with the computed rank the tree renders in the wrong order.
for (const t of json.techs) {
if (rank[t.id] !== undefined) need(rank[t.id] === t.tier, `tech ${t.id} tier ${t.tier} != rank ${rank[t.id]}`);
}
// Cost must climb along each chain, or a later tech would be cheaper than the
// prereq that unlocks it.
for (const t of json.techs) {
for (const p of t.prereqs) {
if (techs[p]) need(t.cost > techs[p].cost, `tech ${t.id} costs no more than its prereq ${p}`);
}
}
// A rung (same field + tier) can hold more than one tech — MOO1-style
// alternatives, where researching either one clears the rung. That only
// has one well-defined "is this rung cleared" answer if every tech sharing
// a rung unlocks under the same condition.
const rungPrereq = {};
for (const t of json.techs) {
const key = `${t.field}:${t.tier}`;
const prereq = t.prereqs[0] ?? null;
if (!(key in rungPrereq)) rungPrereq[key] = { first: t.id, prereq };
else need(rungPrereq[key].prereq === prereq,
`tech ${t.id} shares tier ${t.tier} of ${t.field} with ${rungPrereq[key].first} but has a different prereq`);
}
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
// --- cross references
for (const b of json.buildings) {
if (b.prereq) need(!!techs[b.prereq], `building ${b.id} prereq tech ${b.prereq} unknown`);
need(typeof b.cost === 'number' && b.cost > 0, `building ${b.id} needs a positive cost`);
need(typeof b.upkeep === 'number' && b.upkeep >= 0, `building ${b.id} bad upkeep`);
if (b.channel !== null && b.channel !== undefined) {
need(json.economy.channels.includes(b.channel), `building ${b.id} unknown channel ${b.channel}`);
need(typeof b.mult === 'number' && b.mult > 0, `building ${b.id} needs a positive mult`);
}
}
for (const h of json.hulls) {
need(['recon', 'colony', 'troops', 'warship', 'base'].includes(h.role), `hull ${h.id} bad role`);
need(typeof h.baseCost === 'number' && h.baseCost > 0, `hull ${h.id} bad baseCost`);
need(typeof h.baseHp === 'number' && h.baseHp > 0, `hull ${h.id} bad baseHp`);
need(Number.isInteger(h.space) && h.space >= 0, `hull ${h.id} bad space`);
if (h.role === 'warship' || h.role === 'base') need(h.space > 0, `hull ${h.id} is armed but has no space`);
else need(h.space === 0, `hull ${h.id} is unarmed but has weapon space`);
// Only consumed by the VegaCombatV2 per-ship prototype, but validated here
// like every other hull stat so a missing/typo'd field fails loudly at
// load time instead of silently producing NaN positions/rotations.
need(typeof h.sizeScale === 'number' && h.sizeScale > 0, `hull ${h.id} bad sizeScale`);
need(typeof h.sizeSpeedMult === 'number' && h.sizeSpeedMult >= 0, `hull ${h.id} bad sizeSpeedMult`);
need(typeof h.turnRateBase === 'number' && h.turnRateBase >= 0, `hull ${h.id} bad turnRateBase`);
need(typeof h.brakeSeconds === 'number' && h.brakeSeconds > 0, `hull ${h.id} bad brakeSeconds`);
}
for (const p of json.planetTypes) {
need(typeof p.habitability === 'number' && p.habitability >= 0, `planetType ${p.id} bad habitability`);
need(Number.isInteger(p.hostility) && p.hostility >= 0, `planetType ${p.id} bad hostility`);
if (p.colonizable) need(p.habitability > 0, `planetType ${p.id} is colonizable but uninhabitable`);
else need(p.habitability === 0, `planetType ${p.id} is not colonizable but has habitability`);
}
for (const s of json.species) {
need(!!planetTypes[s.homeworld], `species ${s.id} homeworld ${s.homeworld} unknown`);
if (planetTypes[s.homeworld]) {
need(planetTypes[s.homeworld].colonizable, `species ${s.id} homeworld ${s.homeworld} is not colonizable`);
// A species whose homeworld is hostile must be able to actually live
// there on turn one, or its start is unplayable.
if (planetTypes[s.homeworld].hostility > 0) {
need(s.traits.colonizeAnything === true || s.traits.hostileImmune === true,
`species ${s.id} starts on hostile ${s.homeworld} without hostileImmune/colonizeAnything`);
}
}
for (const f of Object.keys(techFields)) {
need(typeof s.techAffinity?.[f] === 'number' && s.techAffinity[f] > 0,
`species ${s.id} missing techAffinity for ${f}`);
}
for (const k of ['industryMult', 'researchMult', 'tradeMult', 'growthMult', 'maxPopMult']) {
need(typeof s.traits?.[k] === 'number' && s.traits[k] > 0, `species ${s.id} bad trait ${k}`);
}
need(typeof s.traits?.ecologyMult === 'number' && s.traits.ecologyMult >= 0, `species ${s.id} bad ecologyMult`);
need(Number.isInteger(s.traits?.factoriesPerPop) && s.traits.factoriesPerPop > 0,
`species ${s.id} bad factoriesPerPop`);
need(typeof s.color === 'string' && /^#[0-9a-f]{6}$/i.test(s.color), `species ${s.id} bad color`);
need(Array.isArray(s.strengths) && s.strengths.length > 0, `species ${s.id} needs strengths`);
need(Array.isArray(s.weaknesses) && s.weaknesses.length > 0, `species ${s.id} needs weaknesses`);
need(Array.isArray(s.colonyNames) && s.colonyNames.length >= 50, `species ${s.id} needs 50+ colonyNames`);
// Guarded rather than chained onto the check above: `need()` only records a
// message and keeps going (see its definition), so a missing/malformed
// colonyNames must not make THIS check itself throw and abort the whole
// validation pass before every other species/table gets checked.
const names = Array.isArray(s.colonyNames) ? s.colonyNames : [];
need(new Set(names).size === names.length, `species ${s.id} has duplicate colonyNames`);
}
for (const l of json.leaders) {
need(['admin', 'captain'].includes(l.kind), `leader ${l.id} bad kind`);
need(typeof l.hireCost === 'number' && l.hireCost > 0, `leader ${l.id} bad hireCost`);
need(l.skills && Object.keys(l.skills).length > 0, `leader ${l.id} has no skills`);
}
for (const g of json.galaxySizes) {
need(Number.isInteger(g.stars) && g.stars > 0, `galaxySize ${g.id} bad stars`);
need(Number.isInteger(g.maxEmpires) && g.maxEmpires >= 2, `galaxySize ${g.id} bad maxEmpires`);
// Every empire needs a homeworld plus room to expand into.
need(g.stars >= g.maxEmpires * 3, `galaxySize ${g.id} has too few stars for ${g.maxEmpires} empires`);
}
for (const d of json.difficulties) {
for (const k of ['aiProdMult', 'aiResearchMult', 'aiAggression', 'humanResearchMult']) {
need(typeof d[k] === 'number' && d[k] > 0, `difficulty ${d.id} bad ${k}`);
}
need(Number.isInteger(d.aiStartBonus) && d.aiStartBonus >= 0, `difficulty ${d.id} bad aiStartBonus`);
}
need(json.species.length >= 2, 'need at least two species');
need(json.starNames.length >= Math.max(...json.galaxySizes.map((g) => g.stars)),
'starNames must cover the largest galaxy');
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
// --- derived indexes
// Techs grouped by field, ordered by tier — the research screen renders these
// columns directly, and the AI walks them to find "the next thing". Sort is
// stability-guaranteed (ES2019+), so techs sharing a tier keep their
// json.techs array order — nextResearchTarget's auto-pick among rung
// alternatives relies on that, which is why a new branch tech must be
// inserted AFTER its veteran sibling in the source data, not before.
const techsByField = {};
for (const f of Object.keys(techFields)) techsByField[f] = [];
for (const t of json.techs) techsByField[t.field].push(t);
for (const f of Object.keys(techsByField)) techsByField[f].sort((a, b) => a.tier - b.tier);
// Rungs: techs sharing a (field, tier). Most rungs are size 1 (the linear
// chain every field started as); branching tiers have 2+. canResearch and
// rollTechAvailability key off this instead of comparing tiers directly.
const techRungsByField = {};
for (const f of Object.keys(techFields)) {
const byTier = {};
for (const t of techsByField[f]) (byTier[t.tier] ??= []).push(t);
techRungsByField[f] = Object.keys(byTier)
.map(Number)
.sort((a, b) => a - b)
.map((tier) => ({ tier, techs: byTier[tier] }));
}
// What each tech unlocks — powers UI hovers and the "every tech matters"
// verify check.
const gates = {};
for (const id of Object.keys(techs)) gates[id] = { buildings: [], prereqOf: [], effects: [] };
for (const b of json.buildings) if (b.prereq) gates[b.prereq].buildings.push(b.id);
for (const t of json.techs) for (const p of t.prereqs) gates[p].prereqOf.push(t.id);
for (const t of json.techs) gates[t.id].effects = Object.keys(t.effects ?? {});
// Colonizable planet types sorted by how hostile they are, so worldgen and
// the AI can both ask "what is the best thing I could settle here".
const colonizableTypes = json.planetTypes.filter((p) => p.colonizable);
const weightedPick = (list) => {
const total = list.reduce((s, x) => s + (x.weight ?? 1), 0);
return { list, total };
};
return {
version: json.version ?? 1,
raw: json,
techFields, techs, hulls, buildings, planetTypes, planetSizes, richness, gravity,
starClasses, species, leaders, galaxySizes, galaxyShapes, difficulties,
techFieldList: json.techFields,
techList: json.techs,
hullList: json.hulls,
buildingList: json.buildings,
planetTypeList: json.planetTypes,
planetSizeList: json.planetSizes,
richnessList: json.mineralRichness,
gravityList: json.gravity,
starClassList: json.starClasses,
speciesList: json.species,
leaderList: json.leaders,
galaxySizeList: json.galaxySizes,
galaxyShapeList: json.galaxyShapes,
difficultyList: json.difficulties,
techRank: rank,
techsByField,
techRungsByField,
techGates: gates,
colonizableTypes,
sizeWeights: weightedPick(json.planetSizes),
richWeights: weightedPick(json.mineralRichness),
gravityWeights: weightedPick(json.gravity),
starWeights: weightedPick(json.starClasses),
economy: json.economy,
combat: json.combat,
combatV2: json.combatV2,
council: json.council,
diplomacy: json.diplomacy,
leaderHiring: json.leaderHiring,
victory: json.victory ?? { conquest: true, council: true, turnCap: 800 },
starNames: json.starNames,
};
}
// Research cost for a tech, scaled by how many techs the empire already knows
// in that field. MOO-style: each field gets more expensive as you climb it, so
// spreading research wide is cheaper than driving one field to the end.
// `researchFactor` is where a species' racial skill belongs — see
// techCostFactor() below.
export function techCost(rules, tech, knownInField, researchFactor = 1) {
const drag = 1 + 0.05 * Math.max(0, knownInField - tech.tier);
return Math.round(tech.cost * drag * researchFactor);
}
// MOO1's racial-skill rule, corrected: Poor/Average/Good/Excellent changes
// what a tech COSTS, never whether it shows up (that is rollTechAvailability,
// currently still keyed off this same techAffinity number as a stopgap — see
// its own comment — pending the flat-roll rework tracked for the branching
// tech tree). Bucketed from the species' existing techAffinity rating so no
// new per-species data was needed to land the cost side of this fix.
const COST_TIERS = [
[0.85, 1.25], // Poor
[1.05, 1], // Average
[1.25, 0.8], // Good
[Infinity, 0.6], // Excellent
];
export function techCostFactor(spec, field) {
const affinity = spec.techAffinity[field] ?? 1;
return COST_TIERS.find(([ceiling]) => affinity < ceiling)[1];
}
// Turn -> in-fiction year. MOO starts in 2300 and runs a year per turn.
export function turnToYear(turn) { return 2300 + turn; }
// Mark I..VII in Roman, for ship class names.
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
export function markNumeral(mark) { return ROMAN[mark] ?? String(mark); }