154 lines
6.5 KiB
JavaScript
154 lines
6.5 KiB
JavaScript
// Civilization — rules compiler. Pure data module, no Phaser imports, so it can
|
|
// run headless in Node (tools/verifyCivilization.js) and in the browser scene.
|
|
//
|
|
// compileRules(json) validates data/civilization-rules.json and returns an
|
|
// indexed, derived rule set the engine and UI both consume.
|
|
|
|
export function compileRules(json) {
|
|
const errors = [];
|
|
const need = (cond, msg) => { if (!cond) errors.push(msg); };
|
|
|
|
need(Array.isArray(json.techs) && json.techs.length > 0, 'techs missing');
|
|
need(Array.isArray(json.units) && json.units.length > 0, 'units missing');
|
|
need(Array.isArray(json.terrains) && json.terrains.length > 0, 'terrains missing');
|
|
need(Array.isArray(json.buildings) && json.buildings.length > 0, 'buildings missing');
|
|
need(Array.isArray(json.governments) && json.governments.length > 0, 'governments missing');
|
|
need(Array.isArray(json.difficulties) && json.difficulties.length > 0, 'difficulties missing');
|
|
if (errors.length) throw new Error(`civilization-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 techs = byId(json.techs, 'tech');
|
|
const units = byId(json.units, 'unit');
|
|
const terrains = byId(json.terrains, 'terrain');
|
|
const specials = byId(json.specials ?? [], 'special');
|
|
const buildings = byId(json.buildings, 'building');
|
|
const governments = byId(json.governments, 'government');
|
|
const difficulties = byId(json.difficulties, 'difficulty');
|
|
const improvements = byId(json.improvements ?? [], 'improvement');
|
|
const worldSizes = byId(json.worldSizes ?? [], 'worldSize');
|
|
|
|
// --- tech graph validation: prereqs resolve, <=2 each, acyclic, all reachable
|
|
for (const t of json.techs) {
|
|
need(Array.isArray(t.prereqs) && t.prereqs.length <= 2, `tech ${t.id} needs 0-2 prereqs`);
|
|
for (const p of t.prereqs) need(!!techs[p], `tech ${t.id} prereq ${p} unknown`);
|
|
need(['ancient', 'medieval', 'industrial', 'modern'].includes(t.era), `tech ${t.id} bad era`);
|
|
}
|
|
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
|
|
|
|
// Topological rank: rank 0 = no prereqs; rank(t) = 1 + max(rank(prereqs)).
|
|
// Also proves acyclicity and reachability (unrankable => cycle or dangling).
|
|
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)`);
|
|
|
|
// --- cross references
|
|
for (const u of json.units) {
|
|
if (u.prereq) need(!!techs[u.prereq], `unit ${u.id} prereq tech ${u.prereq} unknown`);
|
|
if (u.obsoletedBy) need(!!units[u.obsoletedBy], `unit ${u.id} obsoletedBy ${u.obsoletedBy} unknown`);
|
|
need(['land', 'sea', 'air', 'project'].includes(u.domain), `unit ${u.id} bad domain`);
|
|
}
|
|
for (const b of json.buildings) {
|
|
if (b.prereq) need(!!techs[b.prereq], `building ${b.id} prereq tech ${b.prereq} unknown`);
|
|
if (b.requires) need(!!buildings[b.requires], `building ${b.id} requires ${b.requires} unknown`);
|
|
}
|
|
for (const g of json.governments) {
|
|
if (g.prereq) need(!!techs[g.prereq], `government ${g.id} prereq tech ${g.prereq} unknown`);
|
|
}
|
|
for (const s of json.specials ?? []) {
|
|
need(!!terrains[s.terrain], `special ${s.id} terrain ${s.terrain} unknown`);
|
|
}
|
|
for (const t of json.terrains) {
|
|
if (t.transform) need(!!terrains[t.transform], `terrain ${t.id} transform ${t.transform} unknown`);
|
|
}
|
|
for (const imp of json.improvements ?? []) {
|
|
if (imp.prereq) need(!!techs[imp.prereq], `improvement ${imp.id} prereq tech ${imp.prereq} unknown`);
|
|
if (imp.requires) need(!!improvements[imp.requires], `improvement ${imp.id} requires ${imp.requires} unknown`);
|
|
}
|
|
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
|
|
|
|
// --- derived: what each tech unlocks (for UI hovers and the "every tech
|
|
// matters" verify check)
|
|
const gates = {};
|
|
for (const id of Object.keys(techs)) gates[id] = { units: [], buildings: [], governments: [], improvements: [], prereqOf: [] };
|
|
for (const u of json.units) if (u.prereq) gates[u.prereq].units.push(u.id);
|
|
for (const b of json.buildings) if (b.prereq) gates[b.prereq].buildings.push(b.id);
|
|
for (const g of json.governments) if (g.prereq) gates[g.prereq].governments.push(g.id);
|
|
for (const imp of json.improvements ?? []) if (imp.prereq) gates[imp.prereq].improvements.push(imp.id);
|
|
for (const t of json.techs) for (const p of t.prereqs) gates[p].prereqOf.push(t.id);
|
|
|
|
// Specials grouped by terrain for worldgen.
|
|
const specialsByTerrain = {};
|
|
for (const s of json.specials ?? []) {
|
|
(specialsByTerrain[s.terrain] ??= []).push(s);
|
|
}
|
|
|
|
return {
|
|
version: json.version ?? 1,
|
|
raw: json,
|
|
techs, units, terrains, specials, buildings, governments, difficulties,
|
|
improvements, worldSizes,
|
|
techList: json.techs,
|
|
unitList: json.units,
|
|
terrainList: json.terrains,
|
|
specialList: json.specials ?? [],
|
|
buildingList: json.buildings,
|
|
governmentList: json.governments,
|
|
difficultyList: json.difficulties,
|
|
improvementList: json.improvements ?? [],
|
|
worldSizeList: json.worldSizes ?? [],
|
|
techRank: rank,
|
|
techGates: gates,
|
|
specialsByTerrain,
|
|
grasslandShieldFrame: json.grasslandShieldFrame ?? 1,
|
|
spaceship: json.spaceship,
|
|
playerColors: json.playerColors,
|
|
cityNames: json.cityNames,
|
|
yearCurve: json.yearCurve,
|
|
};
|
|
}
|
|
|
|
// Beaker cost of the (n+1)-th tech when n techs are already known.
|
|
export function techCost(nKnown, researchFactor = 1) {
|
|
return Math.round((10 + 10 * (nKnown + 1)) * researchFactor);
|
|
}
|
|
|
|
// Civ II-style turn -> calendar year.
|
|
export function turnToYear(turn, yearCurve) {
|
|
let year = -4000;
|
|
for (let i = 0; i < turn; i += 1) {
|
|
let step = 1;
|
|
for (const seg of yearCurve) {
|
|
if (year < seg.until) { step = seg.step; break; }
|
|
}
|
|
year += step;
|
|
if (year === 0) year = 1; // no year zero
|
|
}
|
|
return year;
|
|
}
|
|
|
|
export function formatYear(year) {
|
|
return year < 0 ? `${-year} BC` : `${year} AD`;
|
|
}
|