fertig-classic-games/src/games/civilization/CivilizationRules.js

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