313 lines
16 KiB
JavaScript
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); }
|