Files
ark-stronghold-protocol/server/sim/simdata.js
T
sganggs ec0a2fc669 feedback7 R22-LOADOUT: the battle takes each operator's potential and 练度 from its spec entry; golden: unchanged
A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.

- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
  (shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
  a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
  the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
  per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
  ×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
  盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
  the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
  多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
  unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
  the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
  numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
  potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
  chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
2026-10-08 20:38:08 +08:00

876 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.)
// 补位 stand-ins (DATA.md §18): `getChess(id, { standIn: true })` = the def of a NORMAL chess fielded as its official
// stand-in (shared/standIn.js standInRecord over data/backups.json: the chess's ids, bonds, 特质, tier; the stand-in's
// body, skill `backup.skillIndex` and module `backup.uniEquipId` — any other skill / module choice is ignored), with
// `def.standInFor` = the replaced operator's charId and `def.charId` = the stand-in's (content/index.js looks its kit
// up by that charId). A PRESET / DIY chess, or a source without backups.json, gives the chess's own def instead.
// 自选 picks (DATA.md §18): `getChess(slotId, { diy: { charId, skillIndex, uniEquipId } })` = the def of a DIY slot (its
// `_a` / `_b` id) filled with that operator (shared/diy.js diyRecordOf: the slot's tier / price / status, no 特质, the
// pick's derived bonds, the operator's body, skill and module — a prototype's locked ones), with `def.diyFor` = the
// slot's base id, `def.charId` = the operator (its kit, content/index.js kitOf), `def.loadout.diy` = the checked pick and
// `def.tokenOwner` = the key of its summons' variants (data/backups.json `tokens`, read by getToken); null for an illegal
// pick.
// Potential (潜能 1–6, 0.2.2 — the player's 干员调配 setting; shared/potential.js): a loadout's `potential` composes the
// chess / 自选 record at it (`def.loadout.potential`; full potential 6 is the default and the data's own) and the
// summons' variants at their owner's potential (getToken / getDiyToken: talents, a 自选 summon's deploy limit / count).
// A 补位 stand-in and a prototype 自选 pick have no potential.
//
// 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';
import { atPotential, isPotential, POTENTIAL_DEFAULT, cultivateMul } from '../../shared/potential.js';
import { standInRecord } from '../../shared/standIn.js';
import { diyRecordOf, diyTokenOwner } from '../../shared/diy.js';
import { normHitArea } from './body.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;
const skillType = String(s.skillType ?? s.type ?? 'MANUAL').toUpperCase();
// Class rows (profession / subprofession, research-shaped records carry `matchedBy`) apply to every MANUAL skill of the
// class and never to an AUTO one (PRTS 卫戍协议/帮助 技能策略; tools/build-data.mjs resolveTrigger does the same for data).
if (/^(profession|subProfession)/.test(matchedBy) && skillType !== 'MANUAL') 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;
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),
// `allies` (data: an ally row, tools/build-data.mjs TRIGGER_ALLY_RULES — 黍 S3) — skills.js `trigger.allies`
trigger: typeof trig === 'object' && trig.allies === true ? { rule, grid: trigGrid, allies: true } : { 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,
// a 补位 stand-in (shared/standIn.js): the replaced operator's charId — `charId` is the stand-in's (absent otherwise)
...(rec.standInFor ? { standInFor: rec.standInFor } : null),
// a 自选 piece (shared/diy.js): the slot's base id — `charId` is the operator's (absent otherwise)
...(rec.diyFor ? { diyFor: rec.diyFor } : 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 ?? ''}${lo.potentialIsDefault === false ? `|p${lo.potential}` : ''}` : '';
}
/** 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;
}
/** data/enemies.json `attackAnim` → { dur, hit } (hit null = none: half the clip), or null when malformed / absent. */
function normAttackAnim(a) {
const dur = Number(a?.dur);
if (!(Number.isFinite(dur) && dur > 0)) return null;
const hit = Number(a.hit);
return { dur, hit: a.hit != null && Number.isFinite(hit) ? Math.min(dur, Math.max(0, hit)) : null };
}
/** 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),
hitArea: normHitArea(e.hitArea), // huge units only (body.js); null = a point
staticBody: !!e.staticBody, // 静态刚体: pushes / pulls never move it (Battle._displaceable)
attackAnim: normAttackAnim(e.attackAnim), // its attack clip { dur, hit } (ai.js attackStand); null = none known
attackMoves: !!e.attackMoves, // 「不停止移动」: never stops to attack (ai.js attackStand)
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),
// abnormal effects held from the start (tokens.json `abnormal`, PRTS summon pages): 'healFree' 禁疗, 'isolated' 孤立
abnormal: Array.isArray(t0.abnormal) ? t0.abnormal.filter((x) => typeof x === 'string') : [],
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, backups? }`
* (`backups` = data/backups.json `{ units, diy }`, the 补位 stand-ins: getChess(id, { standIn: true })).
*/
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') ?? {},
backups: raw.backups && typeof raw.backups === 'object' && raw.backups.units ? raw.backups : null,
effects: raw.effects && typeof raw.effects === 'object' ? raw.effects : null,
};
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;
}
/** A token record: data/tokens.json, else the 自选 picks' summons (data/backups.json `tokens`). */
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? this.rawBackups()?.tokens?.[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; }
/** data/backups.json (`{ units, diy }`) of this source or its fallback, or null. */
rawBackups() { return this.raw.backups ?? (typeof this.fallback?.rawBackups === 'function' ? this.fallback.rawBackups() : null); }
/** A data/effects.json record of this source, its fallback, or the default game data (Node / the injected data). */
rawEffect(id) {
const own = this.raw.effects && Object.prototype.hasOwnProperty.call(this.raw.effects, id) ? this.raw.effects[id] : null;
if (own) return own;
if (typeof this.fallback?.rawEffect === 'function') return this.fallback.rawEffect(id);
const fx = generatedCache && generatedCache.effects;
return fx && Object.prototype.hasOwnProperty.call(fx, id) ? fx[id] : null;
}
/**
* The 练度 (自持有) multipliers of tier `cultivate` (0–3): ATK / DEF / max HP from the effects.json CHAR_MAP record's
* char_attribute_mul (shared/potential.js cultivateMul), or null.
* @param {number} cultivate
*/
cultivateMul(cultivate) { return cultivateMul((id) => this.rawEffect(id), cultivate); }
/**
* 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).
* `loadout.standIn === true`: the 补位 def of the chess (getStandIn) — its skill and module are the chess's backup
* selection whatever `skillIndex` / `moduleId` say; a chess without a stand-in (PRESET, DIY, no backups data) gives
* its own def for that loadout. `loadout.diy` (`{ charId, skillIndex, uniEquipId }`): the 自选 def of a DIY slot
* (getDiy; null when the pick is not legal for that slot). `loadout.potential` (1–6): the record at that potential
* (loadoutRecord; not for a stand-in).
*/
getChess(id, loadout = null) {
if (loadout && loadout.diy && typeof loadout.diy === 'object') return this.getDiy(id, loadout.diy, loadout.potential);
if (loadout && loadout.standIn === true) {
const sd = this.getStandIn(id);
if (sd) return sd;
}
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;
}
/**
* The 补位 def of chess `id` (DATA.md §18): shared/standIn.js standInRecord of its record over rawBackups(), normalised
* — `def.id` / `baseId` / `golden` / `tier` / `bonds` / `raw.garrisonIds` of the chess, every combat field of the
* stand-in at the chess's status, `def.charId` the stand-in's, `def.standInFor` the replaced operator's charId,
* `def.loadout` the backup selection (`isDefault`, `standIn: true`). Null for a chess without a stand-in (PRESET,
* DIY) or when the data lacks it. Cached per id.
*/
getStandIn(id) {
const key = `${id}|standIn`;
if (this._chess.has(key)) return this._chess.get(key);
const r = this.rawChess(id);
const rec = r ? standInRecord(r, this.rawBackups()) : null;
let d = null;
if (rec) {
d = normalizeChess({ ...rec, chessId: id });
d.loadout = { ...resolveLoadout(rec, null), standIn: true };
freezeDef(d);
}
this._chess.set(key, d);
return d;
}
/**
* The 自选 def of DIY slot `id` (its `_a` or `_b` record) filled with `pick` (DATA.md §18): shared/diy.js diyRecordOf
* over this source (chess records, backups.json), normalised — `def.id` / `baseId` / `golden` / `tier` / `bonds` (the
* pick's derived ones) of the slot, every combat field of the operator at the slot's status with the pick's skill and
* module, `def.charId` the operator's, `def.diyFor` the slot's base id, `def.loadout` = the selection (`isDefault`) with
* `diy` = the checked pick, `def.tokenOwner` = its summons' variant key (shared/diy.js diyTokenOwner). Null for an
* illegal pick (not a pick of the slot's tier, a prototype off its locked skill, an unknown skill or module — checkDiyPick)
* or when the data lacks it. An owned pick is composed at `potential` (1–6, default 6; shared/diy.js diyRecordOf —
* `def.loadout.potential`, its summons' variants follow); a prototype has none. Cached per (id, pick, potential).
* @param {string} id
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick
* @param {number|null} [potential]
*/
getDiy(id, pick, potential = null) {
const pot = isPotential(potential) ? potential : POTENTIAL_DEFAULT;
const key = `${id}|diy|${pick?.charId ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}|p${pot}`;
if (this._chess.has(key)) return this._chess.get(key);
const r = this.rawChess(id);
const rec = r && r.isDiy ? diyRecordOf(r, pick, this, { potential: pot }) : null;
let d = null;
if (rec) {
d = normalizeChess({ ...rec, chessId: id });
const own = rec.diyProto ? POTENTIAL_DEFAULT : pot;
d.loadout = {
...resolveLoadout(rec, null), potential: own, potentialIsDefault: own === POTENTIAL_DEFAULT,
diy: { charId: rec.charId, skillIndex: rec.skill.index, uniEquipId: rec.module?.id ?? null },
};
d.tokenOwner = diyTokenOwner(rec.charId, rec.status);
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) {
if (ownerChessId && ownerLoadout && ownerLoadout.diy && typeof ownerLoadout.diy === 'object') return this.getDiyToken(id, ownerChessId, ownerLoadout.diy, ownerLoadout.potential);
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;
// the owner's potential (0.2.2): the variant's talents (and counts) at it — before the skill / module parts merge
if (variant && olo && olo.potentialIsDefault === false) variant = atPotential(variant, olo.potential);
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;
}
/**
* Token def of a summon of a 自选 piece: its record (data/backups.json `tokens`) with the variant of the owner form
* (`<charId>@<statusKey of the slot record>`, shared/diy.js diyTokenOwner) and the pick as the owner's loadout —
* `bySkill[skillIndex]` (the token skill / count / sources of that skill), `byModule[uniEquipId]` (module token
* attributes, trait, talents), as getToken does for a chess owner's loadout. A token without a variant of that form
* gets the record's defaults (`sources` null).
* @param {string} id token id
* @param {string} slotId the owner's DIY slot record id (`_a` / `_b`)
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick the owner def's `loadout.diy`
* @param {number|null} [potential] the owner's potential (its `loadout.potential`; default 6): the variant at it
*/
getDiyToken(id, slotId, pick, potential = null) {
const slot = this.rawChess(slotId);
const owner = slot && typeof pick?.charId === 'string' ? diyTokenOwner(pick.charId, slot.status) : null;
const pot = isPotential(potential) ? potential : POTENTIAL_DEFAULT;
const ck = `${id}|diy|${owner ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}|p${pot}`;
if (this._token.has(ck)) return this._token.get(ck);
const r = this.rawToken(id);
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
if (variant) variant = atPotential(variant, pot);
if (variant) {
const si = pick.skillIndex;
if (Number.isInteger(si) && variant.bySkill && variant.bySkill[si]) variant = { ...variant, ...variant.bySkill[si] };
const mid = pick.uniEquipId;
if (typeof mid === 'string' && variant.byModule && variant.byModule[mid]) variant = { ...variant, ...variant.byModule[mid] };
}
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). An entry with `standIn: true` (补位) maps its
* chess id to the stand-in def (`getChess(id, { standIn: true })`) the same way, and one with `diy` (自选: `{ charId,
* skillIndex, uniEquipId }`) to its 自选 def (`getChess(id, { diy })`); a `potential` (1–6) rides along (0.2.2).
* @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;
const diy = u.diy && typeof u.diy === 'object' ? u.diy : null;
const pot = u.standIn !== true && isPotential(u.potential) ? u.potential : null;
if (u.skillIndex != null || u.moduleId != null || u.standIn === true || diy || pot != null) any = true;
const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null };
if (u.standIn === true) lo.standIn = true;
if (diy) lo.diy = diy;
if (pot != null) lo.potential = pot;
const prev = map.get(u.chessId);
if (!prev) map.set(u.chessId, lo);
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId || !!prev.standIn !== !!lo.standIn
|| (prev.potential ?? null) !== (lo.potential ?? null)
|| JSON.stringify(prev.diy ?? null) !== JSON.stringify(lo.diy ?? null)) 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');
}