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

443 lines
22 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']);
// Which layer a unit occupies. `air` units ignore the nav grid entirely, never collide with
// ground units, and can only be shot by weapons that list `air` in their `targets`.
export const UNIT_DOMAINS = new Set(['ground', 'air']);
// A unit/building must always be able to see a bit past its own guns, or it ends up firing
// into fog it hasn't revealed. Applied as a floor over whatever `sight` the def declares.
const SIGHT_RANGE_MARGIN_TILES = 2;
// 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', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor',
'fighter', 'bomber', 'hoverConstructor',
'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher',
'advancedVehiclePlant', 'airfield',
]);
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.
//
// The COST TABLE is authoritative, not `terrain.blocksMove`: a null cost is impassable and
// a positive one is passable, whatever the terrain says. `blocksMove` describes the ordinary
// ground unit, and every one of those declares null for cliff and water anyway — but a hover
// class that crosses water, or an air class that crosses everything, is then a JSON-only
// change instead of needing a per-terrain exception flag.
spec.costByTerrainIndex = terrain.map((t) => spec.cost[t.id] ?? null);
spec.air = spec.air === true;
}
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');
w.targetsGround = (w.targets ?? ['ground']).includes('ground');
if (!w.targetsAir && !w.targetsGround) fail(`weapon "${w.id}" targets neither ground nor 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}"`);
u.domain = u.domain ?? 'ground';
if (!UNIT_DOMAINS.has(u.domain)) fail(`unit "${u.id}" has unknown domain "${u.domain}"`);
u.isAir = u.domain === 'air';
// The domain and the move class have to agree: the sim reads `isAir` in its hot loops but
// spawning and the terrain-speed lookup still go through the move class, and a unit that
// flew while pathing on treads would be wrong in whichever of the two you didn't check.
if (u.isAir !== moveClasses[u.moveClass].air) {
fail(`unit "${u.id}" is domain "${u.domain}" but its moveClass "${u.moveClass}" is not`);
}
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.isBuilding = false;
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
u.maxRange = autoRangeOf(u.weaponDefs);
if (u.maxRange > 0) u.sight = Math.max(u.sight, u.maxRange + SIGHT_RANGE_MARGIN_TILES * c.tileSize);
u.sightSq = u.sight * u.sight;
// Regeneration is per-def data, so any unit can be given it later without code. Rates
// are converted to per-tick here so the sim never divides in its hot loop.
if (u.selfHeal) {
const frac = u.selfHeal.fractionPerMinute;
if (!(frac > 0)) fail(`unit "${u.id}" selfHeal.fractionPerMinute must be positive`);
const pause = u.selfHeal.pauseAfterDamageSec ?? 0;
if (!(pause >= 0)) fail(`unit "${u.id}" selfHeal.pauseAfterDamageSec must be >= 0`);
u.selfHeal.hpPerTick = (u.hp * frac) / 60 / c.tickHz;
u.selfHeal.pauseTicks = Math.round(pause * c.tickHz);
}
// Purely cosmetic: how far the renderer lifts this unit off the ground and how long it
// takes. The simulation has NO notion of height — a `flight` unit is still exactly where
// its x/y say it is for movement, collision, targeting and clicking. It is declared
// separately from `domain` on purpose: the Hover Constructor floats visibly but is a
// ground unit that rifles can hit and that has to path around cliffs.
// An armed aircraft STRAFES: it cannot stop in the air, so it makes firing passes and
// lands to sit still. An unarmed one (or a merely hovering ground unit like the Hover
// Constructor) keeps the simple stop-where-you-are behaviour.
u.strafes = u.isAir && (u.weapons ?? []).length > 0;
if (u.flight) {
for (const k of ['height', 'takeoffSec']) {
if (!(u.flight[k] > 0)) fail(`unit "${u.id}" flight.${k} must be positive`);
}
if (u.flight.landSec != null && !(u.flight.landSec > 0)) {
fail(`unit "${u.id}" flight.landSec must be positive when present`);
}
u.flight.landSec = u.flight.landSec ?? u.flight.takeoffSec;
if (u.strafes) {
// How far past the target it carries before turning about, and how close to a hostile
// structure it is willing to put down. Both are distances in pixels.
for (const k of ['overshoot', 'keepout']) {
if (!(u.flight[k] > 0)) fail(`strafing unit "${u.id}" needs a positive flight.${k}`);
}
}
} else if (u.isAir) {
fail(`air unit "${u.id}" needs a flight block, or it will render sitting on the ground`);
}
u.spritePx = u.spritePx ?? sc.radius * 2;
// Losing one of these ends the match under the default victory rule, so it is def data
// rather than a hardcoded "commander" id — a second Commander-class unit is a JSON change.
u.isCommander = u.isCommander === true;
buildable[u.id] = u;
}
const unitById = indexById(units);
const commanderUnits = units.filter((u) => u.isCommander);
// An air roster with nothing able to shoot at it isn't a unit type, it's an auto-win.
if (units.some((u) => u.isAir) && !weapons.some((w) => w.targetsAir)) {
fail('air units exist but no weapon targets the air domain');
}
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.isAir = false;
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
// True half-extents. `radius` is a circle around the centre, which understates a
// building's reach along its diagonals and is what made units drive into the walls of
// anything bigger than 1x1 before they would open fire.
b.halfW = (b.footprint.w * c.tileSize) / 2;
b.halfH = (b.footprint.h * c.tileSize) / 2;
b.sight = b.sight ?? 0;
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
b.maxRange = autoRangeOf(b.weaponDefs);
if (b.maxRange > 0) b.sight = Math.max(b.sight, b.maxRange + SIGHT_RANGE_MARGIN_TILES * c.tileSize);
b.sightSq = b.sight * b.sight;
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`);
}
}
}
// ---- victory modes -----------------------------------------------------
// The engine understands two rules: "commander" (an army dies with its Commander) and
// "annihilation" (an army dies when it can no longer produce). The list here is what the
// skirmish setup offers and in what order, and it names the default.
const victoryModes = json.victoryModes ?? [];
requireUnique(victoryModes, 'victoryMode');
if (!victoryModes.length) fail('at least one victoryMode is required');
for (const v of victoryModes) {
if (!v.label) fail(`victoryMode "${v.id}" needs a label`);
if (v.id !== 'commander' && v.id !== 'annihilation') {
fail(`victoryMode "${v.id}" is not a rule the engine implements (commander|annihilation)`);
}
}
const victoryModeById = indexById(victoryModes);
if (!victoryModeById[c.victoryDefault]) {
fail(`constants.victoryDefault "${c.victoryDefault}" is not one of victoryModes`);
}
if (victoryModeById.commander && !commanderUnits.length) {
fail('the "commander" victory mode needs at least one unit flagged isCommander');
}
// ---- 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, commanderUnits,
buildings, buildingById,
victoryModes, victoryModeById,
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,
};
}
/** Can this weapon engage something in the given domain at all? */
export function weaponHitsDomain(weapon, targetIsAir) {
return targetIsAir ? weapon.targetsAir : weapon.targetsGround;
}
/**
* Does `def` carry ANY weapon that can reach `target`'s domain? Anything that can't should
* never acquire, chase or fire at it — a rifleman ordered to attack a Fighter would otherwise
* follow it across the map forever, never able to shoot.
*
* Reads `airborne`, not `isAir`: a landed aircraft is an ordinary ground target, so this flips
* for the same unit as it takes off and touches down.
*/
export function canEngage(def, target) {
const air = !!target.airborne;
for (const w of def.weaponDefs ?? []) if (weaponHitsDomain(w, air)) return true;
return false;
}
/** 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;
}