fertig-classic-games/src/games/totalannihilation/TARules.js

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