Files
ark-stronghold-protocol/server/sim/simdata.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

711 lines
32 KiB
JavaScript

// server/sim/simdata.js — data access + normalisation layer for the simulation.
//
// The sim never reads raw JSON records directly: every chess / enemy / token record is normalised once into an
// engine "def" (cached, frozen input is never mutated). Sources, in order:
// 1. an injected data object (Battle opts.data: `{ chess, enemies, tokens, stages, waves }` maps or getters)
// 2. the default source:
// Node — `server/data.js → getData()` (data/*.json generated by tools/build-data.mjs) with a research JSON
// fallback (docs/research/03-operators.json, 05-enemies.json, 05-maps.json), loaded by ./nodeData.js
// so the sim and its tests work before/without generated data;
// browser — whatever the page injected with `setSimData(data)` (the fetched /data/*.json, DESIGN §14).
// Both the research record shapes and the build-data shapes (docs/DATA.md) are accepted.
//
// Operator loadouts (DESIGN §16, DATA.md §2.2): `getChess(id, { skillIndex, moduleId })` resolves the def of a chess
// with the selected skill (def.skill / its blackboard / trigger) and — elite only — module (stats = statsBase + attr,
// trait, talents); `getToken(id, owner, ownerLoadout)` the owner-loadout variant of a summon. Pure helpers for the
// match / UI (implemented in shared/loadoutRecord.js, re-exported here): resolveLoadout, loadoutRecord (the resolved
// chess record), composeStats / composeTalents. (Which choices a player may make is shared/protocol.js
// loadoutOptions / checkLoadout.)
//
// This module is pure ESM shared with browsers (served at /sim/): no Node API is imported here. Under Node the default
// loader (./nodeData.js) is imported dynamically at module evaluation; browsers never load it.
// setSimData(data) inject the full game data object ({ chess, enemies, tokens, stages, waves, bonds, items, … });
// it becomes the default source (no research fallback) and `getSimData()` (the browser stand-in of
// server/data.js getData() used by sim/content/support serves it).
// getSimData() the injected data (browser) or the generated data (Node), or null.
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
// ---------------------------------------------------------------------------------------------------------------
// generic helpers
/** Flatten an Arknights blackboard `[{key,value,valueStr}]` (or a plain object) into `{ key: value }`. */
export function flattenBlackboard(bb) {
if (!bb) return {};
if (!Array.isArray(bb)) return { ...bb };
const out = {};
for (const e of bb) {
if (!e || e.key == null) continue;
const v = e.valueStr != null && e.valueStr !== '' && (e.value === 0 || e.value == null) ? e.valueStr : e.value;
out[e.key] = v;
}
return out;
}
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/**
* Is an items.json record a shop item — what the 调度中心 item slot, the 道具补给 / 机密商店 cards and the "random
* item" pools (pool_equip_normal …) draw from: a normal EQUIP item with a tier, neither hidden by the official shop
* table (`hideInShop`: the Arts) nor one of the effect-only items build-data marks `shopExcluded` (the special
* 维式重锤 of 维多利亚 / 洛洛, 突变细胞 — tools/build-data.mjs SHOP_EXCLUDED_ITEMS).
*/
export function isShopItem(it) {
return !!it && typeof it === 'object' && !it.isGolden && !it.hideInShop && !it.shopExcluded && it.itemType === 'EQUIP' && Number.isInteger(it.tier);
}
function toArrayOfPairs(grid) {
if (!Array.isArray(grid)) return null;
const out = [];
for (const p of grid) {
if (Array.isArray(p) && p.length >= 2) out.push([num(p[0]), num(p[1])]);
else if (p && typeof p === 'object' && ('row' in p || 'r' in p)) out.push([num(p.row ?? p.r), num(p.col ?? p.c)]);
}
return out;
}
export function normDmgType(v, fallback = 'phys') {
if (v == null) return fallback;
const s = String(Array.isArray(v) ? v[0] : v).toUpperCase();
if (s.startsWith('PHYS')) return 'phys';
if (s.startsWith('MAG') || s === 'ARTS') return 'arts';
if (s === 'HEAL' || s === 'HEALING') return 'heal';
if (s === 'TRUE' || s === 'REAL' || s === 'PURE') return 'true';
if (s === 'NO_DAMAGE' || s === 'NONE' || s === 'NO') return 'none';
if (s.startsWith('ELEM')) return 'true';
return fallback;
}
export function normSpType(v) {
const s = String(v ?? '').toUpperCase();
if (s.includes('TIME')) return 'time';
if (s.includes('ATTACK')) return 'attack';
if (s.includes('DAMAGE') || s.includes('HURT')) return 'hurt';
return 'none';
}
export function normTriggerRule(v) {
const s = String(v ?? 'DEFAULT').toUpperCase();
if (s === 'ALWAYS' || s === 'SP_FULL') return 'SP_FULL';
if (s.startsWith('CUSTOM_RANGE')) return 'CUSTOM_RANGE';
return s;
}
// ---------------------------------------------------------------------------------------------------------------
// def normalisers
/** Normalise the skill part of a chess/token record. Returns null when there is no skill. */
export function normalizeSkill(rec) {
const s = rec && (rec.skill ?? null);
if (!s || typeof s !== 'object') return null;
const bb = s.bb ? { ...s.bb } : flattenBlackboard(s.blackboard);
const trig = s.trigger ?? rec.skillTrigger ?? {};
let rule = normTriggerRule(typeof trig === 'string' ? trig : trig.rule);
const matchedBy = typeof trig === 'object' ? String(trig.matchedBy ?? '') : '';
const skillIndex = s.skillIndex ?? s.index ?? null;
// Research correction (03 addendum C1): profession/subprofession rows apply to skill 1 only.
if (/^(profession|subProfession)/.test(matchedBy) && skillIndex != null && skillIndex !== 0) rule = 'DEFAULT';
const trigGrid = toArrayOfPairs((typeof trig === 'object' && (trig.customRangeGrid ?? trig.rangeGrid ?? trig.grid)) || rec.skillTriggerCustomRangeGrid);
if (/^skcom_withdraw/.test(String(s.skillId ?? s.id ?? ''))) return null;
const durRaw = s.duration ?? bb.duration ?? 0;
const skillType = String(s.skillType ?? s.type ?? 'MANUAL').toUpperCase();
return {
id: s.skillId ?? s.id ?? 'skill',
name: s.name ?? '',
index: skillIndex,
skillType,
durationType: String(s.durationType ?? 'NONE').toUpperCase(),
duration: num(durRaw, 0),
spType: normSpType(s.spType),
spCost: Math.max(0, num(s.spCost, 0)),
initSp: Math.max(0, num(s.initSp, 0)),
maxCharges: Math.max(1, num(s.maxChargeTime ?? s.maxCharges ?? s.charges, 1)),
rangeGrid: toArrayOfPairs(s.rangeGrid),
trigger: { rule, grid: trigGrid },
kind: s.kind ?? null,
bb,
description: s.description ?? s.desc ?? '',
};
}
function normStats(st) {
st = st || {};
return {
maxHp: Math.max(1, num(st.maxHp ?? st.hp, 1000)),
atk: Math.max(0, num(st.atk, 0)),
def: Math.max(0, num(st.def, 0)),
res: Math.max(0, num(st.magicResistance ?? st.res ?? st.resistance, 0)),
aspd: num(st.attackSpeed ?? st.aspd, 100) || 100,
bat: Math.max(0.05, num(st.baseAttackTime ?? st.bat, 1)),
blockCnt: Math.max(0, num(st.blockCnt ?? st.block, 1)),
cost: Math.max(0, num(st.cost, 10)),
respawnTime: Math.max(0, num(st.respawnTime ?? st.redeploy, 70)),
spRecovery: num(st.spRecoveryPerSec ?? st.spRecovery, 1),
tauntLevel: num(st.tauntLevel ?? st.taunt, 0),
massLevel: num(st.massLevel ?? st.mass, 0),
hpRecoveryPerSec: num(st.hpRecoveryPerSec, 0),
moveSpeed: num(st.moveSpeed, 1),
};
}
/** Normalise a chess record (research 03 shape or data/chess.json shape) into an OpDef. */
export function normalizeChess(rec) {
const id = rec.chessId ?? rec.id;
const golden = !!(rec.isGolden ?? rec.golden ?? /_b$/.test(String(id)));
const baseId = rec.baseChessId ?? rec.baseId ?? String(id).replace(/_b$/, '_a');
const skill = normalizeSkill(rec);
const talents = (rec.talents ?? []).map((t) => ({
name: t.name ?? '',
description: t.description ?? t.desc ?? '',
bb: t.bb ? { ...t.bb } : flattenBlackboard(t.blackboard),
bbStr: t.bbStr ? { ...t.bbStr } : {},
rangeGrid: toArrayOfPairs(t.rangeGrid),
tokenKey: t.tokenKey ?? null,
}));
return {
type: 'chess',
id,
baseId,
golden,
name: rec.name ?? id,
charId: rec.charId ?? null,
tier: num(rec.tier, 1),
rarity: num(rec.rarity, 4),
profession: String(rec.profession ?? 'WARRIOR').toUpperCase(),
subProf: rec.subProfessionId ?? rec.subProfession ?? rec.subProf ?? null,
position: String(rec.position ?? 'MELEE').toUpperCase(),
bonds: [...(rec.bondIds ?? rec.bonds ?? [])],
stats: normStats(rec.stats),
rangeGrid: toArrayOfPairs(rec.rangeGrid ?? rec.range?.grid) ?? [[0, 0], [0, 1]],
dmgType: rec.dmgType ? normDmgType(rec.dmgType) : null,
attackKind: rec.attackKind ?? null,
projectile: rec.projectile ?? null,
canHitFly: rec.canHitFly ?? null,
targetPriority: rec.targetPriority ?? null,
splashRadius: rec.splashRadius ?? null,
trait: typeof rec.trait === 'string' ? rec.trait : (rec.trait?.desc ?? rec.traitText ?? ''),
traitBb: rec.traitBb ?? (rec.trait && typeof rec.trait === 'object' ? { ...(rec.trait.bb || {}) } : flattenBlackboard(rec.traitBlackboard)),
immune: immuneSet(rec.immunities ?? rec.immune),
skill,
talents,
tokens: rec.tokens ?? [],
spine: rec.assets?.spine ?? rec.spine ?? rec.charId ?? null,
avatar: rec.assets?.avatar ?? rec.avatar ?? rec.charId ?? null,
raw: rec,
};
}
// ---------------------------------------------------------------------------------------------------------------
// operator loadouts (DESIGN §16): selected skill + module of a chess
//
// A loadout `{ skillIndex, moduleId }` (also accepted: `{ skill, module }`, the client's per-browser shape) is resolved
// against a data/chess.json record: `skillIndex` must be the index of one of `rec.skills` (the skills unlocked at the
// record's status), `moduleId` 'none' or the uniEquipId of one of `rec.modules` (golden chess only). Anything else —
// missing, unknown, not unlocked at this record (a tier-1/2 normal chess has no S3) — falls back to the default.
// Research-shaped records (no `skills` list) always resolve to their single skill.
/** Resolve a loadout against a chess record (shared/loadoutRecord.js resolveRecordLoadout). */
export const resolveLoadout = resolveRecordLoadout;
export { composeStats, composeTalents, loadoutRecord };
/** Cache key suffix of a resolved non-default loadout ('' for the default). */
export function loadoutKey(lo) {
return lo && !lo.isDefault ? `|s${lo.skillIndex}|m${lo.moduleId ?? ''}` : '';
}
/** Build an immunity Set from an object `{stun:true,…}` or an array `['stun', 'feared']`. */
export function immuneSet(v, extra = null) {
const out = new Set();
const add = (k) => out.add(String(k).replace(/Immune$/, '').toLowerCase());
if (Array.isArray(v)) v.forEach(add);
else if (v && typeof v === 'object') for (const k of Object.keys(v)) if (v[k]) add(k);
if (Array.isArray(extra)) extra.forEach(add);
return out;
}
/** Normalise an enemy record (research 05 shape or data/enemies.json shape) into an EnemyDef. */
export function normalizeEnemy(key, e) {
const st = e.stats ?? e;
const range = num(st.rangeRadius ?? st.range, 0);
const applyWay = String(e.applyWay ?? st.applyWay ?? (range > 0 ? 'RANGED' : 'MELEE')).toUpperCase();
let dmgType = normDmgType(st.dmgType ?? e.dmgType ?? e.dmg, 'phys');
if (applyWay === 'NONE') dmgType = 'none';
const immune = immuneSet(st.immunities ?? e.immune ?? e.immunities, st.otherImmunities ?? e.otherImmunities);
const extra = e.extra ?? {};
return {
type: 'enemy',
id: key,
key,
name: e.name ?? key,
rank: String(e.rank ?? 'NORMAL').toUpperCase(),
maxHp: Math.max(1, num(st.maxHp ?? st.hp, 1000)),
atk: Math.max(0, num(st.atk, 0)),
def: Math.max(0, num(st.def, 0)),
res: Math.max(0, num(st.res ?? st.magicResistance, 0)),
aspd: num(st.aspd ?? st.attackSpeed, 100) || 100,
bat: Math.max(0.1, num(st.bat ?? st.baseAttackTime, 2)),
rangeRadius: range > 0 ? range : 0,
moveSpeed: Math.max(0, num(st.moveSpeed ?? st.spd, 1)),
massLevel: num(st.massLevel ?? st.mass, 1),
lpr: num(st.lpr ?? e.lpr ?? e.lifePointReduce, 1),
motion: String(st.motion ?? e.motion ?? 'WALK').toUpperCase() === 'FLY' ? 'FLY' : 'WALK',
applyWay,
dmgType,
immune,
tauntLevel: num(st.tauntLevel ?? extra.tauntLevel ?? e.tauntLevel, 0),
blockCnt: Math.max(1, num(st.blockCnt ?? e.blockCnt ?? extra.blockCnt, 1)),
epResistance: num(st.elementRes ?? extra.epResistance ?? e.epResistance, 0),
epDamageResistance: num(st.elementDmgRes ?? extra.epDamageResistance ?? e.epDamageResistance, 0),
hpRecoveryPerSec: num(st.hpRecoveryPerSec ?? extra.hpRecoveryPerSec ?? e.hpRecoveryPerSec, 0),
notCountInTotal: !!(e.notCountInTotal),
tags: e.tags ?? [],
abilities: e.abilities ?? [],
skills: e.skills ?? [],
talent: e.talents?.bb ?? e.talent ?? {},
spine: e.spine ?? key,
avatar: e.iconId ?? e.avatar ?? key,
raw: e,
};
}
/** Normalise a token record (+ optional owner-specific variant: `t.variants[ownerChessId]`). */
export function normalizeToken(id, t0, ownerChessId = null, variantOverride = null) {
const variant = variantOverride ?? (ownerChessId && t0.variants ? t0.variants[ownerChessId] : null);
const t = variant ? { ...t0, ...variant } : t0;
const skill = normalizeSkill(t);
const traitText = typeof t.trait === 'string' ? t.trait : (t.trait?.desc ?? '');
const desc = String(t.description ?? t.desc ?? traitText ?? '');
const stats = normStats(t.stats ?? {});
const ak = t.attackKind ? String(t.attackKind).toLowerCase() : null;
return {
type: 'token',
id,
baseId: id,
golden: false,
name: t.name ?? id,
charId: id,
tier: num(t.tier, 1),
rarity: 1,
profession: String(t.profession ?? 'TOKEN').toUpperCase(),
subProf: t.subProfessionId ?? t.subProf ?? null,
position: String(t.position ?? 'MELEE').toUpperCase(),
bonds: [],
stats,
rangeGrid: toArrayOfPairs(t.rangeGrid) ?? [[0, 0], [0, 1]],
dmgType: t.dmgType ? normDmgType(t.dmgType) : (/法术/.test(desc) ? 'arts' : /真实/.test(desc) ? 'true' : /恢复|治疗/.test(desc) ? 'heal' : 'phys'),
attackKind: ak,
projectile: t.projectile ?? null,
canHitFly: t.canHitFly ?? null,
targetPriority: t.targetPriority ?? null,
untargetable: t.untargetable ?? /不会受到攻击|不会成为/.test(desc),
immune: immuneSet(t.immunities),
trait: desc,
traitBb: t.trait && typeof t.trait === 'object' ? { ...(t.trait.bb || {}) } : {},
skill,
// chess/token records carry a talent array; enemy-shaped records (炎佑 enemy_9012_acloon) one `{ bb, bbStr }` object
talents: (Array.isArray(t.talents) ? t.talents : t.talents && typeof t.talents === 'object' ? [t.talents] : [])
.filter((x) => x && typeof x === 'object')
.map((x) => ({ name: x.name ?? '', description: x.desc ?? x.description ?? '', bb: x.bb ? { ...x.bb } : flattenBlackboard(x.blackboard), bbStr: x.bbStr ? { ...x.bbStr } : {}, rangeGrid: toArrayOfPairs(x.rangeGrid), tokenKey: x.tokenKey ?? null })),
tokens: [],
count: num(t.count, 1),
// how the owner (with its loadout) produces this token: ⊆ ['talent','skill','display'] (data variant / bySkill);
// [] or ['display'] = the owner does not produce it; null = unknown (no owner variant)
sources: variant && Array.isArray(t.sources) ? [...t.sources] : null,
spine: t.spine ?? t.assets?.spine ?? id,
avatar: t.avatar ?? t.assets?.avatar ?? id,
raw: t0,
};
}
// ---------------------------------------------------------------------------------------------------------------
// stage / route / wave helpers
/** Accepts a stage entry and returns a sim-ready stage `{ id, rows (bottom-first strings), legend, devices }`. */
export function normalizeStage(id, s) {
if (!s) return null;
if (s._norm) return s;
let rows = s.rows ?? s.grid ?? null;
if (!rows && Array.isArray(s.rowsTopDown)) rows = [...s.rowsTopDown].reverse().map((l) => String(l).replace(/^\s*\d+\s/, ''));
const devices = (s.devices ?? []).map((d) => ({
key: d.key ?? d.id,
name: d.name ?? d.key,
row: num(d.row ?? (Array.isArray(d.pos) ? d.pos[0] : d.pos?.row), -1),
col: num(d.col ?? (Array.isArray(d.pos) ? d.pos[1] : d.pos?.col), -1),
dir: d.dir ?? 'UP',
hidden: !!d.hidden,
active: d.active !== false,
role: d.role ?? null,
stats: d.stats ?? null,
raw: d,
}));
return { _norm: true, id: s.id ?? s.stageId ?? id, name: s.name ?? id, rows, legend: s.legend ?? s.tiles ?? null, devices, special: s.special ?? null, raw: s };
}
function pairOf(v) {
if (Array.isArray(v)) return [num(v[0]), num(v[1])];
if (v && typeof v === 'object') return [num(v.row ?? v.r), num(v.col ?? v.c)];
return null;
}
/** Normalise a route (research `{m,s,e,cp}` or data `{motion,start,end,checkpoints}`) into a RouteSpec. */
export function normalizeRoute(r) {
if (!r) return null;
const m = String(r.motion ?? r.m ?? 'W').toUpperCase();
const motion = m.startsWith('F') ? 'FLY' : 'WALK';
const start = pairOf(r.start ?? r.s ?? r.startPosition);
const end = pairOf(r.end ?? r.e ?? r.endPosition);
const cps = [];
if (Array.isArray(r.steps) && r.steps.length) {
for (const st of r.steps) {
const t = String(st.t ?? st.type ?? 'move').toUpperCase();
cps.push({ type: normCpType(t), pos: pairOf(st.p ?? st.pos) ?? [0, 0], time: num(st.s ?? st.time, 0) });
}
return { motion, start, end, checkpoints: cps };
}
for (const cp of r.checkpoints ?? r.cp ?? []) {
if (Array.isArray(cp) && typeof cp[0] === 'number') {
cps.push({ type: 'MOVE', pos: [num(cp[0]), num(cp[1])], time: 0 });
} else if (Array.isArray(cp)) {
const [type, a, b, t] = cp;
cps.push({ type: normCpType(type), pos: [num(a), num(b)], time: num(t, 0) });
} else if (cp && typeof cp === 'object') {
cps.push({ type: normCpType(cp.type), pos: pairOf(cp.pos ?? cp.position ?? [cp.row, cp.col]) ?? [0, 0], time: num(cp.time ?? cp.t, 0) });
}
}
return { motion, start, end, checkpoints: cps };
}
function normCpType(t) {
const s = String(t ?? 'MOVE').toUpperCase();
if (s.includes('DISAPPEAR')) return 'DISAPPEAR';
if (s.includes('APPEAR')) return 'APPEAR';
if (s.includes('WAIT')) return 'WAIT';
return 'MOVE';
}
/**
* Convert a wave template (research roundLevels entry or data/waves.json entry) into `{ routes, spawns }`.
* Research actions: `{ key, n, pre, int, route }` inside waves[].fragments[].actions (times are cumulative:
* wave.preDelay + fragment.preDelay + action.pre).
*/
export function spawnsFromTemplate(tpl, { mods = null, tag = undefined } = {}) {
if (!tpl) return { routes: [], spawns: [] };
const routes = (tpl.routes ?? []).map(normalizeRoute);
const spawns = [];
if (Array.isArray(tpl.spawns)) {
for (const s of tpl.spawns) {
if (s.action && String(s.action).toUpperCase() !== 'SPAWN') continue; // non-spawn actions are content's job
const key = s.enemyKey ?? s.key;
if (!key) continue;
const sp = {
time: num(s.time ?? s.t, 0), enemyKey: key, routeIndex: num(s.routeIndex ?? s.route, 0),
count: Math.max(1, num(s.count ?? s.n, 1)), interval: num(s.interval ?? s.int, 0),
mods: s.mods ?? mods ?? undefined, tag: s.tag ?? tag, bounty: s.bounty, sourcePlayerId: s.sourcePlayerId,
};
if (s.unharmful || sp.tag === 'part') sp.countInTotal = false;
if (s.hidden) sp.hidden = true;
if (s.group != null) sp.group = s.group;
spawns.push(sp);
}
} else {
let waveT = 0;
for (const w of tpl.waves ?? []) {
waveT += num(w.preDelay, 0);
let fragT = waveT;
let fragEnd = fragT;
for (const f of w.fragments ?? []) {
fragT += num(f.preDelay, 0);
for (const a of f.actions ?? []) {
const key = a.key ?? a.enemyKey;
if (!key || !String(key).startsWith('enemy_')) continue;
const n = Math.max(1, num(a.n ?? a.count, 1));
const t0 = fragT + num(a.pre ?? a.preDelay, 0);
const iv = num(a.int ?? a.interval, 0);
spawns.push({ time: t0, enemyKey: key, routeIndex: num(a.route ?? a.routeIndex, 0), count: n, interval: iv, mods: mods ?? undefined, tag });
fragEnd = Math.max(fragEnd, t0 + iv * (n - 1));
}
}
waveT = Math.max(waveT, fragEnd) + num(w.postDelay, 0);
}
}
return { routes, spawns, maxPlayTime: tpl.maxPlayTime ?? tpl.timeLimit ?? null, overrides: tpl.overrides ?? null, extraRoutes: (tpl.extraRoutes ?? []).map(normalizeRoute) };
}
// ---------------------------------------------------------------------------------------------------------------
// data sources
const IS_NODE = typeof process !== 'undefined' && !!(process && process.versions && process.versions.node);
/** Node default loader (./nodeData.js) or null in browsers. */
let nodeLoader = null;
/** Raw generated data (Node: data/*.json) or the data injected with setSimData (browser). */
let generatedCache = null;
/** Raw research maps (Node only; loaded lazily). */
let researchCache = null;
let injected = false;
if (IS_NODE) {
try {
nodeLoader = await import('./nodeData.js');
generatedCache = await nodeLoader.loadGenerated();
} catch {
nodeLoader = null;
}
}
function loadResearch() {
if (researchCache) return researchCache;
researchCache = nodeLoader ? nodeLoader.loadResearch() : { chess: {}, enemies: {}, tokens: {}, stages: {}, waves: {} };
return researchCache;
}
function asMap(v, idKey) {
if (!v) return null;
if (Array.isArray(v)) {
const m = {};
for (const x of v) if (x && x[idKey] != null) m[x[idKey]] = x;
return m;
}
// Allow wrapper objects like { chess: {...} } or { enemies: {...} }
return v;
}
function unwrap(v, key) {
if (v && typeof v === 'object' && !Array.isArray(v) && v[key] && typeof v[key] === 'object' && Object.keys(v).length <= 4) return v[key];
return v;
}
/**
* Deep-freeze a normalised def (chess / enemy / token). Defs are cached and shared by every unit of every battle in
* the process: a kit or content module that mutated one (`bb.x = …`, `def.rangeGrid.push(…)`) would silently change
* later battles and break determinism. Frozen, such a write throws inside the (guarded) content code instead.
* `raw` (the source record) and Sets (immunities) are left as they are.
*/
export function freezeDef(d) {
if (!d || typeof d !== 'object' || Object.isFrozen(d)) return d;
Object.freeze(d);
for (const k of Object.keys(d)) {
if (k === 'raw') continue;
const v = d[k];
if (v && typeof v === 'object' && !(v instanceof Set) && !(v instanceof Map)) freezeDef(v);
}
return d;
}
/**
* A DataSource resolves ids to normalised defs. `raw` is `{ chess, enemies, tokens, stages, waves }`.
*/
export class DataSource {
constructor(raw = {}, fallback = null) {
this.raw = {
chess: asMap(unwrap(raw.chess, 'chess'), 'chessId') ?? {},
enemies: asMap(unwrap(raw.enemies, 'enemies'), 'key') ?? {},
tokens: asMap(unwrap(raw.tokens, 'tokens'), 'tokenId') ?? {},
stages: asMap(unwrap(raw.stages, 'stages'), 'id') ?? {},
waves: asMap(unwrap(raw.waves, 'waves'), 'id') ?? {},
};
this.fallback = fallback;
this._chess = new Map();
this._enemy = new Map();
this._token = new Map();
this._stage = new Map();
}
rawChess(id) { return this.raw.chess[id] ?? this.fallback?.rawChess(id) ?? null; }
rawEnemy(key) {
return this.raw.enemies[key] ?? this.raw.enemies['enemy_' + key] ?? this.raw.enemies[String(key).replace(/^enemy_/, '')] ?? this.fallback?.rawEnemy(key) ?? null;
}
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? null; }
rawStage(id) { return this.raw.stages[id] ?? this.fallback?.rawStage(id) ?? null; }
rawWave(id) { return this.raw.waves[id] ?? this.fallback?.rawWave(id) ?? null; }
/**
* Chess def for the default loadout, or for `loadout` `{ skillIndex, moduleId }` (DESIGN §16; resolved with
* resolveLoadout — illegal choices fall back to the default). `def.loadout` = the resolved loadout (null for records
* without skill choices); `def.id` is always the chess id. Defs are cached per (id, resolved loadout).
*/
getChess(id, loadout = null) {
if (loadout == null && this._chess.has(id)) return this._chess.get(id);
const r = this.rawChess(id);
const lo = r ? resolveLoadout(r, loadout) : null;
const key = id + loadoutKey(lo);
if (this._chess.has(key)) return this._chess.get(key);
let d = null;
if (r) {
d = normalizeChess({ chessId: id, ...loadoutRecord(r, lo) });
d.loadout = lo;
freezeDef(d);
}
this._chess.set(key, d);
return d;
}
getEnemy(key) {
if (this._enemy.has(key)) return this._enemy.get(key);
const r = this.rawEnemy(key);
const k = String(key).startsWith('enemy_') ? key : 'enemy_' + key;
const d = r ? freezeDef(normalizeEnemy(k, r)) : null;
this._enemy.set(key, d);
return d;
}
/**
* Token def; `ownerChessId` selects owner-specific stats when the data has them, `ownerLoadout` (`{ skillIndex,
* moduleId }`, e.g. the owner def's `loadout`) the owner's selected skill / module: `variants[owner].bySkill[i]`
* (token skill of that slot, count, sources) and `.byModule[id]` (module token attributes, trait, talents).
* `def.sources` / `def.count` are those of the owner's loadout (a hand piece exists only when `sources` has more
* than 'display'; PlayerState / the kits' skill summons read them from here).
*/
getToken(id, ownerChessId = null, ownerLoadout = null) {
const olo = ownerChessId && ownerLoadout ? resolveLoadout(this.rawChess(ownerChessId), ownerLoadout) : null;
const ck = id + '|' + (ownerChessId ?? '') + loadoutKey(olo);
if (this._token.has(ck)) return this._token.get(ck);
let r = this.rawToken(id);
if (r && ownerChessId && !r.variants) {
// research shape: byOwner[chessId] holds the owner-specific token record
const byOwner = r.byOwner?.[ownerChessId] ?? r.statsByOwner?.[ownerChessId];
if (byOwner) r = { ...r, ...(byOwner.stats ? byOwner : { stats: byOwner }) };
}
if (!r && ownerChessId) {
// look inside the owner chess record
const owner = this.rawChess(ownerChessId);
const t = (owner?.tokens ?? []).find((x) => x && typeof x === 'object' && (x.tokenId ?? x.id) === id);
if (t) r = t;
}
let owner = ownerChessId;
if (r && r.variants && owner && !r.variants[owner]) {
// fall back to the normal/golden sibling variant, then any variant
const alt = String(owner).replace(/_b$/, '_a');
owner = r.variants[alt] ? alt : null;
}
if (r && r.variants && !owner) owner = Object.keys(r.variants)[0] ?? null;
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
if (variant && olo && !olo.isDefault) {
if (!olo.skillIsDefault && variant.bySkill && variant.bySkill[olo.skillIndex]) variant = { ...variant, ...variant.bySkill[olo.skillIndex] };
if (!olo.moduleIsDefault && variant.byModule && variant.byModule[olo.moduleId]) variant = { ...variant, ...variant.byModule[olo.moduleId] };
}
const d = r ? freezeDef(normalizeToken(id, r, owner, variant)) : null;
this._token.set(ck, d);
return d;
}
getStage(id) {
if (this._stage.has(id)) return this._stage.get(id);
const d = normalizeStage(id, this.rawStage(id));
this._stage.set(id, d);
return d;
}
getWave(id) { return this.rawWave(id); }
chessIds() {
const s = new Set(Object.keys(this.raw.chess));
if (this.fallback) for (const k of this.fallback.chessIds()) s.add(k);
return [...s];
}
stageIds() {
const s = new Set(Object.keys(this.raw.stages));
if (this.fallback) for (const k of this.fallback.stageIds()) s.add(k);
return [...s];
}
waveIds() {
const s = new Set(Object.keys(this.raw.waves));
if (this.fallback) for (const k of this.fallback.waveIds()) s.add(k);
return [...s];
}
}
let defaultSource = null;
let researchSource = null;
/** Research-only data source (Node: docs/research JSON; browsers: empty). Used as fallback and by tests. */
export function getResearchSource() {
if (!researchSource) researchSource = new DataSource(loadResearch());
return researchSource;
}
/** Whether generated (or injected) data with chess records is available. */
export function hasGeneratedData() {
const g = generatedCache;
return !!(g && g.chess && Object.keys(unwrap(g.chess, 'chess') || {}).length > 0);
}
/**
* Default data source. Node: generated data with the research fallback. Browser: the data injected with setSimData
* (no fallback: server and client battles must resolve every id identically), empty before that.
*/
export function getDefaultSource() {
if (!defaultSource) defaultSource = injected || !nodeLoader ? new DataSource(generatedCache ?? {}, null) : new DataSource(generatedCache ?? {}, getResearchSource());
return defaultSource;
}
/**
* Inject the game data (browser: the fetched /data/*.json as `{ chess, enemies, tokens, stages, waves, bonds, items,
* garrisons, bands, effects, … }`). It becomes the default source and the `getSimData()` object. Returns the new
* default DataSource.
* @param {object} data
*/
export function setSimData(data) {
generatedCache = data && typeof data === 'object' ? data : null;
injected = true;
defaultSource = null;
return getDefaultSource();
}
/** The raw data object behind the default source (injected or generated), or null. */
export function getSimData() {
return generatedCache;
}
/** Reset caches (tests / hot reload after data regeneration). Node reloads data/*.json; browsers keep the injection. */
export async function reloadDataSources() {
if (nodeLoader && !injected) generatedCache = await nodeLoader.reloadGenerated();
defaultSource = null;
}
/** Build a DataSource from an injected object (maps or a DataSource instance). */
export function toDataSource(data) {
if (!data) return getDefaultSource();
if (data instanceof DataSource) return data;
if (typeof data.getChess === 'function' && typeof data.getEnemy === 'function') return data;
return new DataSource(data, getDefaultSource());
}
/**
* Per-battle data view resolving operator loadouts from the battle's PlayerBattleInput units (DESIGN §16): `getChess(id)`
* and `getToken(id, ownerChessId)` without an explicit loadout use the loadout the inputs give that chess id (an
* operator entry without `skillIndex` / `moduleId` = the default). Everything else is the underlying source (prototype
* delegation: caches stay shared). Within one field a chess id has one loadout per player (loadouts are per base chess);
* when two players of a multi-player field give the same chess id different loadouts the first one wins for id-only
* lookups (`view.loadoutConflicts` lists them) — callers that pass the unit's own loadout (`getChess(id, { skillIndex,
* moduleId })`, `getToken(id, owner, ownerDef.loadout)`) are always exact. Returns `ds` itself when no unit carries a
* loadout, and an existing view unchanged (`isLoadoutView`). Used by spec.js createBattleFromSpec and by
* content/index.js installContent (every Battle, however constructed).
* @param {object} ds DataSource
* @param {object[]} players PlayerBattleInput[] (spec players)
*/
export function withUnitLoadouts(ds, players) {
if (!ds || typeof ds.getChess !== 'function' || isLoadoutView(ds)) return ds;
const map = new Map();
const conflicts = new Set();
let any = false;
for (const p of Array.isArray(players) ? players : []) {
for (const u of (p && Array.isArray(p.units) ? p.units : [])) {
if (!u || u.kind === 'token' || typeof u.chessId !== 'string') continue;
if (u.skillIndex != null || u.moduleId != null) any = true;
const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null };
const prev = map.get(u.chessId);
if (!prev) map.set(u.chessId, lo);
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId) conflicts.add(u.chessId);
}
}
if (!any) return ds;
const view = Object.create(ds);
view.getChess = (id, loadout = null) => ds.getChess(id, loadout ?? map.get(id) ?? null);
if (typeof ds.getToken === 'function') {
view.getToken = (id, owner = null, ownerLoadout = null) => ds.getToken(id, owner, ownerLoadout ?? (owner != null ? map.get(owner) ?? null : null));
}
view.loadoutConflicts = [...conflicts];
return view;
}
/** Whether `ds` is a per-battle loadout view (withUnitLoadouts). */
export function isLoadoutView(ds) {
return !!ds && typeof ds === 'object' && Object.prototype.hasOwnProperty.call(ds, 'loadoutConflicts');
}