// 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 * (`@`, 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'); }