322 lines
15 KiB
JavaScript
322 lines
15 KiB
JavaScript
// Total Annihilation — rules compiler and validator.
|
|
//
|
|
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
|
|
//
|
|
// compileRules() indexes data/totalannihilation-rules.json by id and validates every
|
|
// cross-reference against a closed key set, throwing on the first problem. The point is
|
|
// that a typo in the JSON fails loudly in the verify script rather than silently at
|
|
// runtime three minutes into a match.
|
|
|
|
export const WEAPON_KINDS = new Set(['hitscan', 'ballistic', 'guided', 'beam']);
|
|
export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun']);
|
|
export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
|
|
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
|
|
export const TARGET_DOMAINS = new Set(['ground', 'air']);
|
|
|
|
// Procedural painter shapes. A def may only name a shape TAArt knows how to draw;
|
|
// this set is duplicated there and the verify script asserts the two agree.
|
|
export const PROC_SHAPES = new Set([
|
|
'commander', 'infantry', 'sniper', 'jeep', 'tank', 'rockettank',
|
|
'energyGen', 'massGen', 'barracks', 'vehiclePlant',
|
|
]);
|
|
|
|
function fail(msg) {
|
|
throw new Error(`[totalannihilation-rules] ${msg}`);
|
|
}
|
|
|
|
function requireUnique(list, what) {
|
|
const seen = new Set();
|
|
for (const e of list) {
|
|
if (!e || typeof e.id !== 'string' || !e.id) fail(`${what} entry is missing an id`);
|
|
if (seen.has(e.id)) fail(`duplicate ${what} id "${e.id}"`);
|
|
seen.add(e.id);
|
|
}
|
|
}
|
|
|
|
function requirePositive(obj, keys, what) {
|
|
for (const k of keys) {
|
|
const v = obj[k];
|
|
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
|
|
fail(`${what} "${obj.id}" needs a positive ${k} (got ${JSON.stringify(v)})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acquisition range. Manual weapons are excluded so a Commander doesn't chase enemies into
|
|
* D-Gun range it will never voluntarily use; a manual-only def falls back to its full reach
|
|
* so it still knows when something is worth shooting at.
|
|
*/
|
|
function autoRangeOf(weaponDefs) {
|
|
const auto = weaponDefs.filter((w) => !w.manual);
|
|
return (auto.length ? auto : weaponDefs).reduce((m, w) => Math.max(m, w.range), 0);
|
|
}
|
|
|
|
function indexById(list) {
|
|
const m = Object.create(null);
|
|
for (const e of list) m[e.id] = e;
|
|
return m;
|
|
}
|
|
|
|
/**
|
|
* Compile and validate the raw rules JSON.
|
|
* @param {object} json parsed data/totalannihilation-rules.json
|
|
* @returns {object} compiled rules with by-id indexes and derived tables
|
|
*/
|
|
export function compileRules(json) {
|
|
if (!json || typeof json !== 'object') fail('rules JSON is empty');
|
|
|
|
const c = json.constants;
|
|
if (!c) fail('missing constants');
|
|
requirePositive(c, ['tickHz', 'tileSize', 'unitCapPerArmy', 'buildPowerNominal'], 'constants');
|
|
if (c.tileSize % 2 !== 0) fail('constants.tileSize must be even');
|
|
|
|
// ---- closed sets -------------------------------------------------------
|
|
const armorClasses = json.armorClasses ?? [];
|
|
if (!armorClasses.length) fail('armorClasses must be a non-empty array');
|
|
const armorSet = new Set(armorClasses);
|
|
|
|
const sizeClasses = json.sizeClasses ?? {};
|
|
for (const [id, s] of Object.entries(sizeClasses)) {
|
|
if (!(s.radius > 0)) fail(`sizeClass "${id}" needs a positive radius`);
|
|
if (!(s.footprint >= 1)) fail(`sizeClass "${id}" needs footprint >= 1`);
|
|
if (!(s.mass > 0)) fail(`sizeClass "${id}" needs a positive mass`);
|
|
}
|
|
const sizeSet = new Set(Object.keys(sizeClasses));
|
|
|
|
// ---- terrain -----------------------------------------------------------
|
|
const terrain = json.terrain ?? [];
|
|
requireUnique(terrain, 'terrain');
|
|
const terrainById = indexById(terrain);
|
|
const terrainByCh = Object.create(null);
|
|
terrain.forEach((t, i) => {
|
|
if (typeof t.ch !== 'string' || t.ch.length !== 1) fail(`terrain "${t.id}" needs a single-char ch`);
|
|
if (terrainByCh[t.ch]) fail(`terrain char "${t.ch}" used by both "${terrainByCh[t.ch].id}" and "${t.id}"`);
|
|
if (!Number.isInteger(t.frame) || t.frame < 0) fail(`terrain "${t.id}" needs a non-negative integer frame`);
|
|
t.index = i;
|
|
terrainByCh[t.ch] = t;
|
|
});
|
|
if (!terrain.length) fail('at least one terrain type is required');
|
|
|
|
// ---- move classes ------------------------------------------------------
|
|
const moveClasses = json.moveClasses ?? {};
|
|
for (const [mc, spec] of Object.entries(moveClasses)) {
|
|
if (!spec.cost) fail(`moveClass "${mc}" is missing its cost table`);
|
|
for (const t of terrain) {
|
|
if (!(t.id in spec.cost)) fail(`moveClass "${mc}" has no cost for terrain "${t.id}"`);
|
|
const v = spec.cost[t.id];
|
|
if (v !== null && !(typeof v === 'number' && v > 0)) {
|
|
fail(`moveClass "${mc}" cost for "${t.id}" must be a positive number or null (impassable)`);
|
|
}
|
|
}
|
|
for (const key of Object.keys(spec.cost)) {
|
|
if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`);
|
|
}
|
|
// Flat per-terrain-index cost array — the pathfinder's hot path reads this.
|
|
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id]));
|
|
}
|
|
const moveClassSet = new Set(Object.keys(moveClasses));
|
|
|
|
// ---- armies & commanders ----------------------------------------------
|
|
const armies = json.armies ?? [];
|
|
requireUnique(armies, 'army');
|
|
if (armies.length < 2) fail('at least two armies are required');
|
|
for (const a of armies) {
|
|
if (!a.unitSheet || !a.structureSheet) fail(`army "${a.id}" needs unitSheet and structureSheet`);
|
|
if (!/^#[0-9a-f]{6}$/i.test(a.color ?? '')) fail(`army "${a.id}" needs a #rrggbb color`);
|
|
a.colorInt = parseInt(a.color.slice(1), 16);
|
|
}
|
|
const armyById = indexById(armies);
|
|
const armyIndexById = Object.create(null);
|
|
armies.forEach((a, i) => { armyIndexById[a.id] = i; });
|
|
|
|
const commanders = json.commanders ?? [];
|
|
requireUnique(commanders, 'commander');
|
|
for (const cm of commanders) {
|
|
if (!armyById[cm.armyId]) fail(`commander "${cm.id}" references unknown army "${cm.armyId}"`);
|
|
if (!cm.opponentId) fail(`commander "${cm.id}" needs an opponentId for its portrait`);
|
|
}
|
|
const commanderById = indexById(commanders);
|
|
|
|
// ---- weapons -----------------------------------------------------------
|
|
const weapons = json.weapons ?? [];
|
|
requireUnique(weapons, 'weapon');
|
|
for (const w of weapons) {
|
|
if (!WEAPON_KINDS.has(w.kind)) {
|
|
fail(`weapon "${w.id}" has unknown kind "${w.kind}" (expected ${[...WEAPON_KINDS].join('|')})`);
|
|
}
|
|
requirePositive(w, ['damage', 'reload', 'range'], 'weapon');
|
|
if (w.kind === 'ballistic' || w.kind === 'guided') {
|
|
requirePositive(w, ['speed'], 'weapon');
|
|
}
|
|
if (w.kind === 'guided' && !(w.turnRate > 0)) fail(`guided weapon "${w.id}" needs a positive turnRate`);
|
|
if (w.minRange != null && !(w.minRange >= 0 && w.minRange < w.range)) {
|
|
fail(`weapon "${w.id}" minRange must be >= 0 and < range`);
|
|
}
|
|
for (const key of Object.keys(w.armorMul ?? {})) {
|
|
if (!armorSet.has(key)) fail(`weapon "${w.id}" armorMul references unknown armor class "${key}"`);
|
|
}
|
|
for (const a of armorClasses) {
|
|
if (!(a in (w.armorMul ?? {}))) fail(`weapon "${w.id}" armorMul is missing armor class "${a}"`);
|
|
}
|
|
for (const d of w.targets ?? []) {
|
|
if (!TARGET_DOMAINS.has(d)) fail(`weapon "${w.id}" targets unknown domain "${d}"`);
|
|
}
|
|
if (!w.fx || !FX_STYLES.has(w.fx.style)) {
|
|
fail(`weapon "${w.id}" needs fx.style from ${[...FX_STYLES].join('|')}`);
|
|
}
|
|
if (w.aoe != null && !(w.aoe > 0)) fail(`weapon "${w.id}" aoe must be positive when present`);
|
|
// Reload/burst in ticks, precomputed so the sim never divides in its hot loop.
|
|
w.reloadTicks = Math.max(1, Math.round(w.reload * c.tickHz));
|
|
w.burstDelayTicks = Math.max(1, Math.round((w.burstDelay ?? 0) * c.tickHz));
|
|
w.burst = w.burst ?? 1;
|
|
w.rangeSq = w.range * w.range;
|
|
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
|
|
w.targetsAir = (w.targets ?? ['ground']).includes('air');
|
|
// A manual weapon never auto-fires: it needs an explicit attack order on the victim.
|
|
// The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander
|
|
// deletes one attacker every reload for free, which makes assaulting any base suicide.
|
|
w.manual = w.manual === true;
|
|
}
|
|
const weaponById = indexById(weapons);
|
|
|
|
// ---- shared unit/building validation ----------------------------------
|
|
const buildable = Object.create(null); // id -> def, for `builds` cross-checks
|
|
|
|
const units = json.units ?? [];
|
|
requireUnique(units, 'unit');
|
|
for (const u of units) {
|
|
if (!sizeSet.has(u.size)) fail(`unit "${u.id}" has unknown size "${u.size}"`);
|
|
if (!armorSet.has(u.armorClass)) fail(`unit "${u.id}" has unknown armorClass "${u.armorClass}"`);
|
|
if (!moveClassSet.has(u.moveClass)) fail(`unit "${u.id}" has unknown moveClass "${u.moveClass}"`);
|
|
if (u.role && !UNIT_ROLES.has(u.role)) fail(`unit "${u.id}" has unknown role "${u.role}"`);
|
|
if (!SHEET_SLOTS.has(u.sheetSlot)) fail(`unit "${u.id}" has unknown sheetSlot "${u.sheetSlot}"`);
|
|
if (!PROC_SHAPES.has(u.procShape)) fail(`unit "${u.id}" has unknown procShape "${u.procShape}"`);
|
|
requirePositive(u, ['hp', 'speed', 'turnRate', 'sight'], 'unit');
|
|
if (!Number.isInteger(u.frame) || u.frame < 0) fail(`unit "${u.id}" needs a non-negative integer frame`);
|
|
if (u.turretFrame != null && (!Number.isInteger(u.turretFrame) || u.turretFrame < 0)) {
|
|
fail(`unit "${u.id}" turretFrame must be a non-negative integer`);
|
|
}
|
|
for (const wid of u.weapons ?? []) {
|
|
if (!weaponById[wid]) fail(`unit "${u.id}" references unknown weapon "${wid}"`);
|
|
}
|
|
const sc = sizeClasses[u.size];
|
|
u.radius = u.radius ?? sc.radius;
|
|
u.massClass = sc.mass;
|
|
u.footprintTiles = sc.footprint;
|
|
u.sightSq = u.sight * u.sight;
|
|
u.isBuilding = false;
|
|
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
|
|
u.maxRange = autoRangeOf(u.weaponDefs);
|
|
u.spritePx = u.spritePx ?? sc.radius * 2;
|
|
buildable[u.id] = u;
|
|
}
|
|
const unitById = indexById(units);
|
|
|
|
const buildings = json.buildings ?? [];
|
|
requireUnique(buildings, 'building');
|
|
for (const b of buildings) {
|
|
if (!armorSet.has(b.armorClass)) fail(`building "${b.id}" has unknown armorClass "${b.armorClass}"`);
|
|
if (!SHEET_SLOTS.has(b.sheetSlot)) fail(`building "${b.id}" has unknown sheetSlot "${b.sheetSlot}"`);
|
|
if (!PROC_SHAPES.has(b.procShape)) fail(`building "${b.id}" has unknown procShape "${b.procShape}"`);
|
|
requirePositive(b, ['hp', 'buildTime'], 'building');
|
|
if (!b.footprint || !(b.footprint.w >= 1) || !(b.footprint.h >= 1)) {
|
|
fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`);
|
|
}
|
|
if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`);
|
|
if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) {
|
|
fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`);
|
|
}
|
|
for (const wid of b.weapons ?? []) {
|
|
if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`);
|
|
}
|
|
b.isBuilding = true;
|
|
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
|
|
b.sight = b.sight ?? 0;
|
|
b.sightSq = b.sight * b.sight;
|
|
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
|
|
b.maxRange = autoRangeOf(b.weaponDefs);
|
|
buildable[b.id] = b;
|
|
}
|
|
const buildingById = indexById(buildings);
|
|
|
|
// `builds` lists must resolve, and a factory may only build units (not buildings).
|
|
for (const def of [...units, ...buildings]) {
|
|
for (const id of def.builds ?? []) {
|
|
if (!buildable[id]) fail(`"${def.id}" builds unknown def "${id}"`);
|
|
if (def.isBuilding && !unitById[id]) fail(`factory "${def.id}" may only build units, not "${id}"`);
|
|
if (!def.isBuilding && !buildingById[id]) fail(`mobile builder "${def.id}" may only build buildings, not "${id}"`);
|
|
}
|
|
if ((def.builds ?? []).length && !(def.buildPower > 0)) {
|
|
fail(`"${def.id}" has a builds list but no positive buildPower`);
|
|
}
|
|
}
|
|
// Every producible unit must be reachable from some factory, or it's dead data.
|
|
for (const u of units) {
|
|
if (u.buildTime === 0) continue; // starting units (the Commander) are placed, not built
|
|
const madeBy = buildings.filter((b) => (b.builds ?? []).includes(u.id));
|
|
if (!madeBy.length) fail(`unit "${u.id}" has a buildTime but no building builds it`);
|
|
for (const bid of u.builtBy ?? []) {
|
|
if (!buildingById[bid]) fail(`unit "${u.id}" builtBy references unknown building "${bid}"`);
|
|
if (!(buildingById[bid].builds ?? []).includes(u.id)) {
|
|
fail(`unit "${u.id}" claims builtBy "${bid}" but that building does not list it`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- AI skills ---------------------------------------------------------
|
|
const aiSkills = json.aiSkills ?? [];
|
|
const aiBySkill = Object.create(null);
|
|
for (const s of aiSkills) {
|
|
if (!Number.isInteger(s.skill) || s.skill < 1) fail('aiSkills entries need an integer skill >= 1');
|
|
aiBySkill[s.skill] = s;
|
|
}
|
|
if (!aiSkills.length) fail('at least one aiSkills entry is required');
|
|
|
|
// ---- skirmish ----------------------------------------------------------
|
|
const sk = json.skirmish;
|
|
if (!sk) fail('missing skirmish config');
|
|
for (const [name, tiles] of Object.entries(sk.sizes ?? {})) {
|
|
if (!Number.isInteger(tiles) || tiles < 32) fail(`skirmish size "${name}" must be an integer >= 32 tiles`);
|
|
}
|
|
for (const id of sk.startUnits ?? []) {
|
|
if (!unitById[id]) fail(`skirmish.startUnits references unknown unit "${id}"`);
|
|
}
|
|
if (!sk.sizes?.[sk.defaults?.size]) fail('skirmish.defaults.size is not one of skirmish.sizes');
|
|
if (!(sk.symmetries ?? []).includes(sk.defaults?.symmetry)) fail('skirmish.defaults.symmetry is not one of skirmish.symmetries');
|
|
if (!(sk.themes ?? []).includes(sk.defaults?.theme)) fail('skirmish.defaults.theme is not one of skirmish.themes');
|
|
if (!armyById[sk.defaults?.playerArmy]) fail('skirmish.defaults.playerArmy is not a known army');
|
|
|
|
return {
|
|
version: json.version ?? 1,
|
|
constants: c,
|
|
armorClasses, armorSet,
|
|
sizeClasses,
|
|
terrain, terrainById, terrainByCh,
|
|
moveClasses,
|
|
armies, armyById, armyIndexById,
|
|
commanders, commanderById,
|
|
weapons, weaponById,
|
|
units, unitById,
|
|
buildings, buildingById,
|
|
defById: buildable,
|
|
commandIcons: json.commandIcons ?? {},
|
|
aiSkills, aiBySkill,
|
|
skirmish: sk,
|
|
// Derived conveniences the sim and view both want.
|
|
stepMs: 1000 / c.tickHz,
|
|
dt: 1 / c.tickHz,
|
|
halfTile: c.tileSize / 2,
|
|
};
|
|
}
|
|
|
|
/** Damage multiplier of `weapon` against a defender of `armorClass`. */
|
|
export function armorMul(weapon, armorClass) {
|
|
return weapon.armorMul?.[armorClass] ?? 1;
|
|
}
|
|
|
|
/** Look up a def (unit or building) by id. */
|
|
export function defOf(rules, id) {
|
|
return rules.defById[id] ?? null;
|
|
}
|