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