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