443 lines
22 KiB
JavaScript
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;
|
|
}
|