Files
ark-stronghold-protocol/tools/build-data.mjs
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

2685 lines
140 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.
#!/usr/bin/env node
// tools/build-data.mjs — DATA BUILD PIPELINE (task F1).
//
// Reads the official zh_CN client data (Kengxxiao/ArknightsGameData) plus the research JSON in
// docs/research/ and emits compact, game-ready JSON into data/:
// config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves,
// stages, bosses, tokens (every field is documented in docs/DATA.md).
//
// Usage: node tools/build-data.mjs [--refresh | --offline] [--out <dir>] [--cache <dir>]
// [--report <file>] [--quiet] [--no-research] [--force]
// --refresh re-download every official file even if cached
// --offline never download; fail when a file is missing from the cache
// --out output directory (default: <repo>/data)
// --cache official-data cache directory (default: <repo>/.cache/gamedata)
// --report build report file (default: <repo>/.cache/build-data-report.json)
// --no-research ignore docs/research (research-only fields fall back to defaults; for testing)
// --force write the output even when integrity checks fail (default: keep the old files)
// Unknown options are errors (exit code 2).
//
// Official files are cached under <repo>/.cache/gamedata/<repo path> (e.g. excel/activity_table.json)
// and downloaded from https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/
// when missing. Research files are optional inputs: when absent, research-only fields fall back to
// documented defaults and a warning is printed.
//
// Determinism: the output depends only on the input files (object keys are emitted in a stable
// order, no randomness, no timestamps). Anomalies found while joining are printed and written to
// .cache/build-data-report.json (never silently dropped); integrity errors make the exit code 1.
//
// Sections (search for "// ====="): CLI & IO · text/blackboard helpers · context loading ·
// chess · tokens · bonds · garrisons · items · bands · effects · choices · enemies · factions ·
// waves · stages · bosses · config · validation · main.
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Grid } from '../server/sim/grid.js';
// ===== CLI & IO ==================================================================================
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const RESEARCH_DIR = join(ROOT, 'docs', 'research');
const GAMEDATA_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
const SEASON = 'act2autochess';
const USAGE = 'usage: node tools/build-data.mjs [--refresh | --offline] [--out <dir>] [--cache <dir>] [--report <file>] [--quiet] [--no-research] [--force]';
/**
* Parse the command line strictly (unknown options and missing values are errors, so a typo can
* never silently write data somewhere unexpected).
* @param {string[]} argv
* @returns {{refresh:boolean, offline:boolean, quiet:boolean, noResearch:boolean, force:boolean, out:string, cache:string, report:string}}
*/
function parseArgs(argv) {
const opts = {
refresh: false, offline: false, quiet: false, noResearch: false, force: false,
out: join(ROOT, 'data'), cache: join(ROOT, '.cache', 'gamedata'), report: join(ROOT, '.cache', 'build-data-report.json'),
};
const flags = { '--refresh': 'refresh', '--offline': 'offline', '--quiet': 'quiet', '--no-research': 'noResearch', '--force': 'force' };
const dirs = { '--out': 'out', '--cache': 'cache', '--report': 'report' };
for (let i = 0; i < argv.length; i++) {
const [name, inline] = argv[i].includes('=') ? [argv[i].slice(0, argv[i].indexOf('=')), argv[i].slice(argv[i].indexOf('=') + 1)] : [argv[i], null];
if (name === '--help' || name === '-h') { console.log(USAGE); process.exit(0); }
if (flags[name] && inline === null) { opts[flags[name]] = true; continue; }
if (dirs[name]) {
const v = inline ?? argv[++i];
if (!v || v.startsWith('--')) throw new Error(`${name} needs a path argument\n${USAGE}`);
opts[dirs[name]] = resolve(v);
continue;
}
throw new Error(`unknown option ${argv[i]}\n${USAGE}`);
}
if (opts.refresh && opts.offline) throw new Error(`--refresh and --offline are mutually exclusive\n${USAGE}`);
return opts;
}
let OPTS;
try {
OPTS = parseArgs(process.argv.slice(2));
} catch (e) {
console.error(`build-data: ${e.message}`);
process.exit(2);
}
const CACHE_DIR = OPTS.cache;
const log = (...a) => { if (!OPTS.quiet) console.log(...a); };
const warnings = [];
const warned = new Set();
/** Record a non-fatal anomaly (printed at the end and written to the build report; duplicates once). */
function warn(msg) {
if (warned.has(msg)) return;
warned.add(msg);
warnings.push(msg);
}
/**
* Make sure an official gamedata file exists in the cache; download it when missing.
* @param {string} rel path relative to zh_CN/gamedata/ (e.g. 'excel/activity_table.json')
* @returns {Promise<string>} absolute path of the cached file
*/
async function ensureGamedata(rel) {
const abs = join(CACHE_DIR, rel);
if (!OPTS.refresh && existsSync(abs)) return abs;
if (OPTS.offline) {
if (existsSync(abs)) return abs;
throw new Error(`missing cached file ${rel} (offline mode)`);
}
await mkdir(dirname(abs), { recursive: true });
const url = GAMEDATA_URL + rel;
let lastErr;
for (let attempt = 1; attempt <= 4; attempt++) {
try {
log(` download ${rel}${attempt > 1 ? ` (attempt ${attempt})` : ''}`);
const res = await fetch(url, { signal: AbortSignal.timeout(180_000) });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
const text = await res.text();
JSON.parse(text); // refuse to cache a truncated / invalid file
const tmp = `${abs}.tmp-${process.pid}`;
await writeFile(tmp, text);
await rename(tmp, abs);
return abs;
} catch (e) {
lastErr = e;
if (attempt < 4) await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
if (existsSync(abs)) { warn(`download failed for ${rel}, using stale cache: ${lastErr.message}`); return abs; }
throw new Error(`cannot obtain ${rel}: ${lastErr && lastErr.message}`);
}
/** Load (and cache in memory) an official gamedata JSON file. */
const jsonCache = new Map();
async function loadGamedata(rel) {
if (jsonCache.has(rel)) return jsonCache.get(rel);
const abs = await ensureGamedata(rel);
const obj = JSON.parse(await readFile(abs, 'utf8'));
jsonCache.set(rel, obj);
return obj;
}
/** Load an optional research JSON (docs/research/<name>); returns null when absent/unreadable. */
async function loadResearch(name) {
if (OPTS.noResearch) return null;
const abs = join(RESEARCH_DIR, name);
if (!existsSync(abs)) { warn(`research file ${name} not found; research-derived fields use defaults`); return null; }
try { return JSON.parse(await readFile(abs, 'utf8')); } catch (e) { warn(`research file ${name} unreadable: ${e.message}`); return null; }
}
/** Map an official levelId ('Activities/ACT1AUTOCHESS/level_act1autochess_h07_01_S') to a template id. */
function templateIdOf(levelId) {
const m = /level_([A-Za-z0-9_]+)$/.exec(String(levelId));
if (!m) throw new Error(`bad levelId ${levelId}`);
return m[1].toLowerCase();
}
/**
* Cache-relative path of a level file. Accepts an official levelId
* ('Activities/ACT1AUTOCHESS/level_autochess_enemy_data') or a plain stage id ('act2autochess_m01').
*/
function levelPath(idOrLevelId) {
const s = String(idOrLevelId);
if (s.includes('/')) {
const dir = s.slice(0, s.lastIndexOf('/')).toLowerCase();
return `levels/${dir}/level_${templateIdOf(s)}.json`;
}
const season = s.split('_')[0];
return `levels/activities/${season}/level_${s.toLowerCase()}.json`;
}
// ===== text & blackboard helpers ================================================================
/** Replace literal "\n" escape sequences used by skill texts with real newlines. */
function unescapeNewlines(s) { return typeof s === 'string' ? s.replace(/\\n/g, '\n') : s; }
/**
* Strip official rich-text markup (<@ba.vup>…</>, <$ba.sluggish>…</>, <color=…>) but keep literal
* trigger labels such as <获得时> / <在场6名…>.
*/
function stripRich(s) {
if (typeof s !== 'string') return s ?? null;
return unescapeNewlines(s)
.replace(/<[@$#][^<>]*>/g, '')
.replace(/<\/>/g, '')
.replace(/<\/?color[^<>]*>/gi, '')
.replace(/<\/?[bi]>/gi, '');
}
/** Raw rich text with newlines normalized (markup kept). */
function richRaw(s) { return typeof s === 'string' ? unescapeNewlines(s) : s ?? null; }
/** Round away float noise; keep integers and huge values untouched. */
function cleanNum(v) {
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
if (Number.isInteger(v) || Math.abs(v) >= 1e6) return v;
return Math.round(v * 1e6) / 1e6;
}
/**
* Flatten an official blackboard list [{key,value,valueStr}] into { bb, bbStr }.
* bb[key] = numeric value; bbStr[key] = valueStr when present. Duplicate keys get a _1, _2… suffix.
*/
function flattenBB(list, ctxLabel = '') {
const bb = {};
const bbStr = {};
for (const e of Array.isArray(list) ? list : []) {
if (!e || typeof e.key !== 'string') continue;
let k = e.key;
const taken = (x) => Object.prototype.hasOwnProperty.call(bb, x) || Object.prototype.hasOwnProperty.call(bbStr, x);
if (taken(k)) {
let i = 1;
while (taken(`${e.key}_${i}`)) i++;
k = `${e.key}_${i}`;
if (ctxLabel) warn(`duplicate blackboard key ${e.key} in ${ctxLabel} (stored as ${k})`);
}
const num = cleanNum(typeof e.value === 'number' ? e.value : Number(e.value) || 0);
const hasStr = e.valueStr != null && e.valueStr !== '';
// A string-valued entry carries a meaningless 0 in "value": keep it only in bbStr.
if (!hasStr || num !== 0) bb[k] = num;
if (hasStr) bbStr[k] = String(e.valueStr);
}
return { bb, bbStr };
}
/** Format a number like the client's C#-style format strings ('0%', '0.0%', '0', '0.0'). */
function formatValue(v, fmt) {
if (typeof v !== 'number' || !Number.isFinite(v)) return String(v);
if (!fmt) return String(cleanNum(v));
const pct = fmt.endsWith('%');
const core = pct ? fmt.slice(0, -1) : fmt;
const decimals = core.includes('.') ? core.split('.')[1].length : 0;
const x = pct ? v * 100 : v;
const rounded = Math.sign(x) * Math.round(Math.abs(x) * 10 ** decimals + 1e-9) / 10 ** decimals;
return rounded.toFixed(decimals) + (pct ? '%' : '');
}
/**
* Resolve {key}, {-key}, {key:0%} placeholders against a flattened blackboard (case-insensitive).
* Unknown keys are kept verbatim and reported.
*/
function resolvePlaceholders(text, bb, bbStr = {}, label = '') {
if (typeof text !== 'string') return text ?? null;
const lower = new Map();
for (const [k, v] of Object.entries(bb || {})) lower.set(k.toLowerCase(), v);
const lowerStr = new Map();
for (const [k, v] of Object.entries(bbStr || {})) lowerStr.set(k.toLowerCase(), v);
return text.replace(/\{(-?)([^{}:]+)(?::([^{}]+))?\}/g, (m, neg, key, fmt) => {
const k = key.trim().toLowerCase();
if (lowerStr.has(k) && (!lower.has(k) || !fmt)) return lowerStr.get(k);
if (lower.has(k)) {
const v = lower.get(k);
return formatValue(neg ? -v : v, fmt);
}
if (label) warn(`unresolved placeholder ${m} in ${label}`);
return m;
});
}
/** Build the {desc, descRaw} pair for an official rich text (placeholders resolved when bb given). */
function textPair(raw, bb, bbStr, label) {
const resolved = bb ? resolvePlaceholders(richRaw(raw), bb, bbStr, label) : richRaw(raw);
return { desc: stripRich(resolved), descRaw: resolved };
}
/** Natural sort for ids like chess_char_1_10_a (numbers compared numerically). */
function naturalCmp(a, b) { return String(a).localeCompare(String(b), 'en', { numeric: true }); }
const PHASE_INDEX = { PHASE_0: 0, PHASE_1: 1, PHASE_2: 2 };
const phaseIdx = (p) => (typeof p === 'number' ? p : PHASE_INDEX[p] ?? 0);
/** Does an unlock condition hold for a given phase/level at potential rank 0? */
function unlocked(cond, phase, level, potRank = 0, reqPot = 0) {
if ((reqPot || 0) > potRank) return false;
if (!cond) return true;
const p = phaseIdx(cond.phase);
if (p < phase) return true;
if (p > phase) return false;
return (cond.level || 1) <= level;
}
/** Pick the best candidate (the last unlocked one) from an official candidate list. */
function bestCandidate(cands, phase, level) {
let best = null;
for (const c of cands || []) if (c && unlocked(c.unlockCondition, phase, level, 0, c.requiredPotentialRank)) best = c;
return best;
}
// ===== context loading ==========================================================================
/**
* Load every official table and research file into one context object.
* @returns {Promise<object>} ctx
*/
async function loadContext() {
log('loading official data…');
const [activity, charTable, skillTable, rangeTable, uniequip, battleEquip, handbook, enemyDbRaw] = await Promise.all([
loadGamedata('excel/activity_table.json'),
loadGamedata('excel/character_table.json'),
loadGamedata('excel/skill_table.json'),
loadGamedata('excel/range_table.json'),
loadGamedata('excel/uniequip_table.json'),
loadGamedata('excel/battle_equip_table.json'),
loadGamedata('excel/enemy_handbook_table.json'),
loadGamedata('levels/enemydata/enemy_database.json'),
]);
const act = activity?.activity?.AUTOCHESS_SEASON?.[SEASON];
const ac = activity?.autoChessData;
if (!act || !ac) throw new Error('activity_table has no act2autochess / autoChessData section');
// Note: summons/traps live in character_table (token_table.json only lists 4 unrelated traps).
const enemyDb = new Map();
for (const e of enemyDbRaw.enemies || []) enemyDb.set(e.Key, e.Value);
// Level files: every wave template in battleDataDict + escaped templates + stage terrains +
// the season-wide enemy override level.
const levelSrc = new Map(); // template/stage id -> official levelId or stage id
for (const rounds of Object.values(act.battleDataDict)) {
for (const entries of Object.values(rounds)) for (const e of entries) levelSrc.set(templateIdOf(e.levelId), e.levelId);
}
for (const k of ['escapedBattleTemplateMapSinglePlayer', 'escapedBattleTemplateMapMultiPlayer']) {
if (act.constData[k]) levelSrc.set(templateIdOf(act.constData[k]), act.constData[k]);
}
const templateIds = new Set(levelSrc.keys());
const stageIds = Object.keys(act.stageDatasDict).sort(naturalCmp);
for (const id of stageIds) levelSrc.set(id, id);
const enemyDataLevelId = ac.constData.enemyDataLevelId ? templateIdOf(ac.constData.enemyDataLevelId) : null;
if (enemyDataLevelId) levelSrc.set(enemyDataLevelId, ac.constData.enemyDataLevelId);
const levels = {};
// Sequential download to stay polite to GitHub; cached files load instantly.
for (const id of [...levelSrc.keys()].sort(naturalCmp)) levels[id] = await loadGamedata(levelPath(levelSrc.get(id)));
const research = {
core: await loadResearch('01-core-data.json'),
bonds: await loadResearch('02-bonds.json'),
items: await loadResearch('04-items.json'),
enemies: await loadResearch('05-enemies.json'),
maps: await loadResearch('05-maps.json'),
assets: await loadResearch('07-assets.json'),
};
return {
act, ac, charTable, skillTable, rangeTable, uniequip, battleEquip, handbook, enemyDb,
levels, templateIds: [...templateIds].sort(naturalCmp), stageIds, enemyDataLevelId, research,
};
}
// ===== shared game-object helpers ===============================================================
/** Range grid [[dRow,dCol]…] of a range_table id (facing right), or null. */
function rangeGrid(ctx, rangeId) {
if (!rangeId) return null;
const r = ctx.rangeTable[rangeId];
if (!r) { warn(`unknown rangeId ${rangeId}`); return null; }
return (r.grids || []).map((g) => [g.row, g.col]);
}
/** Interpolate character attribute keyframes at (phase, level); returns the raw float data object. */
function interpolateAttrs(char, phase, level) {
const ph = char.phases?.[phase];
if (!ph) return null;
const kfs = ph.attributesKeyFrames || [];
if (!kfs.length) return null;
const lv = Math.max(1, Math.min(level, ph.maxLevel || level));
let a = kfs[0], b = kfs[kfs.length - 1];
for (let i = 0; i < kfs.length - 1; i++) {
if (lv >= kfs[i].level && lv <= kfs[i + 1].level) { a = kfs[i]; b = kfs[i + 1]; break; }
}
const t = b.level === a.level ? 0 : (lv - a.level) / (b.level - a.level);
const out = {};
for (const [k, v0] of Object.entries(a.data)) {
const v1 = b.data[k];
out[k] = typeof v0 === 'number' && typeof v1 === 'number' ? v0 + (v1 - v0) * t : v0;
}
return out;
}
/** Map of module attribute blackboard keys → stat fields. */
const MODULE_ATTR_MAP = {
max_hp: 'maxHp', atk: 'atk', def: 'def', magic_resistance: 'res', attack_speed: 'aspd',
cost: 'cost', respawn_time: 'respawnTime', block_cnt: 'blockCnt', base_attack_time: 'bat',
max_deploy_count: 'deployLimit', max_deck_stack_cnt: 'deckStack',
};
/** Convert interpolated attributes (+ optional additive module bonus) into the compact stats object. */
function statsFrom(attrs, bonus = {}) {
if (!attrs) return null;
const r = (v) => Math.round(v);
const s = {
maxHp: r(attrs.maxHp),
atk: r(attrs.atk),
def: r(attrs.def),
res: cleanNum(attrs.magicResistance),
cost: r(attrs.cost),
blockCnt: r(attrs.blockCnt),
bat: cleanNum(attrs.baseAttackTime),
aspd: cleanNum(attrs.attackSpeed),
respawnTime: r(attrs.respawnTime),
spRecovery: cleanNum(attrs.spRecoveryPerSec),
hpRecoveryPerSec: cleanNum(attrs.hpRecoveryPerSec),
moveSpeed: cleanNum(attrs.moveSpeed),
tauntLevel: attrs.tauntLevel | 0,
massLevel: attrs.massLevel | 0,
deployLimit: attrs.maxDeployCount | 0,
deckStack: attrs.maxDeckStackCnt | 0,
};
for (const [k, v] of Object.entries(bonus)) {
const f = MODULE_ATTR_MAP[k];
if (!f) { warn(`unmapped module attribute key ${k}`); continue; }
s[f] = cleanNum((s[f] || 0) + v);
}
return s;
}
/** Immunity flags of a character/enemy attribute object. */
function immunitiesOf(attrs) {
return {
stun: !!attrs?.stunImmune, silence: !!attrs?.silenceImmune, sleep: !!attrs?.sleepImmune,
frozen: !!attrs?.frozenImmune, levitate: !!attrs?.levitateImmune,
};
}
/** Official trigger rule names → DESIGN §5.6 names. */
const TRIGGER_RENAME = { ALWAYS: 'SP_FULL', CUSTOM_RANGE_SEARCH_ENEMY: 'CUSTOM_RANGE' };
/**
* Resolve the auto-cast rule of a skill (research 03 Addendum C1): charId rows first (exact
* skillIndex, or -1 = all skills), then subProfession rows, then profession rows (skillIndex 0 =
* skill 1 only), else DEFAULT.
*/
function resolveTrigger(ctx, char, charId, skillIdx, skillId) {
const rows = Object.values(ctx.ac.skillTriggerDataList || {});
const pick =
rows.find((r) => r.charId === charId && (r.skillIndex === skillIdx || r.skillIndex === -1)) ||
rows.find((r) => !r.charId && r.subProfessionId && r.subProfessionId === char.subProfessionId && r.skillIndex === skillIdx) ||
rows.find((r) => !r.charId && !r.subProfessionId && r.profession === char.profession && r.skillIndex === skillIdx);
const rawRule = pick ? pick.skillTriggerType : 'DEFAULT';
const rule = TRIGGER_RENAME[rawRule] || rawRule;
let customRangeGrid = null;
if (rawRule === 'CUSTOM_RANGE_SEARCH_ENEMY') {
const rid = ctx.ac.skillRangeDict?.[skillId];
customRangeGrid = rangeGrid(ctx, rid);
if (!customRangeGrid) warn(`skill ${skillId}: CUSTOM_RANGE trigger without skillRangeDict entry`);
}
return { rule, rawRule, customRangeGrid };
}
/** Numeric SpType enum values found in skill_table (passive skills use 8 = ON_DEPLOY, no SP). */
const SP_TYPE_NAMES = { 1: 'INCREASE_WITH_TIME', 2: 'INCREASE_WHEN_ATTACK', 4: 'INCREASE_WHEN_TAKEN_DAMAGE', 8: 'ON_DEPLOY' };
/**
* Build a skill record (default skill of a chess or a token) at a given level.
* @returns {object|null}
*/
function buildSkill(ctx, skillId, level, trigger, label) {
if (!skillId) return null;
const s = ctx.skillTable[skillId];
if (!s) { warn(`${label}: skill ${skillId} missing from skill_table`); return null; }
const levels = Array.isArray(s.levels) ? s.levels : [];
const lv = levels[Math.max(0, Math.min(level, levels.length) - 1)];
if (!lv) { warn(`${label}: skill ${skillId} has no level ${level}`); return null; }
const { bb, bbStr } = flattenBB(lv.blackboard, `skill ${skillId}`);
// Some descriptions reference 'duration' implicitly; expose it for placeholder resolution.
const phBB = { duration: lv.duration, ...bb };
const { desc, descRaw } = textPair(lv.description, phBB, bbStr, `skill ${skillId}`);
const sp = lv.spData || {};
if (typeof sp.spType === 'number' && !SP_TYPE_NAMES[sp.spType]) warn(`skill ${skillId}: unknown numeric spType ${sp.spType}`);
return {
skillId,
iconId: s.iconId || skillId,
name: lv.name,
level: Math.max(1, Math.min(level, levels.length)),
desc, descRaw,
skillType: lv.skillType,
durationType: lv.durationType,
duration: cleanNum(lv.duration),
spType: typeof sp.spType === 'number' ? SP_TYPE_NAMES[sp.spType] || String(sp.spType) : sp.spType,
spCost: sp.spCost,
initSp: sp.initSp,
maxChargeTime: sp.maxChargeTime,
increment: cleanNum(sp.increment),
bb, bbStr,
rangeId: lv.rangeId || null,
rangeGrid: rangeGrid(ctx, lv.rangeId),
prefabId: lv.prefabId || skillId,
trigger: trigger || { rule: 'DEFAULT', rawRule: 'DEFAULT', customRangeGrid: null },
};
}
/**
* Split the parts of a module phase into the parts that apply to the operator itself and the parts
* flagged `isToken` (they upgrade the operator's summons, e.g. 伺夜's wolves, 缪尔赛思's 流形,
* 浊心斯卡蒂's 海嗣, 耀骑士临光's “耀阳”) and must never be applied to the operator.
* @param {object|null} modulePhase battle_equip_table phase (or null)
* @returns {{ op: object[], token: object[] }}
*/
function splitModuleParts(modulePhase) {
const op = [], token = [];
for (const part of modulePhase?.parts || []) (part && part.isToken ? token : op).push(part);
return { op, token };
}
/**
* Apply module trait-override parts to a trait blackboard/text (golden chess or their tokens).
* Mutates `traitBB`; returns the (possibly overridden) trait template, module text and trait range id.
* @returns {{ template: string, moduleText: string|null, rangeId: string|null }}
*/
function applyModuleTraitParts(parts, phase, level, traitBB, template, rangeId, label) {
let moduleText = null;
for (const part of parts || []) {
const mc = bestCandidate(part.overrideTraitDataBundle?.candidates, phase, level);
if (!mc) continue;
const mb = flattenBB(mc.blackboard, `${label} module trait`);
Object.assign(traitBB.bb, mb.bb);
Object.assign(traitBB.bbStr, mb.bbStr);
if (mc.overrideDescripton) template = mc.overrideDescripton;
if (mc.additionalDescription) moduleText = mc.additionalDescription;
if (mc.rangeId) rangeId = mc.rangeId;
}
return { template, moduleText, rangeId };
}
/**
* Trait record of a character at (phase, level) with the given (operator) module parts, plus the
* combat classification derived from its text.
* @returns {{ trait: object, classify: object }}
*/
function traitRecord(ctx, char, phase, level, opParts, chessId) {
const tc = bestCandidate(char.trait?.candidates, phase, level);
const traitBB = flattenBB(tc?.blackboard, `${chessId} trait`);
const traitMod = applyModuleTraitParts(opParts, phase, level, traitBB,
tc?.overrideDescripton || char.description || '', tc?.rangeId || null, chessId);
const tp = textPair(traitMod.template, traitBB.bb, traitBB.bbStr, `${chessId} trait`);
const trait = { desc: tp.desc, descRaw: tp.descRaw, bb: traitBB.bb, bbStr: traitBB.bbStr, rangeGrid: rangeGrid(ctx, traitMod.rangeId) };
if (traitMod.moduleText) {
const mp = textPair(traitMod.moduleText, traitBB.bb, traitBB.bbStr, `${chessId} module trait`);
trait.moduleDesc = mp.desc;
trait.moduleDescRaw = mp.descRaw;
}
return { trait, classify: classifyAttack(char, tp.desc) };
}
/**
* Flat stat additions of a module phase in stat-field names (ModuleRecord.attr): exactly what
* statsFrom() adds to the no-module stats (`stats[f] = cleanNum(statsBase[f] + attr[f])`).
*/
function moduleAttr(modulePhase) {
const sum = {};
for (const b of modulePhase?.attributeBlackboard || []) sum[b.key] = (sum[b.key] || 0) + b.value;
const out = {};
for (const [k, v] of Object.entries(sum)) {
const f = MODULE_ATTR_MAP[k];
if (!f) { warn(`unmapped module attribute key ${k}`); continue; }
out[f] = cleanNum(v);
}
return out;
}
/**
* Talents of a character at (phase, level), plus module talent upgrades from `moduleParts`
* (golden chess: the operator's non-token parts; tokens: the owner's `isToken` parts).
* An override of an existing talent keeps the base values that the module candidate does not
* restate (blackboard keys merged, module values win; name/text/range/token kept when absent).
* `modPhase`/`modLevel` select module candidates (tokens: the owner's phase/level).
* @returns {Array<{index:number,name:string|null,desc:string|null,descRaw:string|null,bb:object,bbStr:object,rangeGrid:any,tokenKey:string|null,hidden:boolean,fromModule:boolean}>}
*/
function buildTalents(ctx, char, phase, level, moduleParts, label, modPhase = phase, modLevel = level) {
return mergeTalentChanges(baseTalentList(ctx, char, phase, level, label),
moduleTalentChanges(ctx, moduleParts, modPhase, modLevel, label));
}
/** The character's own talents at (phase, level), unfiltered (no module). */
function baseTalentList(ctx, char, phase, level, label) {
const talents = [];
(char.talents || []).forEach((t, index) => {
const c = bestCandidate(t.candidates, phase, level);
if (!c) return;
const { bb, bbStr } = flattenBB(c.blackboard, `${label} talent ${index}`);
const { desc, descRaw } = textPair(c.description, bb, bbStr);
talents.push({
index, name: c.name || null, desc, descRaw, bb, bbStr,
rangeGrid: rangeGrid(ctx, c.rangeId), tokenKey: c.tokenKey || null,
hidden: !!c.isHideTalent, fromModule: false,
});
});
return talents;
}
/**
* Talent additions/overrides of module parts at (modPhase, modLevel), in part order, as ModuleRecord
* `talentChanges` entries: { talentIndex, name, desc, descRaw, bb, bbStr, rangeGrid, tokenKey, hidden }.
* `talentIndex` −1 = a new (usually hidden, data-only) talent.
*/
function moduleTalentChanges(ctx, moduleParts, modPhase, modLevel, label) {
const out = [];
for (const part of moduleParts || []) {
const cands = part.addOrOverrideTalentDataBundle?.candidates;
if (!cands) continue;
const c = bestCandidate(cands, modPhase, modLevel);
if (!c) continue;
const { bb, bbStr } = flattenBB(c.blackboard, `${label} module talent`);
const text = c.upgradeDescription || c.description;
const { desc, descRaw } = textPair(text, bb, bbStr);
out.push({
talentIndex: c.talentIndex, name: c.name || null, desc, descRaw, bb, bbStr,
rangeGrid: rangeGrid(ctx, c.rangeId), tokenKey: c.tokenKey || null, hidden: !!c.isHideTalent,
});
}
return out;
}
/**
* Apply module talent changes to a talent list — the ONE merge rule, mirrored exactly by
* server/sim/simdata.js composeTalents (a data test checks both agree on every golden chess).
* Override of an existing talent index: module values win; keep what the candidate does not restate
* (e.g. 浊心斯卡蒂's data-only upgrade has an empty blackboard but the talent still sends 1 海嗣: cnt
* stays). Otherwise the change is appended. Fully empty placeholder talents (no name, no text, no
* blackboard, no token) are dropped at the end.
*/
function mergeTalentChanges(base, changes) {
const talents = base.map((t) => ({ ...t }));
for (const ch of changes || []) {
const { talentIndex, ...rest } = ch;
const rec = { index: talentIndex, ...rest, fromModule: true };
const at = talentIndex >= 0 ? talents.findIndex((x) => x.index === talentIndex) : -1;
if (at >= 0) {
const old = talents[at];
talents[at] = {
...rec,
name: rec.name || old.name, desc: rec.desc ?? old.desc, descRaw: rec.descRaw ?? old.descRaw,
bb: { ...old.bb, ...rec.bb }, bbStr: { ...old.bbStr, ...rec.bbStr },
rangeGrid: rec.rangeGrid || old.rangeGrid, tokenKey: rec.tokenKey || old.tokenKey,
hidden: old.hidden && rec.hidden,
};
} else {
talents.push(rec);
}
}
return talents.filter((t) => t.name || t.desc || Object.keys(t.bb).length || t.tokenKey);
}
// ===== chess ====================================================================================
/** Melee sub-professions whose normal attack is a ranged attack (can hit FLY). */
const MELEE_RANGED_SUBPROFS = new Set(['lord', 'fortress', 'shotprotector', 'agent', 'hookmaster']);
/** Sub-professions whose normal state is "no attack" (buff aura / skill-only attackers). */
const NO_ATTACK_SUBPROFS = new Set(['bard', 'phalanx', 'librator']);
/**
* Derive combat classification of an operator from profession / sub-profession / trait text.
* Heuristic (documented in DATA.md); the sim may override per kit.
*/
function classifyAttack(char, traitText) {
const prof = char.profession;
const sub = char.subProfessionId;
const trait = traitText || '';
let dmgType;
if ((prof === 'MEDIC' && sub !== 'incantationmedic') || sub === 'bard') dmgType = 'heal';
else if (/法术伤害/.test(trait) || prof === 'CASTER') dmgType = 'arts';
else dmgType = 'phys';
let attackKind;
if (NO_ATTACK_SUBPROFS.has(sub)) attackKind = 'none';
else if (dmgType === 'heal') attackKind = 'heal';
else if (char.position === 'RANGED' || MELEE_RANGED_SUBPROFS.has(sub)) attackKind = 'ranged';
else attackKind = 'melee';
let projectile = 'none';
if (attackKind === 'heal') projectile = 'orb';
else if (attackKind === 'ranged') projectile = dmgType === 'arts' ? 'bolt' : 'arrow';
// Ranged attackers hit FLY unless the trait restricts them to ground targets (投掷手 "地面敌人").
const canHitFly = (attackKind === 'ranged' && !/地面敌人/.test(trait)) || sub === 'skywalker';
let targetPriority = null;
if (/优先攻击空中单位/.test(trait)) targetPriority = 'fly';
else if (/防御力最低/.test(trait)) targetPriority = 'lowestDef';
return { dmgType, attackKind, projectile, canHitFly, targetPriority };
}
/** E2 art exists for this char? (research 07 knows; else assume when the char has an E2 phase) */
function hasE2Art(ctx, charId, kind) {
const a = ctx.research.assets?.operators?.[charId]?.[kind];
if (a) return !!a.e2;
return (ctx.charTable[charId]?.phases?.length || 0) >= 3;
}
/**
* Build data/chess.json: every chess (normal + golden) of the season, keyed by chessId, with its loadout
* choices (DESIGN §16: `skills[]`; golden `modules[]` + `statsBase`/`traitBase`/`talentsBase`).
* `tokenOwners` entries carry `skillAlts` ({index, count, sources} per non-default skill) and `moduleAlts`
* ({id, modulePhase, moduleTokenParts} per non-default module + 'none') for the token variants.
* @returns {{ chess: object, tokenOwners: Map<string, Array<{chessId:string, charId:string, phase:number, level:number, skillIndex:number, skillLevel:number, count:number|null, golden:boolean, modulePhase:any, skillAlts:object[], moduleAlts:object[]}>> }}
*/
function buildChess(ctx) {
const { act, charTable, uniequip, battleEquip } = ctx;
const out = {};
const tokenOwners = new Map();
const diyIds = new Set(Object.keys(act.diyChessDict || {}));
const priceTable = act.shopCharChessInfoData;
for (const chessId of Object.keys(act.charChessDataDict).sort(naturalCmp)) {
const cd = act.charChessDataDict[chessId];
const baseId = act.chessNormalIdLookupDict[chessId] || chessId;
const shop = act.charShopChessDatas[baseId];
if (!shop) { warn(`chess ${chessId}: no charShopChessDatas entry for ${baseId}`); continue; }
const isGolden = !!cd.isGolden;
const tier = shop.chessLevel;
const status = cd.status || {};
const phase = phaseIdx(status.evolvePhase);
const level = status.charLevel || 1;
const priceRow = (priceTable[String(tier)] || []).find((p) => !!p.isGolden === isGolden) || {};
const isDiy = shop.chessType === 'DIY' || diyIds.has(baseId);
const rec = {
chessId, baseId, goldenId: shop.goldenChessId, isGolden, tier,
identifier: cd.identifier,
isHidden: !!shop.isHidden, isDiy, visible: !shop.isHidden && !isDiy,
chessType: shop.chessType,
shopSortId: shop.shopLevelSortId,
charId: shop.charId || null,
name: null, appellation: null, rarity: null, profession: null, subProfessionId: null, subProfessionName: null,
position: null, nationId: null,
bonds: [...(cd.bondIds || [])],
garrisonIds: [...(cd.garrisonIds || [])],
price: priceRow.purchasePrice ?? null,
sellPrice: priceRow.chessSoldPrice ?? null,
upgradeNum: cd.upgradeNum,
upgradeChessId: cd.upgradeChessId || null,
status: { phase, level, skillLevel: status.skillLevel, equipLevel: status.equipLevel || 0 },
stats: null, immunities: null, rangeId: null, rangeGrid: null,
dmgType: null, attackKind: null, projectile: null, canHitFly: false, targetPriority: null,
trait: null, skill: null, talents: [], tokens: [], module: null,
assets: null,
};
if (isDiy) {
rec.name = '甄选干员';
rec.diyRequirement = act.diyChessDict?.[baseId] || null;
out[chessId] = rec;
continue;
}
const char = charTable[shop.charId];
if (!char) { warn(`chess ${chessId}: char ${shop.charId} missing from character_table`); out[chessId] = rec; continue; }
rec.name = char.name;
rec.appellation = char.appellation;
rec.rarity = Number(String(char.rarity).replace('TIER_', '')) || null;
rec.profession = char.profession;
rec.subProfessionId = char.subProfessionId;
rec.subProfessionName = uniequip.subProfDict?.[char.subProfessionId]?.subProfessionName || null;
rec.position = char.position;
rec.nationId = char.nationId || null;
// Module (only active on golden chess: equipLevel > 0).
const modId = shop.defaultUniEquipId || null;
const equipLevel = status.equipLevel || 0;
let modulePhase = null;
if (modId) {
const meta = uniequip.equipDict?.[modId];
const be = battleEquip[modId];
if (equipLevel > 0) {
modulePhase = be?.phases?.find((p) => p.equipLevel === equipLevel) || null;
if (!modulePhase) warn(`chess ${chessId}: module ${modId} has no level ${equipLevel}`);
}
rec.module = {
id: modId, name: meta?.uniEquipName || null,
type: meta ? `${meta.typeName1 || ''}${meta.typeName2 ? '-' + meta.typeName2 : ''}` : null,
level: equipLevel, active: equipLevel > 0 && !!modulePhase,
};
} else if (equipLevel > 0) {
rec.module = { id: null, name: null, type: null, level: equipLevel, active: false };
}
// Module parts flagged isToken upgrade the summons, not the operator (see splitModuleParts).
const moduleParts = splitModuleParts(modulePhase);
// Stats (+ module attribute bonus on golden).
const attrs = interpolateAttrs(char, phase, level);
if (!attrs) warn(`chess ${chessId}: cannot interpolate attributes`);
const bonus = {};
for (const b of modulePhase?.attributeBlackboard || []) bonus[b.key] = (bonus[b.key] || 0) + b.value;
rec.stats = statsFrom(attrs, bonus);
if (rec.stats) rec.immunities = immunitiesOf(attrs);
// Trait (character trait candidate + module trait override on golden).
// Trait-effect range (e.g. 散射手 front row, 傀儡师 substitute area) — NOT the attack range.
const rangeId = char.phases?.[phase]?.rangeId || null;
const traitDefault = traitRecord(ctx, char, phase, level, moduleParts.op, chessId);
rec.trait = traitDefault.trait;
rec.rangeId = rangeId;
rec.rangeGrid = rangeGrid(ctx, rangeId);
Object.assign(rec, traitDefault.classify);
// Every skill unlocked at the chess status (DESIGN §16), at the chess skill level; the trigger is
// resolved per skill index (official rows with skillIndex 0 apply to skill 1 only, -1 = all).
const sIdx = shop.defaultSkillIndex ?? 0;
const sEntry = char.skills?.[sIdx];
const skillLevel = status.skillLevel || 1;
const skillRecs = [];
(char.skills || []).forEach((se, i) => {
if (!se?.skillId || (i !== sIdx && !unlocked(se.unlockCond, phase, level))) return;
const trig = resolveTrigger(ctx, char, shop.charId, i, se.skillId);
const s = buildSkill(ctx, se.skillId, skillLevel, trig, `chess ${chessId}`);
if (!s) return;
s.index = i;
s.overrideTokenKey = se.overrideTokenKey || null;
skillRecs.push(s);
});
// Default skill (unchanged shape: no isDefault flag).
if (!sEntry?.skillId) warn(`chess ${chessId}: default skill index ${sIdx} not found`);
else {
const d = skillRecs.find((s) => s.index === sIdx);
rec.skill = d ? { ...d } : null;
}
rec.skills = skillRecs.map((s) => ({ ...s, isDefault: s.index === sIdx }));
// Talents (module token parts excluded: they belong to the summons).
const talentList = baseTalentList(ctx, char, phase, level, `chess ${chessId}`);
rec.talents = mergeTalentChanges(talentList, moduleTalentChanges(ctx, moduleParts.op, phase, level, `chess ${chessId}`));
// Selectable modules of the golden chess (DESIGN §16): every ADVANCED uniequip of the character
// (INITIAL = "no module") at the chess equipLevel, plus the no-module base the choices apply to.
const moduleAlts = [];
if (isGolden && equipLevel > 0) {
rec.statsBase = statsFrom(attrs, {});
rec.traitBase = traitRecord(ctx, char, phase, level, [], chessId).trait;
rec.talentsBase = mergeTalentChanges(talentList, []);
rec.modules = [];
for (const id of uniequip.charEquip?.[shop.charId] || []) {
const meta = uniequip.equipDict?.[id];
if (!meta || meta.type === 'INITIAL') continue;
const ph = battleEquip[id]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
if (!ph) { warn(`chess ${chessId}: module ${id} has no level ${equipLevel} (not selectable)`); continue; }
const parts = splitModuleParts(ph);
const hasTraitPart = parts.op.some((pt) => bestCandidate(pt.overrideTraitDataBundle?.candidates, phase, level));
const tr = hasTraitPart ? traitRecord(ctx, char, phase, level, parts.op, chessId) : null;
if (tr && JSON.stringify(tr.classify) !== JSON.stringify(traitDefault.classify)) {
warn(`chess ${chessId}: module ${id} changes the combat classification (not applied by loadouts)`);
}
rec.modules.push({
uniEquipId: id, name: meta.uniEquipName || null,
typeName: `${meta.typeName1 || ''}${meta.typeName2 ? '-' + meta.typeName2 : ''}`,
typeIcon: meta.typeIcon || null, icon: meta.uniEquipIcon || id,
isDefault: id === modId, level: equipLevel,
attr: moduleAttr(ph),
traitOverride: tr ? tr.trait : null,
talentChanges: moduleTalentChanges(ctx, parts.op, phase, level, `chess ${chessId}`),
});
if (id !== modId) moduleAlts.push({ id, modulePhase: ph, moduleTokenParts: parts.token });
}
if (modId) moduleAlts.push({ id: 'none', modulePhase: null, moduleTokenParts: [] });
}
// Tokens / summons: displayTokenDict + default-skill overrideTokenKey + talent tokenKey.
// A talent tokenKey missing from character_table is a container id (凛御银灰's
// token_10057_svash2_eagle): the real token is the one the default skill overrides it with.
const skillToken = sEntry?.overrideTokenKey || null;
for (const t of [...rec.talents, ...(rec.talentsBase || [])]) {
if (t.tokenKey && !charTable[t.tokenKey] && skillToken && charTable[skillToken]) {
t.containerTokenKey = t.tokenKey;
t.tokenKey = skillToken;
}
}
/** token id → { sources: Set('display'|'skill'|'talent'), count } for a selected skill record. */
const tokenUse = (skillRec) => {
const skTok = skillRec?.overrideTokenKey || null;
const use = new Map();
const add = (id, src) => { if (!use.has(id)) use.set(id, new Set()); use.get(id).add(src); };
for (const id of Object.keys(char.displayTokenDict || {})) add(id, 'display');
if (skTok) add(skTok, 'skill');
for (const t of rec.talents) {
const key = t.containerTokenKey ? (skTok && charTable[skTok] ? skTok : t.tokenKey) : t.tokenKey;
if (key) add(key, 'talent');
}
const count = (id) => {
const tal = rec.talents.find((t) => (t.containerTokenKey ? (skTok && charTable[skTok] ? skTok : t.tokenKey) : t.tokenKey) === id && typeof t.bb.cnt === 'number');
const skillCnt = skTok === id ? skillRec?.bb?.cnt : undefined;
return tal ? tal.bb.cnt : typeof skillCnt === 'number' ? skillCnt : null;
};
return { use, count };
};
const defUse = tokenUse(rec.skill);
const sources = defUse.use;
const resolvable = [...sources.keys()].filter((id) => {
if (charTable[id]) return true;
// A container id already remapped onto the skill token (see above) is expected; others are anomalies.
if (!rec.talents.some((t) => t.containerTokenKey === id)) warn(`chess ${chessId}: token ${id} not in character_table (skipped)`);
return false;
}).sort(naturalCmp);
rec.tokens = resolvable;
const altSkills = rec.skills.filter((s) => s.index !== sIdx);
for (const s of altSkills) {
for (const id of tokenUse(s).use.keys()) {
if (charTable[id] && !resolvable.includes(id)) warn(`chess ${chessId}: token ${id} of skill ${s.skillId} is not listed by the character (loadouts cannot summon it)`);
}
}
for (const tokenId of resolvable) {
const src = sources.get(tokenId);
// Per selectable non-default skill: the token skill slot, count and sources change with it.
const skillAlts = altSkills.map((s) => {
const u = tokenUse(s);
return { index: s.index, count: u.count(tokenId), sources: ['talent', 'skill', 'display'].filter((x) => u.use.get(tokenId)?.has(x)) };
});
if (!tokenOwners.has(tokenId)) tokenOwners.set(tokenId, []);
tokenOwners.get(tokenId).push({
chessId, charId: shop.charId, phase, level, skillIndex: sIdx, skillLevel,
count: defUse.count(tokenId), golden: isGolden, modulePhase, moduleTokenParts: moduleParts.token,
// 'display' only = listed by the character but not produced by this chess's default skill or talents.
sources: ['talent', 'skill', 'display'].filter((s) => src.has(s)),
skillAlts, moduleAlts,
});
}
// Asset ids (URLs are resolved by fetch-assets / data/assets.json).
const e2Avatar = isGolden && hasE2Art(ctx, shop.charId, 'avatar');
const e2Portrait = isGolden && hasE2Art(ctx, shop.charId, 'portrait');
rec.assets = {
avatar: e2Avatar ? `${shop.charId}_2` : shop.charId,
portrait: `${shop.charId}_${e2Portrait ? 2 : 1}`,
spine: shop.charId,
skillIcon: rec.skill?.iconId || null,
subProfIcon: `sub_${char.subProfessionId}_icon`,
};
out[chessId] = rec;
}
// Integrity: golden ids resolve both ways.
for (const rec of Object.values(out)) {
if (!out[rec.baseId]) warn(`chess ${rec.chessId}: baseId ${rec.baseId} missing`);
if (rec.goldenId && !out[rec.goldenId]) warn(`chess ${rec.chessId}: goldenId ${rec.goldenId} missing`);
}
return { chess: out, tokenOwners };
}
// ===== tokens ===================================================================================
/** Classify a token / map character (heal tokens have no MEDIC profession). */
function classifyToken(char, traitText) {
const c = classifyAttack(char, traitText);
if (char.profession === 'TOKEN' || char.profession === 'TRAP') {
const t = traitText || '';
if (/恢复[^。,]*生命/.test(t) && !/攻击造成/.test(t)) {
return { ...c, dmgType: 'heal', attackKind: 'heal', projectile: 'orb', canHitFly: false };
}
if (char.position === 'ALL' && c.attackKind === 'melee') {
return { ...c, attackKind: 'ranged', projectile: c.dmgType === 'arts' ? 'bolt' : 'arrow', canHitFly: true };
}
}
return c;
}
/**
* Build one owner-specific variant of a token / map character at (phase, level, skill level).
* @returns {object}
*/
function tokenVariant(ctx, tokenId, char, { phase, level, skillIndex, skillLevel, modulePhase, moduleTokenParts, label }) {
const ph = Math.min(phase, (char.phases?.length || 1) - 1);
const lv = Math.min(level, char.phases?.[ph]?.maxLevel || level);
const bonus = {};
const tokBonus = modulePhase?.tokenAttributeBlackboard?.[tokenId];
for (const b of Array.isArray(tokBonus) ? tokBonus : []) bonus[b.key] = (bonus[b.key] || 0) + b.value;
const attrs = interpolateAttrs(char, ph, lv);
const tc = bestCandidate(char.trait?.candidates, ph, lv);
const tbb = flattenBB(tc?.blackboard);
// The owner's module parts flagged isToken upgrade the token's trait/talents. Their unlock
// conditions refer to the owner's phase/level (the token's own level may be clamped lower).
const ownerPhase = phase, ownerLevel = level;
const traitMod = applyModuleTraitParts(moduleTokenParts, ownerPhase, ownerLevel, tbb,
tc?.overrideDescripton || char.description || '', null, label);
const tp = textPair(traitMod.template, tbb.bb, tbb.bbStr, `${label} trait`);
const moduleTrait = traitMod.moduleText ? textPair(traitMod.moduleText, tbb.bb, tbb.bbStr, `${label} module trait`) : null;
// Token skill: same index as the owner's skill when present, else the first defined one.
const skills = char.skills || [];
let sIdx = skills[skillIndex]?.skillId ? skillIndex : skills.findIndex((x) => x && x.skillId);
const sId = sIdx >= 0 ? skills[sIdx].skillId : null;
const trig = sId ? resolveTrigger(ctx, char, tokenId, sIdx, sId) : null;
const skill = sId ? buildSkill(ctx, sId, skillLevel, trig, label) : null;
if (skill) skill.index = sIdx;
return {
phase: ph, level: lv,
stats: statsFrom(attrs, bonus),
immunities: immunitiesOf(attrs),
rangeGrid: rangeGrid(ctx, char.phases?.[ph]?.rangeId),
trait: {
desc: tp.desc, descRaw: tp.descRaw, bb: tbb.bb, bbStr: tbb.bbStr,
...(moduleTrait ? { moduleDesc: moduleTrait.desc, moduleDescRaw: moduleTrait.descRaw } : {}),
},
...classifyToken(char, tp.desc),
skill,
talents: buildTalents(ctx, char, ph, lv, moduleTokenParts, label, ownerPhase, ownerLevel),
};
}
/** Convert an enemy_database record (already merged) into ally-token stats (used for 炎佑). */
function enemyAsTokenStats(e) {
return {
maxHp: e.stats.maxHp, atk: e.stats.atk, def: e.stats.def, res: e.stats.res, cost: 0, blockCnt: 0,
bat: e.stats.bat, aspd: e.stats.aspd, respawnTime: 0, spRecovery: 0, hpRecoveryPerSec: e.stats.hpRecoveryPerSec,
// deployLimit 2: 【炎】9 summons two 炎佑 (bonds.yanShip); 6 summons one.
moveSpeed: e.stats.moveSpeed, tauntLevel: e.stats.tauntLevel, massLevel: e.stats.massLevel, deployLimit: 2, deckStack: 0,
rangeRadius: e.stats.rangeRadius,
};
}
/**
* Build data/tokens.json: summons of chess (per-owner variants), bond summons (炎佑) and band map
* characters (band_amedic 预备干员-医疗 / Touch).
*/
function buildTokens(ctx, chess, tokenOwners, enemies) {
const { charTable, ac } = ctx;
const out = {};
const displayType = (id) => ac.shopStateTokenDict?.[id]?.tokenDisplayType || null;
for (const tokenId of [...tokenOwners.keys()].sort(naturalCmp)) {
const char = charTable[tokenId];
const owners = tokenOwners.get(tokenId);
const variants = {};
for (const o of owners) {
const label = `token ${tokenId}@${o.chessId}`;
const v = variants[o.chessId] = {
...tokenVariant(ctx, tokenId, char, { ...o, label }),
count: o.count,
sources: o.sources,
};
// Owner loadouts (DESIGN §16): a non-default owner skill selects the token skill of the same
// slot (and may change how many are sent / whether the chess produces it at all); a non-default
// owner module (or none) changes the module token attributes / isToken trait & talent parts.
if (o.skillAlts?.length) {
v.bySkill = {};
for (const alt of o.skillAlts) {
const tv = tokenVariant(ctx, tokenId, char, { ...o, skillIndex: alt.index, label });
v.bySkill[alt.index] = { skill: tv.skill, count: alt.count, sources: alt.sources };
}
}
if (o.moduleAlts?.length) {
v.byModule = {};
for (const alt of o.moduleAlts) {
const tv = tokenVariant(ctx, tokenId, char, { ...o, modulePhase: alt.modulePhase, moduleTokenParts: alt.moduleTokenParts, label });
v.byModule[alt.id] = { stats: tv.stats, immunities: tv.immunities, trait: tv.trait, talents: tv.talents };
}
}
}
const first = variants[owners[0].chessId];
out[tokenId] = {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType: displayType(tokenId), placeable: displayType(tokenId) === 'DEFAULT',
owners: owners.map((o) => o.chessId),
// Defaults = first owner's variant; per-owner data in variants[chessId].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
projectile: first.projectile, canHitFly: first.canHitFly,
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
variants,
assets: { avatar: tokenId, spine: tokenId },
};
}
// 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template.
const loon = enemies['enemy_9012_acloon'];
if (loon) {
out['enemy_9012_acloon'] = {
tokenId: 'enemy_9012_acloon', kind: 'bondSummon', bondId: 'yanShip', name: loon.name, appellation: null,
desc: '【炎】6名成员激活时召唤的友方单位;攻击力/生命值在开战时替换为【炎】干员总和的30%(见 bonds.json yanShip)',
descRaw: null, profession: 'TOKEN', subProfessionId: null, position: 'NONE', motion: loon.stats.motion,
displayType: null, placeable: false, owners: [],
stats: enemyAsTokenStats(loon), rangeGrid: null, dmgType: loon.stats.dmgType, attackKind: 'ranged',
projectile: 'bolt', canHitFly: true, skill: loon.skills?.[0] ? { skillId: loon.skills[0].prefabKey, bb: loon.skills[0].bb } : null,
skills: loon.skills, talents: loon.talents, deployLimit: 2, count: 1, variants: {},
assets: { avatar: loon.iconId, spine: loon.spine, isEnemyModel: true },
};
} else warn('炎佑 enemy_9012_acloon missing from enemies');
// Band map characters (auto_chess_change_map in aceffect_band_61): read from any stage predefine.
const mapChars = new Map();
for (const stageId of ctx.stageIds) {
for (const ci of ctx.levels[stageId]?.predefines?.characterInsts || []) {
const key = ci.inst?.characterKey;
if (!key) continue;
if (!mapChars.has(key)) mapChars.set(key, { inst: ci, positions: [] });
const m = mapChars.get(key);
if (!m.positions.some((p) => p.alias === ci.alias)) {
m.positions.push({ alias: ci.alias, pos: [ci.position.row, ci.position.col], dir: ci.direction, multiOnly: /multi_only/.test(ci.alias || '') });
}
}
}
for (const [charId, { inst, positions }] of [...mapChars].sort((a, b) => naturalCmp(a[0], b[0]))) {
const char = charTable[charId];
if (!char) { warn(`map character ${charId} missing from character_table`); continue; }
const v = tokenVariant(ctx, charId, char, {
phase: phaseIdx(inst.inst.phase), level: inst.inst.level || 1, skillIndex: inst.skillIndex ?? 0,
skillLevel: inst.mainSkillLvl || 1, modulePhase: null, label: `mapChar ${charId}`,
});
out[charId] = {
tokenId: charId, kind: 'mapChar', name: char.name, appellation: char.appellation || null,
desc: v.trait.desc, descRaw: v.trait.descRaw, profession: char.profession, subProfessionId: char.subProfessionId,
position: char.position, displayType: null, placeable: false, owners: [],
stats: v.stats, rangeGrid: v.rangeGrid, dmgType: v.dmgType, attackKind: v.attackKind, projectile: v.projectile,
canHitFly: v.canHitFly, skill: v.skill, talents: v.talents, trait: v.trait, phase: v.phase, level: v.level,
deployLimit: 1, count: 1, positions: positions.sort((a, b) => naturalCmp(a.alias, b.alias)), variants: {},
assets: { avatar: charId, spine: charId },
source: 'band_amedic (aceffect_band_61 auto_chess_change_map)',
};
}
return out;
}
// ===== effects (shared by bonds / items / bands / choices) =======================================
/** Buff list of an effect: [{ key, countType, bb, bbStr }]. */
function effectBuffs(ctx, effectId) {
return (ctx.act.effectBuffInfoDataDict?.[effectId] || []).map((b, i) => {
const { bb, bbStr } = flattenBB(b.blackboard, `effect ${effectId} buff ${i}`);
return { key: b.key, countType: b.countType || 'NONE', bb, bbStr };
});
}
/** Flattened params of all buffs of an effect (first occurrence wins; string values included). */
function effectParams(buffs) {
const params = {};
for (const b of buffs) {
for (const [k, v] of Object.entries({ ...b.bb, ...b.bbStr })) if (!(k in params)) params[k] = v;
}
return params;
}
/**
* Build data/effects.json: every effect of the season (effectInfoDataDict + effectBuffInfoDataDict):
* bands, bonds, equipment, 机变 choices, enemy modifiers, char-map entries.
*/
function buildEffects(ctx) {
const out = {};
for (const effectId of Object.keys(ctx.act.effectInfoDataDict).sort(naturalCmp)) {
const e = ctx.act.effectInfoDataDict[effectId];
const buffs = effectBuffs(ctx, effectId);
out[effectId] = {
effectId, effectType: e.effectType, name: e.effectName || null,
desc: stripRich(e.effectDesc), descRaw: richRaw(e.effectDesc),
counterType: e.effectCounterType || 'NONE', continuedRound: e.continuedRound ?? -1,
decoIconId: e.effectDecoIconId || null, enemyPrice: e.enemyPrice || 0,
buffs, params: effectParams(buffs),
};
}
for (const id of Object.keys(ctx.act.effectBuffInfoDataDict)) {
if (!out[id]) warn(`effectBuffInfoDataDict has ${id} without effectInfoDataDict entry`);
}
return out;
}
// ===== bonds ====================================================================================
/** Numeric member-count tier keys found in bond blackboards. */
const BOND_COUNT_KEYS = ['power_bond_char_cnt', 'ex_bond_char_cnt', 'power_char_cnt', 'ex_char_cnt'];
/**
* Build data/bonds.json: the 23 bonds with thresholds, counting mode, effect blackboards and the
* research implementer spec.
*/
function buildBonds(ctx, chess, effects) {
const { act, ac } = ctx;
const researchBonds = new Map((ctx.research.bonds?.bonds || []).map((b) => [b.bondId, b]));
const out = {};
const ids = Object.keys(act.bondInfoDict);
ids.sort((a, b) => (act.bondInfoDict[a].identifier ?? 0) - (act.bondInfoDict[b].identifier ?? 0) || naturalCmp(a, b));
for (const bondId of ids) {
const b = act.bondInfoDict[bondId];
const g = ac.bondInfoDict?.[bondId] || {};
const eff = effects[b.effectId];
if (!eff) warn(`bond ${bondId}: effect ${b.effectId} missing`);
const buffs = eff ? eff.buffs : [];
const bb = {};
for (const x of buffs) for (const [k, v] of Object.entries(x.bb)) if (!(k in bb)) bb[k] = v;
const bbStr = {};
for (const x of buffs) for (const [k, v] of Object.entries(x.bbStr)) if (!(k in bbStr)) bbStr[k] = v;
// Member-count thresholds: activeParamList + tier keys + "<在场N名…>" in the description.
const counts = new Set();
const params = (b.activeParamList || []).map(Number).filter((n) => Number.isFinite(n) && n > 0);
const template = b.activeConditionTemplate;
let maxCount = null;
if (template === 'count_threshold_downward') {
if (params.length >= 1) counts.add(params[0]);
if (params.length >= 2) maxCount = params[1] - 1;
} else {
for (const n of params) counts.add(n);
for (const x of buffs) {
for (const k of BOND_COUNT_KEYS) if (typeof x.bb[k] === 'number' && x.bb[k] > 0) counts.add(x.bb[k]);
if (x.key === 'bond_activated_add_layer' && typeof x.bb.count === 'number' && x.bb.count > 0) counts.add(x.bb.count);
}
for (const m of String(b.desc || '').matchAll(/在场<@[^>]*>(\d+)<\/>名/g)) counts.add(Number(m[1]));
for (const m of String(b.desc || '').matchAll(/在场(\d+)名/g)) counts.add(Number(m[1]));
}
const thresholds = [...counts].sort((x, y) => x - y);
if (!thresholds.length) warn(`bond ${bondId}: no thresholds derived`);
// Layer milestones (independent of member count).
const layerMilestones = [];
for (const x of buffs) {
const lb = x.bb;
if (typeof lb.power_bond_stack_cnt === 'number' && lb.power_bond_stack_cnt > 0) layerMilestones.push({ layer: lb.power_bond_stack_cnt, mode: 'reach', effect: x.key });
if (x.key === 'bond_layer_added_reward_equip' || x.key === 'bond_layer_gain_coin') layerMilestones.push({ layer: lb.layer, mode: 'every', effect: x.key });
if (x.key === 'bond_multi_layer_char_goods_price_bond_discount') {
layerMilestones.push({ layer: lb.layer1, mode: 'first', effect: x.key });
layerMilestones.push({ layer: lb.layer2, mode: 'first', effect: x.key });
}
if (x.key === 'bond_layer_char_garrison_bonus' && lb.layer > 0) layerMilestones.push({ layer: lb.layer, mode: 'reach', effect: x.key });
}
const members = (b.chessIdList || []).filter((id) => chess[id] && !chess[id].isGolden).sort(naturalCmp);
for (const id of b.chessIdList || []) if (!chess[id]) warn(`bond ${bondId}: member ${id} missing from chess`);
const rb = researchBonds.get(bondId);
const dp = textPair(b.desc);
out[bondId] = {
bondId, name: b.name, identifier: b.identifier,
isCore: !!g.isPower, bondType: g.bondType || null, bondOrder: g.bondOrder ?? null,
powerIdList: g.powerIdList || [], iconId: b.iconId || g.icon || null,
activeCount: b.activeCount, thresholds, maxCount,
thresholdTemplate: template, countMode: b.activeCondition,
countsHand: b.activeCondition === 'BOARD_AND_DECK', countsGoldenOnly: template === 'count_threshold_upward_golden',
activeType: b.activeType, isActiveInDeck: !!b.isActiveInDeck,
noStack: !!b.noStack, weight: b.weight, maxInactiveBondCount: b.maxInactiveBondCount,
layerMilestones,
desc: dp.desc, descRaw: dp.descRaw,
effectId: b.effectId, effectName: eff?.name || null, effectDesc: eff?.desc || null, effectDescRaw: eff?.descRaw || null,
// Positional {i:fmt} placeholders of effectDesc = bb[baseParams[i]] + bb[perStackParams[i]] * layers.
effectDescParams: [...String(eff?.descRaw || '').matchAll(/\{(\d+)(?::([^{}]+))?\}/g)].map((m) => ({
index: Number(m[1]), format: m[2] || null,
base: (b.descParamBaseList || [])[Number(m[1])] || null, perStack: (b.descParamPerStackList || [])[Number(m[1])] || null,
})),
bb, bbStr, buffs: buffs.map((x) => ({ key: x.key, bb: x.bb, bbStr: x.bbStr })),
baseParams: b.descParamBaseList || [], perStackParams: b.descParamPerStackList || [],
members, visibleMembers: members.filter((id) => chess[id].visible),
spec: rb?.spec || null,
};
}
return out;
}
// ===== garrisons (特质) ===========================================================================
/**
* Build data/garrisons.json: every garrison referenced by season chess (plus garrisons referenced
* from other garrisons' blackboards, transitively).
*/
function buildGarrisons(ctx, chess) {
const dict = ctx.act.garrisonDataDict;
const wanted = new Set();
for (const c of Object.values(chess)) for (const g of c.garrisonIds) wanted.add(g);
const queue = [...wanted];
while (queue.length) {
const id = queue.pop();
const g = dict[id];
if (!g) continue;
for (const e of g.blackboard || []) {
if (typeof e.valueStr !== 'string') continue;
for (const m of e.valueStr.matchAll(/garrison_\d+_[ab]/g)) {
if (!wanted.has(m[0]) && dict[m[0]]) { wanted.add(m[0]); queue.push(m[0]); }
}
}
}
const out = {};
for (const id of [...wanted].sort(naturalCmp)) {
const g = dict[id];
if (!g) { warn(`garrison ${id} referenced but missing from garrisonDataDict`); continue; }
const { bb, bbStr } = flattenBB(g.blackboard, `garrison ${id}`);
const raw = g.garrisonDesc || g.description;
const dp = textPair(raw);
out[id] = {
garrisonId: id, desc: dp.desc, descRaw: dp.descRaw,
eventType: g.eventType, eventTypeDesc: g.eventTypeDesc || null, eventTypeIcon: g.eventTypeIcon || null,
effectType: g.effectType, effectKey: bbStr.key || g.effectType, battleRuneKey: g.battleRuneKey || null,
charLevel: g.charLevel || 0, bb, bbStr,
owners: Object.values(chess).filter((c) => c.garrisonIds.includes(id)).map((c) => c.chessId).sort(naturalCmp),
};
}
return out;
}
// ===== items ====================================================================================
/**
* Build data/items.json: every item chess (EQUIP normal + golden, MAGIC Arts), keyed by chessId.
*/
function buildItems(ctx, effects) {
const { act, charTable } = ctx;
const researchItems = new Map((ctx.research.items?.items || []).map((i) => [i.id, i]));
const shopByBase = new Map();
for (const s of Object.values(act.trapShopChessDatas)) {
shopByBase.set(s.itemId, s);
if (s.goldenItemId) shopByBase.set(s.goldenItemId, s);
}
const out = {};
for (const chessId of Object.keys(act.trapChessDataDict).sort(naturalCmp)) {
const t = act.trapChessDataDict[chessId];
const shop = shopByBase.get(chessId);
if (!shop) warn(`item ${chessId}: no trapShopChessDatas entry`);
const baseId = shop?.itemId || chessId;
const eff = effects[t.effectId];
if (!eff) warn(`item ${chessId}: effect ${t.effectId} missing`);
const trap = charTable[t.charId];
if (!trap) warn(`item ${chessId}: trap ${t.charId} missing from character_table`);
const ri = researchItems.get(baseId);
const isGolden = !!t.isGolden;
const upgradeNum = t.upgradeNum;
out[chessId] = {
id: chessId, baseId, goldenId: shop?.goldenItemId || null, isGolden,
trapId: t.charId, iconId: t.charId, identifier: t.identifier,
name: trap?.name || eff?.name || chessId,
itemType: t.itemType, tier: shop?.itemLevel ?? null, shopSortId: shop?.shopLevelSortId ?? null,
price: t.purchasePrice, hideInShop: !!shop?.hideInShop,
mergeable: !isGolden && upgradeNum > 0 && upgradeNum < 100,
upgradeNum, upgradeChessId: t.upgradeChessId || null,
duration: t.trapDuration,
giveBondId: t.giveBondId || null, givePowerId: t.givePowerId || null, canGiveBond: !!t.canGiveBond,
requiresBondId: ri?.requiresBond || null,
effectId: t.effectId, effectName: eff?.name || null,
desc: eff?.desc || null, descRaw: eff?.descRaw || null,
buffs: (eff?.buffs || []).map((b) => ({ key: b.key, countType: b.countType, bb: b.bb, bbStr: b.bbStr })),
params: eff?.params || {},
category: ri?.category || null, kind: ri?.kind || null, family: ri?.family || null,
implFormula: ri?.implFormula || null,
rangeGrid: Array.isArray(ri?.rangeGrids) ? ri.rangeGrids.map((g) => [g.row, g.col]) : null,
flavor: ri?.flavor || null,
};
if (!ri) warn(`item ${chessId}: no research 04 entry (category/kind unknown)`);
}
return out;
}
// ===== bands (策略) ===============================================================================
/** Build data/bands.json: the 40 season strategies. */
function buildBands(ctx, effects) {
const { act, ac } = ctx;
const out = {};
const ids = Object.keys(act.bandDataListDict);
ids.sort((a, b) => (act.bandDataListDict[a].sortId ?? 0) - (act.bandDataListDict[b].sortId ?? 0) || naturalCmp(a, b));
for (const bandId of ids) {
const b = act.bandDataListDict[bandId];
const meta = ac.bandDataDict?.[bandId] || {};
const eff = effects[b.effectId];
if (!eff) warn(`band ${bandId}: effect ${b.effectId} missing`);
const dp = textPair(b.bandDesc);
out[bandId] = {
bandId, sortId: b.sortId, name: meta.bandName || eff?.name || bandId, iconId: meta.bandIconId || `icon_${bandId.replace(/^band_/, '')}`,
modeTypeList: b.modeTypeList || [], totalHp: b.totalHp,
effectId: b.effectId, effectName: eff?.name || null, desc: dp.desc, descRaw: dp.descRaw,
buffs: (eff?.buffs || []).map((x) => ({ key: x.key, bb: x.bb, bbStr: x.bbStr })),
params: eff?.params || {},
victorCount: b.victorCount, rewardModulus: b.bandRewardModulus ?? 1,
unlockDesc: meta.unlockDesc || null,
};
}
return out;
}
// ===== enemies ==================================================================================
/** Template placeholder keys (constData.templateEnemy*) → slot code. */
function templateSlots(ctx) {
const c = ctx.ac.constData;
const slots = {
[c.templateEnemyNormal]: 'N', [c.templateEnemyElite]: 'E', [c.templateEnemySpecial]: 'S',
[c.templateEnemyNormalFly]: 'NF', [c.templateEnemyEliteFly]: 'EF', [c.templateEnemySpecialFly]: 'SF',
[c.templateEnemyToken]: 'T', [c.templateEnemyTokenFly]: 'TF',
};
delete slots.undefined;
return slots;
}
/** Merge an enemy_database "enemyData" override (m_defined fields) onto a base record (returns new obj). */
function mergeEnemyData(base, over) {
if (!over) return base;
const out = { ...base };
for (const [k, v] of Object.entries(over)) {
if (v && typeof v === 'object' && 'm_defined' in v) { if (v.m_defined) out[k] = v; continue; }
if (k === 'attributes' && v && typeof v === 'object') { out.attributes = mergeEnemyData(base.attributes || {}, v); continue; }
if ((k === 'talentBlackboard' || k === 'skills' || k === 'spData') && v != null) out[k] = v;
}
return out;
}
/**
* Read m_value of an enemy field (or a default). An undefined field (`m_defined:false`) whose
* m_value is the C# zero value (null/0/false/'') was never set in the database, so the game uses the
* prefab default: return `dflt` (matters for lifePointReduce → 1, e.g. 萨卡兹王庭军战士/深池逐火战士,
* and baseAttackTime → 1, e.g. 枯朽之种, whose zero would mean "attack every tick"). A non-zero
* m_value of an undefined field carries the prefab value and is kept (e.g. boss-part immunities).
*/
const mv = (f, dflt = null) => {
if (!f || typeof f !== 'object' || !('m_value' in f)) return dflt;
const v = f.m_value;
if (f.m_defined === false && (v == null || v === 0 || v === false || v === '')) return dflt;
return v;
};
/** Only defined fields of an override (m_defined === true), flattened to plain values. */
function definedFields(over) {
const out = {};
if (!over) return out;
for (const [k, v] of Object.entries(over)) {
if (v && typeof v === 'object' && 'm_defined' in v) { if (v.m_defined) out[k] = v.m_value; }
else if (k === 'attributes' && v) Object.assign(out, definedFields(v));
else if ((k === 'talentBlackboard' || k === 'skills') && v != null) out[k] = v;
}
return out;
}
const ENEMY_STAT_FIELDS = {
maxHp: 'maxHp', atk: 'atk', def: 'def', magicResistance: 'res', moveSpeed: 'moveSpeed',
baseAttackTime: 'bat', attackSpeed: 'aspd', blockCnt: 'blockCnt', massLevel: 'massLevel',
hpRecoveryPerSec: 'hpRecoveryPerSec', tauntLevel: 'tauntLevel', epDamageResistance: 'elementDmgRes',
epResistance: 'elementRes', damageHitratePhysical: 'hitRatePhys', damageHitrateMagical: 'hitRateArts',
rangeRadius: 'rangeRadius', lifePointReduce: 'lpr',
};
const DMG_MAP = { PHYSIC: 'phys', MAGIC: 'arts', HEAL: 'heal', NO_DAMAGE: 'none', TRUE: 'true', ELEMENT: 'element' };
/**
* Normal-attack radius under the DESIGN §5.2/§5.5 contract (`rangeRadius > 0` ⇔ the enemy attacks
* units it is not blocked by). MELEE enemies only ever hit their blocker, so they get 0 even when the
* official record carries a radius (粉碎攻坚手 2.5, 宿主士兵 2.5, 冰爆源石虫 1.75, 深池方阵步兵 1.5 …
* used by their abilities); the official value is kept in `rawRangeRadius`. Negative sentinels → 0.
* @param {string} applyWay MELEE / RANGED / ALL / NONE
* @param {number} raw official rangeRadius
* @returns {number}
*/
function effectiveRangeRadius(applyWay, raw) {
if (applyWay === 'MELEE' || typeof raw !== 'number' || !Number.isFinite(raw)) return 0;
return Math.max(0, raw);
}
/** Convert enemy skills list → compact records. */
function enemySkills(list, label) {
return (Array.isArray(list) ? list : []).map((sk) => {
const { bb, bbStr } = flattenBB(sk.blackboard, `${label} skill ${sk.prefabKey}`);
return { prefabKey: sk.prefabKey, priority: sk.priority, cooldown: sk.cooldown, initCooldown: sk.initCooldown, spCost: sk.spCost, bb, bbStr };
});
}
/** Convert an override (definedFields) into the enemies.json vocabulary (for per-template overrides). */
function enemyOverrideRecord(over, label) {
const stats = {};
const extra = {};
for (const [k, v] of Object.entries(over)) {
if (ENEMY_STAT_FIELDS[k]) stats[ENEMY_STAT_FIELDS[k]] = cleanNum(v);
else if (k === 'talentBlackboard') extra.talents = flattenBB(v, `${label} talents`);
else if (k === 'skills') extra.skills = enemySkills(v, label);
else if (k === 'motion' || k === 'applyWay' || k === 'levelType' || k === 'notCountInTotal' || k === 'name') extra[k] = v;
else if (/Immune$/.test(k)) (extra.immunities ||= {})[k.replace(/Immune$/, '')] = !!v;
}
return { ...(Object.keys(stats).length ? { stats } : {}), ...extra };
}
/**
* Collect every enemy key that can appear in a match (templates, factions, bounties, bands,
* bosses, summons), transitively through extraEnemyKeyList and enemy blackboards.
*/
function collectEnemyKeys(ctx) {
const keys = new Set();
const add = (k) => { if (typeof k === 'string' && /^enemy_/.test(k)) keys.add(k); };
const refs0 = [];
for (const id of ctx.templateIds) {
const lv = ctx.levels[id];
for (const w of lv.waves || []) for (const f of w.fragments || []) for (const a of f.actions || []) if (a.actionType === 'SPAWN') add(a.key);
for (const b of Object.values(lv.branches || {})) for (const ph of b.phases || []) for (const a of ph.actions || []) if (a.actionType === 'SPAWN') add(a.key);
for (const r of lv.enemyDbRefs || []) refs0.push(r.id);
}
for (const e of Object.values(ctx.act.specialEnemyInfoDict || {})) {
add(e.specialEnemyKey);
(e.attachedNormalEnemyKeys || []).forEach(add);
(e.attachedEliteEnemyKeys || []).forEach(add);
}
for (const list of Object.values(ctx.act.enemyInfoDict || {})) list.forEach(add);
for (const b of Object.values(ctx.ac.bossInfoDict || {})) add(b.enemyId);
for (const k of Object.keys(templateSlots(ctx))) add(k);
for (const m of Object.values(ctx.act.modeDataDict)) (m.inactiveEnemyKey || []).forEach(add);
for (const buffs of Object.values(ctx.act.effectBuffInfoDataDict || {})) {
for (const b of buffs) for (const e of b.blackboard || []) {
if (typeof e.valueStr === 'string') for (const m of e.valueStr.matchAll(/enemy_[A-Za-z0-9_]+/g)) if (ctx.enemyDb.has(m[0])) add(m[0]);
}
}
// Keys a wave action, a special entry, a leader or a bounty spawns directly; everything reached only through level
// enemyDbRefs, summons or blackboards is a token (`tokenOnly`: 联防 routes it on the escaped template's token
// actions, research 08 §5).
const direct = new Set(keys);
refs0.forEach(add);
// Transitive closure: summons & blackboard references of included enemies.
const queue = [...keys];
while (queue.length) {
const k = queue.pop();
const extra = ctx.ac.randomEnemyAttributeDict?.[k]?.extraEnemyKeyList || [];
const levels = ctx.enemyDb.get(k) || [];
const refs = [...extra];
for (const lvl of levels) {
const d = lvl.enemyData || {};
for (const e of [...(d.talentBlackboard || []), ...(d.skills || []).flatMap((s) => s.blackboard || [])]) {
if (typeof e.valueStr === 'string') for (const m of e.valueStr.matchAll(/enemy_[A-Za-z0-9_]+/g)) refs.push(m[0]);
}
}
for (const r of refs) if (!keys.has(r) && ctx.enemyDb.has(r)) { keys.add(r); queue.push(r); }
}
const out = [...keys].sort(naturalCmp);
out.direct = direct;
return out;
}
/**
* Official attribute power of an enemy (client `RandomEnemyGenerater._GetEnemyAttrPower`, research 08 §2.3), in
* float32 like the client: atk·5 + maxHp·1 + def·3 + res·3 from the enemy_database record at `level` (the
* season override level is NOT applied: 灼藤 / 元核孽生者 count with their database ATK).
*/
function enemyAttrPower(ctx, key, level) {
const c = ctx.ac.constData;
const levels = ctx.enemyDb.get(key) || [];
const base = levels.find((l) => l.level === 0)?.enemyData?.attributes || levels[0]?.enemyData?.attributes || {};
const over = level ? levels.find((l) => l.level === level)?.enemyData?.attributes || {} : {};
const g = (n) => {
const o = over[n];
if (o && o.m_defined) return Number(o.m_value) || 0;
return Number(base[n]?.m_value) || 0;
};
const f = Math.fround;
let p = f(f(f(g('atk') * (c.enemyAtkFactor ?? 5)) + f(g('maxHp') * (c.enemyMaxHpFactor ?? 1))) + f(g('def') * (c.enemyDefFactor ?? 3)));
p = f(p + f((c.enemyMagicResistanceFactor ?? 3) * g('magicResistance')));
return p;
}
/**
* Build data/enemies.json: base stats at the season level (randomEnemyAttributeDict.level, 0 for
* all), with the season-wide override level (level_autochess_enemy_data) applied.
*/
function buildEnemies(ctx) {
const { ac, act, handbook } = ctx;
const c = ac.constData;
const hpF = c.enemyMaxHpFactor ?? 1, atkF = c.enemyAtkFactor ?? 5, defF = c.enemyDefFactor ?? 3, resF = c.enemyMagicResistanceFactor ?? 3;
const globalOverrides = new Map();
for (const r of ctx.levels[ctx.enemyDataLevelId]?.enemyDbRefs || []) if (r.overwrittenData) globalOverrides.set(r.id, r.overwrittenData);
const slots = templateSlots(ctx);
const typeOf = new Map();
for (const [type, list] of Object.entries(act.enemyInfoDict || {})) for (const k of list) {
if (!typeOf.has(k)) typeOf.set(k, []);
typeOf.get(k).push(type);
}
const specialType = new Map(Object.values(act.specialEnemyInfoDict || {}).map((e) => [e.specialEnemyKey, e.type]));
const inactiveIn = new Map();
for (const m of Object.values(act.modeDataDict)) for (const k of m.inactiveEnemyKey || []) {
if (!inactiveIn.has(k)) inactiveIn.set(k, []);
inactiveIn.get(k).push(m.modeId);
}
const out = {};
const allKeys = collectEnemyKeys(ctx);
for (const key of allKeys) {
const levels = ctx.enemyDb.get(key);
if (!levels || !levels.length) { warn(`enemy ${key} missing from enemy_database`); continue; }
const rand = ac.randomEnemyAttributeDict?.[key] || null;
const wantLevel = rand?.level ?? 0;
const base = levels.find((l) => l.level === 0)?.enemyData || levels[0].enemyData;
let data = base;
if (wantLevel > 0) {
const lv = levels.find((l) => l.level === wantLevel);
if (lv) data = mergeEnemyData(base, lv.enemyData);
else warn(`enemy ${key}: level ${wantLevel} missing, using 0`);
}
const override = globalOverrides.get(key) || null;
if (override) data = mergeEnemyData(data, override);
const at = data.attributes || {};
const hb = handbook.enemyData?.[key] || null;
const dmgTypes = (hb?.damageType || []).map((d) => DMG_MAP[d] || String(d).toLowerCase());
const applyWay = mv(data.applyWay, 'NONE');
let dmgType = dmgTypes[0] || (applyWay === 'NONE' ? 'none' : 'phys');
if (applyWay === 'NONE' && !dmgTypes.length) dmgType = 'none';
const rawRangeRadius = cleanNum(mv(data.rangeRadius, 0));
const stats = {
maxHp: mv(at.maxHp, 0), atk: mv(at.atk, 0), def: mv(at.def, 0), res: cleanNum(mv(at.magicResistance, 0)),
moveSpeed: cleanNum(mv(at.moveSpeed, 1)), bat: cleanNum(mv(at.baseAttackTime, 1)), aspd: cleanNum(mv(at.attackSpeed, 100)),
rangeRadius: effectiveRangeRadius(applyWay, rawRangeRadius),
rawRangeRadius,
blockCnt: at.blockCnt?.m_defined ? at.blockCnt.m_value : 1,
massLevel: mv(at.massLevel, 0), lpr: mv(data.lifePointReduce, 1),
hpRecoveryPerSec: cleanNum(mv(at.hpRecoveryPerSec, 0)),
elementRes: cleanNum(mv(at.epResistance, 0)), elementDmgRes: cleanNum(mv(at.epDamageResistance, 0)),
hitRatePhys: cleanNum(mv(at.damageHitratePhysical, 0)), hitRateArts: cleanNum(mv(at.damageHitrateMagical, 0)),
dmgType, dmgTypes, motion: mv(data.motion, 'WALK'),
immunities: {
stun: !!mv(at.stunImmune, false), silence: !!mv(at.silenceImmune, false), sleep: !!mv(at.sleepImmune, false),
frozen: !!mv(at.frozenImmune, false), levitate: !!mv(at.levitateImmune, false),
},
otherImmunities: ['disarmedCombat', 'feared', 'palsy', 'attract', 'teleport', 'groundBound'].filter((k) => !!mv(at[`${k}Immune`], false)),
tauntLevel: mv(at.tauntLevel, 0),
};
const beFactor = rand?.enemyBattleEffectivenessFactor ?? 1;
const be = beFactor > 0 ? Math.round((stats.maxHp * hpF + stats.atk * atkF + stats.def * defF + stats.res * resF) / beFactor) : null;
const talents = flattenBB(data.talentBlackboard, `enemy ${key} talents`);
const abilities = (hb?.abilityList || []).map((a) => ({ text: stripRich(a.text), textRaw: richRaw(a.text), format: a.textFormat || 'NORMAL' }));
const name = mv(data.name) || hb?.name || key;
const descRaw = mv(data.description);
out[key] = {
key, name, level: wantLevel, rank: mv(data.levelType, 'NORMAL'), handbookIndex: hb?.enemyIndex || null,
desc: stripRich(descRaw), descRaw: richRaw(descRaw),
applyWay, stats,
abilities,
talents: { bb: talents.bb, bbStr: talents.bbStr },
skills: enemySkills(data.skills, `enemy ${key}`),
notCountInTotal: !!mv(data.notCountInTotal, false),
tags: mv(data.enemyTags) || [],
be, beFactor, attrPower: enemyAttrPower(ctx, key, wantLevel), isFlyEnemy: rand ? !!rand.isFlyEnemy : stats.motion === 'FLY',
tokenOnly: !allKeys.direct.has(key),
acTypes: typeOf.get(key) || [], acType: specialType.get(key) || (typeOf.get(key) || [])[0] || null,
templateSlot: slots[key] || null,
summons: rand?.extraEnemyKeyList || [],
inactiveIn: inactiveIn.get(key) || [],
seasonOverride: override ? Object.keys(definedFields(override)) : null,
iconId: key, spine: mv(data.prefabKey) || key,
};
}
return out;
}
// ===== waves ====================================================================================
const pos = (p) => (p ? [p.row, p.col] : null);
/** Resolve an official route into { motion, start, end, checkpoints, steps? }. */
function resolveRoute(r) {
if (!r) return null;
const checkpoints = [];
const steps = [];
let special = false;
for (const c of r.checkpoints || []) {
switch (c.type) {
case 'MOVE': checkpoints.push(pos(c.position)); steps.push({ t: 'move', p: pos(c.position) }); break;
case 'PATROL_MOVE': special = true; checkpoints.push(pos(c.position)); steps.push({ t: 'patrol', p: pos(c.position) }); break;
case 'WAIT_FOR_SECONDS': special = true; steps.push({ t: 'wait', s: c.time }); break;
case 'DISAPPEAR': special = true; steps.push({ t: 'disappear' }); break;
case 'APPEAR_AT_POS': special = true; steps.push({ t: 'appear', p: pos(c.position) }); break;
case 'WAIT_CURRENT_FRAGMENT_TIME': case 'WAIT_CURRENT_WAVE_TIME': special = true; steps.push({ t: 'wait', s: c.time, until: c.type }); break;
default: special = true; steps.push({ t: String(c.type).toLowerCase(), p: pos(c.position), s: c.time }); warn(`unknown checkpoint type ${c.type}`);
}
}
const out = { motion: r.motionMode, start: pos(r.startPosition), end: pos(r.endPosition), checkpoints };
if (special) out.steps = steps;
const rr = r.spawnRandomRange;
if (rr && (rr.x || rr.y)) out.spawnRandom = [cleanNum(rr.x), cleanNum(rr.y)];
if (r.allowDiagonalMove === false) out.allowDiagonal = false;
return out;
}
/** Expand sequential phases/fragments of actions into absolute-time spawn entries. */
function expandActions(groups, slots, bossKeys, label) {
const spawns = [];
let cursor = 0;
for (const g of groups) {
const start = cursor + (g.preDelay || 0);
let end = start;
for (const a of g.actions || []) {
const time = cleanNum(start + (a.preDelay || 0));
const count = a.count ?? 1;
const interval = cleanNum(a.interval || 0);
const e = { time, key: a.key, count, interval, routeIndex: a.routeIndex ?? 0 };
if (a.actionType !== 'SPAWN') e.action = a.actionType;
const slot = slots[a.key];
if (slot) e.slot = slot;
if (bossKeys.has(a.key)) e.tag = 'boss';
else if (a.isUnharmfulAndAlwaysCountAsKilled || a.randomSpawnGroupKey) e.tag = 'part';
if (a.hiddenGroup) { e.hidden = true; e.hiddenGroup = a.hiddenGroup; }
if (a.randomSpawnGroupKey) { e.group = a.randomSpawnGroupKey; e.pack = a.randomSpawnGroupPackKey; e.weight = a.weight; }
if (a.isUnharmfulAndAlwaysCountAsKilled) e.unharmful = true;
if (a.randomType && a.randomType !== 'ALWAYS') e.randomType = a.randomType;
spawns.push(e);
end = Math.max(end, time + Math.max(0, count - 1) * interval);
}
cursor = end;
}
if (groups.length > 1) warn(`${label}: multi-fragment timing approximated (next fragment after previous one's last spawn)`);
return spawns;
}
/** Usage map template id → [{modeId, round, bossId}] from battleDataDict. */
function templateUsage(ctx) {
const usage = new Map();
for (const [modeId, rounds] of Object.entries(ctx.act.battleDataDict)) {
for (const [round, entries] of Object.entries(rounds)) {
for (const e of entries) {
const id = templateIdOf(e.levelId);
if (!usage.has(id)) usage.set(id, []);
usage.get(id).push({ modeId, round: Number(round), bossId: e.bossId || null });
}
}
}
return usage;
}
/**
* Build data/waves.json: every wave template (normal, boss, hidden, escaped, training) with resolved
* routes, absolute-time spawn lists, branches, per-template enemy overrides and mode/round usage.
*/
function buildWaves(ctx, enemies) {
const slots = templateSlots(ctx);
const usage = templateUsage(ctx);
const bossEnemy = Object.fromEntries(Object.entries(ctx.ac.bossInfoDict || {}).map(([id, b]) => [id, b.enemyId]));
const escaped = new Set(['escapedBattleTemplateMapSinglePlayer', 'escapedBattleTemplateMapMultiPlayer']
.map((k) => ctx.act.constData[k]).filter(Boolean).map(templateIdOf));
const out = {};
for (const id of ctx.templateIds) {
const lv = ctx.levels[id];
const uses = usage.get(id) || [];
const bossIds = [...new Set(uses.map((u) => u.bossId).filter(Boolean))];
const bossKeys = new Set(bossIds.map((b) => bossEnemy[b]).filter(Boolean));
const groups = [];
for (const w of lv.waves || []) {
(w.fragments || []).forEach((f, fi) => groups.push({ preDelay: (fi === 0 ? (w.preDelay || 0) : 0) + (f.preDelay || 0), actions: f.actions }));
}
const spawns = expandActions(groups, slots, bossKeys, `template ${id}`);
const branches = {};
for (const [name, b] of Object.entries(lv.branches || {})) {
// Each phase is kept separately (times relative to the phase start); see DATA.md.
branches[name] = (b.phases || []).map((ph, pi) => expandActions([{ preDelay: ph.preDelay, actions: ph.actions }], slots, bossKeys, `template ${id} branch ${name}#${pi}`));
}
const overrides = {};
for (const r of lv.enemyDbRefs || []) {
if (!r.overwrittenData) continue;
const rec = enemyOverrideRecord(definedFields(r.overwrittenData), `template ${id} ${r.id}`);
// Same rangeRadius contract as enemies.json (MELEE ⇒ 0, official value in rawRangeRadius).
const way = rec.applyWay || enemies[r.id]?.applyWay;
if (rec.stats && 'rangeRadius' in rec.stats) {
rec.stats.rawRangeRadius = rec.stats.rangeRadius;
rec.stats.rangeRadius = effectiveRangeRadius(way, rec.stats.rangeRadius);
} else if (rec.applyWay && enemies[r.id]) {
const eff = effectiveRangeRadius(rec.applyWay, enemies[r.id].stats.rawRangeRadius);
if (eff !== enemies[r.id].stats.rangeRadius) rec.stats = { ...(rec.stats || {}), rangeRadius: eff };
}
if (Object.keys(rec).length) overrides[r.id] = rec;
}
const devices = (lv.predefines?.tokenInsts || []).map((t) => ({
key: t.inst?.characterKey, alias: t.alias || null, pos: pos(t.position), dir: t.direction, hidden: !!t.hidden,
}));
const o = lv.options || {};
let kind = 'normal';
if (escaped.has(id)) kind = 'escaped';
else if (/_tr\d+$/.test(id)) kind = 'training';
else if (/_h08_/.test(id)) kind = 'hidden';
else if (bossIds.length) kind = 'boss';
const slotCounts = {};
let total = 0;
for (const sp of spawns) {
if (sp.action) continue;
if (sp.slot) slotCounts[sp.slot] = (slotCounts[sp.slot] || 0) + sp.count;
if (!sp.unharmful) total += sp.count;
}
out[id] = {
id, kind, solo: /_s$/.test(id), bossId: bossIds.length === 1 ? bossIds[0] : bossIds.length ? bossIds : null,
maxPlayTime: o.maxPlayTime ?? null,
dp: { init: o.initialCost ?? 10, perSec: o.costIncreaseTime ? cleanNum(1 / o.costIncreaseTime) : 1, max: o.maxCost ?? 99 },
characterLimit: o.characterLimit ?? 8, moveMultiplier: o.moveMultiplier ?? 0.5,
bgm: lv.bgmEvent || null,
routes: (lv.routes || []).map(resolveRoute),
extraRoutes: (lv.extraRoutes || []).map(resolveRoute),
spawns, branches, overrides, devices,
totalCount: total, slotCounts,
usedBy: uses.sort((a, b) => naturalCmp(a.modeId, b.modeId) || a.round - b.round),
};
for (const sp of spawns) {
if (sp.action) continue;
if (!out[id].routes[sp.routeIndex]) warn(`template ${id}: spawn ${sp.key} uses missing route ${sp.routeIndex}`);
if (!enemies[sp.key]) warn(`template ${id}: spawn key ${sp.key} not in enemies`);
}
for (const [bn, phases] of Object.entries(branches)) for (const sp of phases.flat()) {
if (sp.action) continue;
if (!enemies[sp.key]) warn(`template ${id}: branch ${bn} key ${sp.key} not in enemies`);
if (!out[id].extraRoutes[sp.routeIndex]) warn(`template ${id}: branch ${bn} uses missing extraRoute ${sp.routeIndex}`);
}
}
return out;
}
// ===== stages ===================================================================================
/** Glyph legend of stage rows (documented in DATA.md). */
const TILE_LEGEND = {
'#': { tileKey: 'tile_forbidden', desc: '禁区:不可部署,地面不可通行(飞行可越过)' },
X: { tileKey: 'tile_forbidden', desc: '硬分隔(第6/13行):任何单位不可通行' },
r: { tileKey: 'tile_road', desc: '道路:近战/远程均可部署,地面可通行' },
R: { tileKey: 'tile_road', desc: '道路(不可部署)' },
f: { tileKey: 'tile_floor', desc: '地板:不可部署,地面可通行(第9列通道/敌人预览区)' },
p: { tileKey: 'tile_floor', desc: '地板(预览区 previewNotAlloed)' },
h: { tileKey: 'tile_wall', desc: '高台:仅远程可部署,地面不可通行' },
b: { tileKey: 'tile_fence_bound', desc: '围栏低地:可部署,地面敌人不可通行' },
a: { tileKey: 'tile_achand', desc: '整备区格(isValidHand)' },
A: { tileKey: 'tile_achand', desc: '临时整备区/非整备手牌格' },
S: { tileKey: 'tile_start', desc: '敌人出生点(红门)' },
E: { tileKey: 'tile_end', desc: '保护目标(蓝门)' },
I: { tileKey: 'tile_telin', desc: '传送入口(敌人消失)' },
O: { tileKey: 'tile_telout', desc: '传送出口/领袖区出生点' },
m: { tileKey: 'tile_mire', special: 'mire', desc: '沼泽:停留叠加减速减攻速' },
g: { tileKey: 'tile_smog', special: 'smog', desc: '排气格栅:其上干员不会成为敌方远程攻击目标' },
d: { tileKey: 'tile_deepsea', special: 'deepsea', desc: '深水:敌人持续受伤、减速、减攻速' },
i: { tileKey: 'tile_infection', special: 'infection', desc: '活性源石:单位受持续真实伤害,攻击力与攻速提升' },
};
/** Map an official tile object to a legend glyph. */
function tileGlyph(t) {
const bbKeys = new Set((t.blackboard || []).map((b) => b.key));
switch (t.tileKey) {
case 'tile_forbidden': return t.passableMask === 'NONE' ? 'X' : '#';
case 'tile_road': return t.buildableType === 'NONE' ? 'R' : 'r';
case 'tile_floor': return bbKeys.has('previewNotAlloed') ? 'p' : 'f';
case 'tile_wall': return 'h';
case 'tile_fence_bound': case 'tile_fence': return 'b';
case 'tile_achand': return bbKeys.has('isValidHand') ? 'a' : 'A';
case 'tile_start': return 'S';
case 'tile_end': return 'E';
case 'tile_telin': return 'I';
case 'tile_telout': return 'O';
case 'tile_mire': return 'm';
case 'tile_smog': return 'g';
case 'tile_deepsea': return 'd';
case 'tile_infection': return 'i';
default: return '?';
}
}
/**
* Official ground route (research 08 §3.4, the same code the sim runs: server/sim/grid.js flow field — 4-direction
* SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing) as the tiles an enemy crosses, start and goal
* included. The whole 19×21 map is walkable like in the client.
* @param {object} stage { rows, legend }
* @param {Array<{pos:number[], role:string}>} devices active devices to apply (crates = cost 1000, platforms/mounds = blocked)
*/
function officialPath(stage, devices, start, end) {
const g = new Grid(stage, { r0: 0, r1: 18, c0: 0, c1: 20 });
for (const d of devices) {
if (!d.pos) continue;
if (d.role === 'crate') g.setObstacle(d.pos[0], d.pos[1], true, 'crate');
else g.setObstacle(d.pos[0], d.pos[1], true);
}
return g.findPath(start[0], start[1], end[0], end[1]);
}
/** Gameplay role of known stage devices. */
const DEVICE_ROLES = {
trap_1105_accrate: 'crate', trap_1106_achplat: 'platform', trap_032_mound: 'mound', trap_013_blower: 'blower',
trap_098_mire: 'mireController', trap_042_tidectrl: 'tideController', trap_1104_aclasert: 'turret',
trap_1112_acblzd: 'coldWind', trap_036_storm: 'sandstorm', trap_040_canoe: 'waterPlatform',
trap_1107_acblock: 'sealedFloor', trap_218_fttree: 'bush', trap_039_dstnta: 'bossSpawn',
};
/** Roles that block ground movement while active ([ASSUMED] for platform/mound). */
const BLOCKING_ROLES = new Set(['crate', 'platform', 'mound']);
/**
* Is a predefined device active at match start? Exactly the non-hidden ones: the 下半 act1 m02 has all its crates
* and platforms hidden and starts with none (research 08 §3.3: level file + the PRTS 下半 screenshot); hidden devices
* are switched on by effects (auto_chess_change_map) only.
*/
function deviceActiveAtStart(stageId, d) { // eslint-disable-line no-unused-vars
return !d.hidden;
}
/**
* Rotate a facing-right range grid [[dRow,dCol]…] to a direction (row 0 = bottom, so UP = +row):
* RIGHT (dr,dc) · UP (dc,−dr) · LEFT (−dr,−dc) · DOWN (−dc,dr). Unknown directions keep RIGHT.
* @param {number[][]} grid
* @param {string} dir RIGHT / UP / LEFT / DOWN
* @returns {number[][]}
*/
function rotateGrid(grid, dir) {
const rot = { UP: ([r, c]) => [c, -r], LEFT: ([r, c]) => [-r, -c], DOWN: ([r, c]) => [-c, r] }[dir];
// `x + 0` normalizes -0 so the JSON output stays clean.
return grid.map((p) => (rot ? rot(p) : p).map((x) => x + 0));
}
/** Device (predefined token/trap) record with stats and skill blackboard at its instance level. */
function deviceRecord(ctx, t) {
const key = t.inst?.characterKey;
const ch = ctx.charTable[key];
const rec = { key, name: ch?.name || key, alias: t.alias || null, pos: pos(t.position), dir: t.direction, hidden: !!t.hidden, role: DEVICE_ROLES[key] || null };
if (!rec.role) warn(`device ${key} has no known role`);
if (!ch) { warn(`device ${key} missing from character_table`); return rec; }
const phase = phaseIdx(t.inst.phase);
const level = t.inst.level || 1;
const ph = Math.max(0, Math.min(phase, (ch.phases?.length || 1) - 1));
const attrs = interpolateAttrs(ch, ph, level);
const s = statsFrom(attrs);
rec.stats = s ? { maxHp: s.maxHp, atk: s.atk, def: s.def, res: s.res, bat: s.bat, aspd: s.aspd, blockCnt: s.blockCnt } : null;
rec.rangeGrid = rangeGrid(ctx, ch.phases?.[ph]?.rangeId);
// Absolute tiles covered by the (direction-rotated) range, clipped to the 19×21 grid.
rec.rangeTiles = rec.rangeGrid && rec.pos
? rotateGrid(rec.rangeGrid, rec.dir).map(([dr, dc]) => [rec.pos[0] + dr, rec.pos[1] + dc])
.filter(([r, c]) => r >= 0 && r < 19 && c >= 0 && c < 21)
: null;
const sk = ch.skills?.[t.skillIndex ?? 0];
const skill = sk?.skillId ? buildSkill(ctx, sk.skillId, t.mainSkillLvl || 1, null, `device ${key}`) : null;
rec.skill = skill ? { skillId: skill.skillId, name: skill.name, level: skill.level, desc: skill.desc, descRaw: skill.descRaw, bb: skill.bb, bbStr: skill.bbStr } : null;
const dp = textPair(ch.description);
rec.desc = dp.desc;
return rec;
}
/**
* Player-facing stage name. activity_table carries no stage names, so they come from research 05,
* whose notes can leave a bracketed English annotation in a name ('战场#01 (upper half #01)'); that
* text would reach the briefing BATTLEFIELD row. Drop any bracketed segment containing Latin
* letters, normalize whitespace and report the change. Falls back to the stage id.
* @param {string} stageId
* @param {unknown} raw research name
* @returns {string}
*/
function stageDisplayName(stageId, raw) {
if (typeof raw !== 'string' || !raw.trim()) return stageId;
const name = raw.replace(/\s*[((][^()()]*[A-Za-z][^()()]*[))]/g, '').replace(/\s+/g, ' ').trim();
if (!name) { warn(`stage ${stageId}: research name "${raw}" is only an annotation; using the stage id`); return stageId; }
if (name !== raw.trim()) warn(`stage ${stageId}: dropped research annotation from name "${raw}" -> "${name}"`);
return name;
}
/**
* Build data/stages.json: 19×21 terrain grids (row 0 = bottom), legend, devices, special terrain
* parameters, deployable tiles and helper ground paths.
*/
function buildStages(ctx, modesById) {
const researchStages = ctx.research.maps?.stages || {};
const out = {};
for (const stageId of ctx.stageIds) {
const sd = ctx.act.stageDatasDict[stageId];
const lv = ctx.levels[stageId];
const map = lv.mapData?.map || [];
const tiles = lv.mapData?.tiles || [];
const H = map.length, W = map[0]?.length || 0;
if (H !== 19 || W !== 21) warn(`stage ${stageId}: unexpected size ${H}x${W}`);
const tileAt = (r, c) => tiles[map[H - 1 - r]?.[c]];
const rows = [];
const glyphTiles = {};
for (let r = 0; r < H; r++) {
let line = '';
for (let c = 0; c < W; c++) {
const t = tileAt(r, c);
const g = t ? tileGlyph(t) : '?';
if (g === '?') warn(`stage ${stageId}: unknown tile at (${r},${c}) ${t?.tileKey}`);
line += g;
if (t && !glyphTiles[g]) {
const { bb } = flattenBB(t.blackboard);
glyphTiles[g] = {
tileKey: t.tileKey, height: t.heightType === 'HIGHLAND' ? 'HIGH' : 'LOW', buildable: t.buildableType,
passable: t.passableMask, groundPassable: t.passableMask === 'ALL', flyPassable: t.passableMask !== 'NONE',
special: TILE_LEGEND[g]?.special || null, bb,
};
}
}
rows.push(line);
}
const devices = (lv.predefines?.tokenInsts || []).map((t) => deviceRecord(ctx, t));
for (const d of devices) d.active = deviceActiveAtStart(stageId, d);
const mapChars = (lv.predefines?.characterInsts || []).map((c) => ({
key: c.inst?.characterKey, alias: c.alias || null, pos: pos(c.position), dir: c.direction, hidden: !!c.hidden,
}));
// Special terrain parameters.
const special = {};
const byKey = (k) => devices.find((d) => d.key === k);
if (rows.some((l) => l.includes('m'))) {
const d = byKey('trap_098_mire');
special.mire = { source: d ? d.key : null, intervalSec: d?.skill?.bb?.value ?? 3, aspdPerStack: d?.skill?.bb?.attack_speed ?? -0.05, moveMulPerStack: d?.skill?.bb?.move_speed ?? -0.05, maxStacks: d?.skill?.bb?.max_stack_cnt ?? 10, clearedOnLeave: true };
}
if (rows.some((l) => l.includes('d'))) {
const d = byKey('trap_042_tidectrl');
special.deepsea = { source: d ? d.key : null, skillId: d?.skill?.skillId || null, bb: d?.skill?.bb || {} };
}
if (rows.some((l) => l.includes('i'))) special.infection = { bb: glyphTiles.i?.bb || {} };
if (rows.some((l) => l.includes('g'))) special.smog = { rule: 'operatorsNotTargetableByEnemyRanged' };
const blower = byKey('trap_013_blower');
if (blower) special.blower = { rangeGrid: blower.rangeGrid, bb: blower.skill?.bb || {}, desc: blower.skill?.desc || null };
const runes = (lv.runes || []).map((r) => ({ key: r.key, ...flattenBB(r.blackboard) }));
const globalBuffs = (lv.globalBuffs || []).map((g) => ({ key: g.key, ...flattenBB(g.blackboard) }));
// Deployable tiles at match start (active crates/mounds remove a tile, platforms make it ranged-only).
const activeBlocking = devices.filter((d) => d.active && BLOCKING_ROLES.has(d.role));
const blocked = new Set(activeBlocking.map((d) => d.pos.join(',')));
const platformAt = new Set(activeBlocking.filter((d) => d.role === 'platform').map((d) => d.pos.join(',')));
const deployIn = (r0, r1, c0, c1) => {
const melee = [], rangedOnly = [], byDevice = [];
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
const t = tileAt(r, c);
if (!t) continue;
const k = `${r},${c}`;
const buildable = t.buildableType !== 'NONE';
if (platformAt.has(k)) { rangedOnly.push([r, c]); if (buildable) byDevice.push([r, c]); continue; }
if (blocked.has(k)) { if (buildable) byDevice.push([r, c]); continue; }
if (t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE')) melee.push([r, c]);
else if (t.buildableType === 'RANGED' || (t.heightType === 'HIGHLAND' && t.buildableType === 'ALL' && t.tileKey !== 'tile_achand')) rangedOnly.push([r, c]);
}
return { melee, rangedOnly, changedByDevices: byDevice };
};
// Helper ground routes (the official flow field + smoothing, as crossed tiles), without / with the active devices.
const gridStage = { rows, legend: glyphTiles };
const pairs = [
[[9, 10], [9, 2]], [[12, 10], [9, 2]], [[9, 18], [9, 2]], [[12, 18], [9, 2]],
[[2, 10], [2, 2]], [[5, 10], [2, 2]], [[2, 10], [1, 3]], [[5, 10], [1, 3]],
[[2, 10], [2, 18]], [[5, 10], [2, 18]], [[2, 10], [1, 17]], [[5, 10], [1, 17]],
];
const groundPaths = {}, groundPathsWithDevices = {};
for (const [a, b] of pairs) {
const k = `${a.join(',')}->${b.join(',')}`;
const passable = (p) => tileAt(p[0], p[1])?.passableMask === 'ALL';
if (!passable(a) || !passable(b)) continue;
const p1 = officialPath(gridStage, [], a, b);
if (p1) groundPaths[k] = p1;
const p2 = officialPath(gridStage, activeBlocking, a, b);
if (p2) groundPathsWithDevices[k] = p2;
}
const modes = [...(sd.mode || [])];
const o = lv.options || {};
out[stageId] = {
id: stageId, name: stageDisplayName(stageId, researchStages[stageId]?.name), weight: sd.weight, active: sd.weight > 0,
modes, size: [H, W], rows, tiles: glyphTiles,
devices, mapChars, special, runes, globalBuffs,
deployTiles: { normal: deployIn(9, 12, 2, 10), bossLeft: deployIn(1, 5, 2, 10), bossRight: deployIn(1, 5, 10, 18) },
groundPaths, groundPathsWithDevices,
options: { characterLimit: o.characterLimit ?? 8, moveMultiplier: o.moveMultiplier ?? 0.5 },
config: flattenBB(o.configBlackBoard).bbStr,
};
for (const m of modes) if (modesById && !modesById[m]) warn(`stage ${stageId}: unknown mode ${m}`);
}
return out;
}
// ===== factions (特训敌人) ========================================================================
/**
* Build data/factions.json: enemy types, the 67 special-enemy entries, template placeholders and the parameters of
* the official generator (client `RandomEnemyGenerater`, research 08 §2): a 15-slot type schedule per match, one
* weighted entry per round, per-ACTION replacement counts computed at runtime by server/match/waves.js from
* enemies.json `attrPower` / `beFactor`.
*/
function buildFactions(ctx, enemies) {
const { ac, act } = ctx;
const c = ac.constData;
const slots = templateSlots(ctx);
const slotKey = Object.fromEntries(Object.entries(slots).map(([k, s]) => [s, k]));
const minK = c.minReplacedEnemyCount ?? 1, maxK = c.maxReplacedEnemyCount ?? 5;
const inactive = new Map();
for (const m of Object.values(act.modeDataDict)) for (const k of m.inactiveEnemyKey || []) {
if (!inactive.has(k)) inactive.set(k, []);
inactive.get(k).push(m.modeId);
}
const isFly = (k) => {
const r = ac.randomEnemyAttributeDict?.[k];
return r ? !!r.isFlyEnemy : enemies[k]?.stats?.motion === 'FLY';
};
const entries = {};
for (const key of Object.keys(act.specialEnemyInfoDict).sort(naturalCmp)) {
const e = act.specialEnemyInfoDict[key];
const all = [e.specialEnemyKey, ...(e.attachedNormalEnemyKeys || []), ...(e.attachedEliteEnemyKeys || [])];
const fly = isFly(e.specialEnemyKey);
if (all.some((k) => isFly(k) !== fly)) warn(`faction entry ${key}: mixed movement classes`);
entries[key] = {
key: e.specialEnemyKey, type: e.type, weight: e.randomWeight, firstHalf: !!e.isInFirstHalf, fly,
N: (e.attachedNormalEnemyKeys || []).map((k) => ({ key: k })),
E: (e.attachedEliteEnemyKeys || []).map((k) => ({ key: k })),
// modes whose inactiveEnemyKey bans the SPECIAL key (attached keys are never filtered — research 08 §2.2)
inactiveIn: [...new Set([...(inactive.get(e.specialEnemyKey) || [])])].sort(naturalCmp),
};
for (const k of all) if (!enemies[k]) warn(`faction entry ${key}: enemy ${k} missing`);
}
const types = {};
for (const [type, t] of Object.entries(ac.enemyTypeDatas || {})) {
const r = act.specialEnemyRandomTypeDict?.[type] || {};
types[type] = {
type, name: t.name, desc: t.description, icon: t.icon, sortId: t.sortId, typeIdentifier: t.typeIdentifier,
involveRandom: !!t.involveRandom, count: r.count ?? null, weight: r.weight ?? null,
pool: [...(act.enemyInfoDict?.[type] || [])],
entries: Object.keys(entries).filter((k) => entries[k].type === type),
};
}
const fillType = Object.values(types).find((t) => t.typeIdentifier === c.enemyTypeIdentifierToFillRandom)?.type || 'SPECIAL';
const maxLevelCnt = c.maxLevelCnt ?? 15;
// placeholder key → { cls: normal/elite/special, fly } (constData.templateEnemy*; T / TF are NOT placeholders: tokens)
const placeholders = {};
for (const [cls, flyCode, walkCode] of [['normal', 'NF', 'N'], ['elite', 'EF', 'E'], ['special', 'SF', 'S']]) {
if (slotKey[walkCode]) placeholders[slotKey[walkCode]] = { slot: walkCode, cls, fly: false };
if (slotKey[flyCode]) placeholders[slotKey[flyCode]] = { slot: flyCode, cls, fly: true };
}
return {
templateSlots: slotKey,
types, entries,
generation: {
source: 'official client RandomEnemyGenerater (research 08 §2, decoded from the client; normative)',
specialEnemyNum: c.specialEnemyNum ?? 3,
maxLevelCnt,
fillType,
alwaysIncludedType: fillType,
typeSlots: Object.fromEntries(Object.values(types).filter((t) => t.involveRandom).map((t) => [t.type, t.count ?? 3])),
trainingTypes: act.constData.trSpecialEnemyTypes || [],
firstHalfMaxRound: Math.floor(maxLevelCnt / 2),
minReplacedEnemyCount: minK, maxReplacedEnemyCount: maxK,
minActionIntervalRatio: 0.05,
beFactors: { hp: c.enemyMaxHpFactor ?? 1, atk: c.enemyAtkFactor ?? 5, def: c.enemyDefFactor ?? 3, res: c.enemyMagicResistanceFactor ?? 3 },
placeholders,
powerFormula: 'P(k) = f32(f32(f32(atk*5) + f32(maxHp*1)) + f32(def*3)) + f32(3*res) — enemy_database level stats (enemies.json attrPower)',
countFormula: "n' = clamp(roundHalfEven(f32(f32(f32(n*P(tpl))/f(tpl)) / f32(P(new)/f(new)))), minReplacedEnemyCount, maxReplacedEnemyCount); f = beFactor",
timingFormula: "unit i at preDelay + i*max(n*interval/n', minActionIntervalRatio*n*interval)",
notes: [
'Match start: shuffle the involveRandom types, keep specialEnemyNum (3); each owns `count` (3) of the maxLevelCnt (15) round slots, fillType (SPECIAL) the rest; the slots are shuffled (slot r = type of round r).',
'Per round r: half = r <= firstHalfMaxRound; weighted (weight) pick among the entries of that type and half whose SPECIAL key is not in the mode inactiveEnemyKeys; normal/elite = a random attached key (never filtered).',
'Per SPAWN action whose key is a placeholder: skipped when isFly(new key) != placeholder fly flag (not spawned, not previewed); else the count follows countFormula and the units keep the action window (timingFormula). Literal keys and T/TF tokens are kept.',
'Leader / hidden rounds use slot 14 / 15 (solo 标准: 9) with the same rule, so only the E or the EF escorts spawn.',
'enemy_9012_acloon (炎佑) is never an enemy.',
],
},
};
}
// ===== bosses ===================================================================================
/** Build data/bosses.json: the 10 leaders with HP pools, weights, templates per mode and escorts. */
function buildBosses(ctx, enemies, waves) {
const { ac, act } = ctx;
const out = {};
const usage = templateUsage(ctx);
for (const bossId of Object.keys(act.bossInfoDict).sort(naturalCmp)) {
const b = act.bossInfoDict[bossId];
const g = ac.bossInfoDict?.[bossId] || {};
const enemyKey = g.enemyId;
const e = enemies[enemyKey];
if (!e) warn(`boss ${bossId}: enemy ${enemyKey} missing`);
const templates = {};
for (const [tid, uses] of usage) for (const u of uses) if (u.bossId === bossId) {
templates[u.modeId] = { round: u.round, template: tid };
}
const escortsByTemplate = {};
const parts = new Set();
for (const tid of new Set(Object.values(templates).map((t) => t.template))) {
const w = waves[tid];
if (!w) continue;
const list = {};
for (const sp of w.spawns) {
if (sp.action || sp.tag === 'boss') continue;
if (sp.tag === 'part') { parts.add(sp.key); continue; }
const k = sp.slot ? `${sp.slot}:${sp.key}` : sp.key;
list[k] = (list[k] || 0) + sp.count;
}
escortsByTemplate[tid] = Object.entries(list).map(([k, count]) => {
const [slot, key] = k.includes(':') ? k.split(':') : [null, k];
return { key, slot, count };
});
}
out[bossId] = {
bossId, enemyKey, handbookId: g.handbookEnemyId || enemyKey, name: e?.name || enemyKey, sortId: b.sortId,
weight: b.weight, hidden: !!b.isHidingBoss,
bloodPoint: { FUNNY: b.bloodPoint, NORMAL: b.bloodPointNormal, HARD: b.bloodPointHard, ABYSS: b.bloodPointAbyss },
lpr: e?.stats?.lpr ?? null, templates, escortsByTemplate, parts: [...parts].sort(naturalCmp),
abilities: e?.abilities?.map((a) => a.text) || [],
};
}
return out;
}
// ===== choices (机变) =============================================================================
const ROMAN = { I: 1, II: 2, III: 3 };
/** Classify a 机变 choice event id into a family. */
function choiceFamily(ev) {
if (ev.choiceType === 'BOUNTY_HUNT' || ev.choiceType === 'PERSONAL_CHOOSE') return 'bounty';
if (ev.choiceType === 'EQUIP_FREE') return /^artifact_paid/.test(ev.choiceEventId) ? 'shop' : 'supply';
if (ev.choiceType === 'BUFF_SELECT') return 'tactic';
return 'other';
}
/**
* Build data/choices.json: 机变 events, families, card pools, per-mode round schedule (research 01
* A4 defaults, [ASSUMED] where the data is silent) and server-side reward pools.
*/
function buildChoices(ctx, effects, items, chess) {
const { act } = ctx;
const events = {};
for (const id of Object.keys(act.effectChoiceInfoDict).sort(naturalCmp)) {
const ev = act.effectChoiceInfoDict[id];
events[id] = {
id, choiceType: ev.choiceType, effectType: ev.effectType, name: ev.name, desc: stripRich(ev.desc), descRaw: richRaw(ev.desc),
color: ev.typeTxtColor || null, family: choiceFamily(ev), solo: /_s$/.test(id), training: /_tr$/.test(id),
};
}
// Effects that open a personal 机变 choice (e.g. 教鞭 / “神秘顾客” → hunter_band_1).
for (const e of Object.values(effects)) for (const b of e.buffs) {
const ce = b.bbStr.choice_event;
if (!ce) continue;
if (!events[ce]) { warn(`effect ${e.effectId} references unknown choice event ${ce}`); continue; }
const users = Object.values(items).filter((i) => i.effectId === e.effectId).map((i) => i.id);
(events[ce].usedBy ||= []).push(...(users.length ? users : [e.effectId]));
}
const eventsOf = (family, pred = () => true) => Object.values(events).filter((e) => e.family === family && pred(e)).map((e) => e.id);
// Bounty cards (ENEMY_GAIN effects).
const bounty = [];
for (const e of Object.values(effects)) {
if (e.effectType !== 'ENEMY_GAIN') continue;
const adds = e.buffs.filter((b) => /add_enemy/.test(b.key)).map((b) => ({
enemyKey: b.bbStr.enemy_id || null, count: b.bb.count ?? 1, coin: b.bb.coin ?? 0,
rounds: b.bb.round ?? 1, payout: /kill_gain_coin/.test(b.key) ? 'kill' : 'perfect', buffKey: b.key,
}));
if (!adds.length) { warn(`bounty effect ${e.effectId} has no add_enemy buff`); continue; }
const main = adds.find((a) => a.buffKey !== 'next_battle_add_enemy_win_gain_coin') || adds[0];
const m = /([I]{1,3})$/.exec(e.name || '');
let tier = m ? ROMAN[m[1]] : Math.min(3, Math.max(1, e.enemyPrice || main.coin || 1));
const multiRound = main.rounds >= 99;
if (multiRound) tier = 2;
for (const a of adds) if (a.enemyKey && !ctx.enemyDb.has(a.enemyKey)) warn(`bounty ${e.effectId}: unknown enemy ${a.enemyKey}`);
bounty.push({
effectId: e.effectId, name: e.name, desc: e.desc, tier, coin: e.enemyPrice || main.coin,
payout: main.payout, rounds: main.rounds, multiRound, enemyKey: main.enemyKey, count: main.count, adds,
});
}
bounty.sort((a, b) => naturalCmp(a.effectId, b.effectId));
// Tactic cards (BUFF_GAIN effects).
const tactic = [];
for (const e of Object.values(effects)) {
if (e.effectType !== 'BUFF_GAIN') continue;
let kind = 'ally', stageId = null;
if (/^enemydebuff_select/.test(e.effectId)) kind = 'enemyDebuff';
else if (/^map_m0\d/.test(e.effectId)) { kind = 'terrain'; stageId = `act1autochess_m0${/^map_m0(\d)/.exec(e.effectId)[1]}`; }
const team = /若存在其他队友则他们也获得/.test(e.desc || '');
tactic.push({ effectId: e.effectId, name: e.name, desc: e.desc, kind, stageId, team });
}
tactic.sort((a, b) => naturalCmp(a.effectId, b.effectId));
const equipNormal = Object.values(items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden);
const itemByName = (name) => equipNormal.find((i) => i.name === name)?.id || (warn(`pool item "${name}" not found`), null);
const chessByName = (name) => Object.values(chess).find((c) => !c.isGolden && c.visible && c.name === name)?.chessId
|| Object.values(chess).find((c) => !c.isGolden && c.name === name)?.chessId || (warn(`pool chess "${name}" not found`), null);
// Per-mode schedule of families (research 01 A4).
const schedule = {};
const supplyWindow = { 3: [1, 4], 6: [2, 5], 9: [3, 6], 11: [4, 6] };
const bountyTiers = { 3: [1, 2], 6: [1, 2, 3], 9: [2, 3], 11: [2, 3] };
for (const [modeId, rounds] of Object.entries(act.battleDataDict)) {
const mode = act.modeDataDict[modeId];
const solo = mode.modeType === 'SINGLE';
const diff = mode.modeDifficulty;
const sp = Object.entries(rounds).filter(([, es]) => es.some((e) => e.isSpPrepare)).map(([r]) => Number(r)).sort((a, b) => a - b);
const m = {};
for (const r of sp) {
let families;
if (diff === 'TRAINING') families = [{ family: 'supply', weight: 100 }];
else if (diff === 'HARD' || diff === 'ABYSS') {
families = r === 11 ? [{ family: 'supply', weight: 50 }, { family: 'shop', weight: 50 }] : [{ family: 'bounty', weight: 100 }];
} else if (diff === 'NORMAL') {
families = solo
? [{ family: r === 9 ? 'tactic' : 'supply', weight: 100 }]
: [{ family: 'bounty', weight: 50 }, { family: 'supply', weight: 25 }, { family: 'shop', weight: 10 }, { family: 'tactic', weight: 15 }];
} else families = [{ family: 'supply', weight: 45 }, { family: 'tactic', weight: 35 }, { family: 'shop', weight: 20 }];
const bountyEvents = r <= 3 ? eventsOf('bounty', (e) => /^enemy_initial/.test(e.id) && e.solo === solo) : eventsOf('bounty', (e) => /^(bounty_hunter|bossInitial)/.test(e.id) && e.solo === solo);
m[r] = {
families, cards: solo ? 3 : 6,
supplyTiers: supplyWindow[r] || [1, 6], bountyTiers: bountyTiers[r] || [1, 3],
events: {
bounty: bountyEvents,
supply: diff === 'TRAINING' ? eventsOf('supply', (e) => e.training) : eventsOf('supply', (e) => !e.training && e.solo === solo),
shop: eventsOf('shop', (e) => e.solo === solo),
tactic: eventsOf('tactic', (e) => e.solo === solo && (/^hardbuff/.test(e.id) ? diff === 'HARD' || diff === 'ABYSS' : true)),
},
assumed: !(diff === 'HARD' || diff === 'ABYSS') || r === 11,
};
}
schedule[modeId] = { spRounds: sp, rounds: m };
}
return {
events,
families: {
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'cards.bounty' },
supply: { name: '道具补给', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items in schedule[*].supplyTiers (duplicates allowed)' },
shop: { name: '机密商店', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items of any tier I–VI (duplicates allowed)' },
tactic: { name: '战术决策', desc: '选择战术增益。', cards: 'cards.tactic (terrain cards only for the match stage)' },
},
format: {
multi: { cards: 6, pickOrder: 'random', firstPickSec: 30, otherPickSec: 16, onTimeout: 'autoPickRandom', eachPlayerPicks: 1 },
solo: { cards: 3, timer: null },
opensAfterIncome: true,
},
cards: { bounty, tactic },
schedule,
pools: {
pool_equip_normal: { kind: 'equip', rule: 'shopEligible', maxTier: 'shopLevel', assumed: true },
pool_equip_shop_1: { kind: 'equip', rule: 'shopEligible', tiers: [1], assumed: true },
pool_equip_kathe: { kind: 'equip', rule: 'shopEligible', maxTier: 'shopLevel', assumed: true },
pool_equip_narant: { kind: 'equip', rule: 'shopEligible', maxTier: 'shopLevel', assumed: true },
pool_equip_vict: { kind: 'equip', items: ['灼燃维式重锤', '坚固维式重锤', '加速维式重锤', '战栗维式重锤'].map(itemByName), assumed: true },
pool_equip_pepe: { kind: 'equip', weighted: [['盟约之币', 45], ['萨尔贡浓茶', 45], ['黄沙罗盘', 10]].map(([n, w]) => [itemByName(n), w]), goldenWeights: [40, 40, 20], assumed: true },
pool_equip_rockr: { kind: 'equip', items: ['有限加速器', '激光发射器', '护盾无人机', '双模机械臂', '蜂鸣器'].map(itemByName), assumed: true },
pool_chess_glady: { kind: 'chess', items: ['斯卡蒂', '幽灵鲨', '深巡'].map(chessByName), assumed: true },
pool_char_pinus: { kind: 'chess', weighted: [['野鬃', 45], ['灰毫', 45], ['远牙', 10]].map(([n, w]) => [chessByName(n), w]), assumed: true },
pool_char_later: { kind: 'chess', bond: 'lateranoShip', minTier: 4, golden: true, rule: 'shopEligible', assumed: true },
...Object.fromEntries([1, 2, 3, 4, 5, 6].map((t) => [`pool_chess_shop_${t}_reward`, { kind: 'chess', tier: t, rule: 'shopEligible', assumed: true }])),
},
};
}
// ===== config ===================================================================================
/** Fallback enemy multiplier table (research 01 Addendum A3) when 01-core-data.json is absent. */
const DEFAULT_ENEMY_MULTIPLIERS = {
single: {
FUNNY: { atkBase: 0.7, hpBase: 0.75, k: [0, 0, 0, 0, 0, 0, 0, 0, 0], hidden: null },
NORMAL: { atkBase: 0.7, hpBase: 0.75, k: [0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 3, 4, 5, 5], hidden: 5 },
HARD: { atkBase: 0.8, hpBase: 0.8, k: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 3, 3, 4, 4], hidden: 4 },
ABYSS: { atkBase: 1, hpBase: 1, k: [1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 5, 5, 6, 7], hidden: 7 },
},
multi: {
FUNNY: { atkBase: 0.8, hpBase: 0.8, k: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], hidden: null },
NORMAL: { atkBase: 0.8, hpBase: 0.8, k: [0, 1, 1, 2, 2, 2, 2, 3, 4, 4, 5, 6, 7, 7], hidden: 7 },
HARD: { atkBase: 1, hpBase: 1, k: [1, 2, 2, 3, 3, 3, 3, 3, 4, 5, 6, 6, 7, 8], hidden: 8 },
ABYSS: {
atkBase: 1, hpBase: 1,
kAtk: [1, 2, 2, 3, 3, 4, 5, 5, 5, 5, 6, 6, 7, 8], hiddenAtk: 8,
kHp: [1, 2, 2, 3, 4, 4, 7, 8, 8, 8, 9, 10, 10, 10], hiddenHp: 10,
hpExtra: [1, 1, 1, 1, 1, 1.08, 1, 1, 1, 1, 1, 1, 1.08, 1.08], hiddenHpExtra: 1.08,
},
},
abyssMoveSpeedMulFromRound3: 1.15,
};
/** Enemy stat multipliers {atk, hp, speed} of one round (research 01 A3; leader HP pools excluded). */
function enemyScaleFor(table, type, difficulty, round, isHidden) {
const t = table?.[type === 'SINGLE' ? 'single' : 'multi']?.[difficulty];
if (!t) return { atk: 1, hp: 1, speed: 1, assumed: true };
const i = round - 1;
const pick = (arr, hidden) => (isHidden ? (hidden ?? arr?.[arr.length - 1] ?? 0) : (arr?.[Math.min(i, (arr?.length || 1) - 1)] ?? 0));
const kAtk = t.kAtk ? pick(t.kAtk, t.hiddenAtk) : pick(t.k, t.hidden);
const kHp = t.kHp ? pick(t.kHp, t.hiddenHp) : pick(t.k, t.hidden);
const extra = t.hpExtra ? (isHidden ? t.hiddenHpExtra ?? 1 : t.hpExtra[Math.min(i, t.hpExtra.length - 1)] ?? 1) : 1;
const speedMul = difficulty === 'ABYSS' && round >= 3 ? (table.abyssMoveSpeedMulFromRound3 ?? 1.15) : 1;
return {
atk: cleanNum(t.atkBase * 1.1 ** kAtk), hp: cleanNum(t.hpBase * 1.2 ** kHp * extra), speed: speedMul, kAtk, kHp,
};
}
/**
* Build data/config.json: modes (rounds, timers, shop, enemy scaling), economy, bans, timers,
* titles, tips, broadcasts, trophies and other global tunables.
*/
function buildConfig(ctx, waves, stages, bands) {
const { act, ac } = ctx;
const addendum = ctx.research.core?._criticAddendum || {};
const multipliers = addendum.enemyStatMultipliers || DEFAULT_ENEMY_MULTIPLIERS;
if (!addendum.enemyStatMultipliers) warn('config: using built-in enemy multiplier table (research 01 _criticAddendum missing)');
const incomeCap = addendum.incomePerRound?.CAP ?? 12;
const DIFF_KEYS = { FUNNY: 'bloodPoint', NORMAL: 'bloodPointNormal', HARD: 'bloodPointHard', ABYSS: 'bloodPointAbyss' };
const modes = {};
for (const modeId of Object.keys(act.modeDataDict)) {
const m = act.modeDataDict[modeId];
const battle = act.battleDataDict[modeId] || {};
const turns = ac.turnInfoDataDict?.[modeId] || {};
const roundNums = Object.keys(battle).map(Number).sort((a, b) => a - b);
const type = m.modeType;
const rounds = {};
const enemyScale = {};
const combatTimeLimit = {};
let lastRound = 0, bossRound = null, hiddenRound = null;
for (const r of roundNums) {
const entries = battle[String(r)];
const turn = turns[String(r)] || {};
const isBoss = entries.some((e) => e.bossId) || !!turn.isBossTurn;
const bossTemplates = {};
for (const e of entries) if (e.bossId) bossTemplates[e.bossId] = templateIdOf(e.levelId);
const isHidden = isBoss && Object.keys(bossTemplates).some((b) => act.bossInfoDict[b]?.isHidingBoss);
const template = isBoss ? null : templateIdOf(entries[0].levelId);
if (!isBoss && entries.length > 1) warn(`mode ${modeId} round ${r}: ${entries.length} templates for a non-boss round`);
const tplId = template || Object.values(bossTemplates)[0];
const multi = type === 'MULTI';
rounds[r] = {
template, bossTemplates: isBoss ? bossTemplates : null,
combatTimeLimit: isBoss ? null : (waves[template]?.maxPlayTime ?? null),
levelMaxPlayTime: waves[tplId]?.maxPlayTime ?? null,
prepTime: multi || type === 'LOCAL' ? turn.normalPhaseTime ?? null : null,
prepTimeData: turn.normalPhaseTime ?? null,
isSpPrepare: entries.some((e) => e.isSpPrepare), isBoss, isHidden,
bossOvertimeAfter: isBoss ? turn.bossTurnHpReduceTime || 150 : null,
};
enemyScale[r] = enemyScaleFor(multipliers, type, m.modeDifficulty, r, isHidden);
combatTimeLimit[r] = rounds[r].combatTimeLimit;
if (isHidden) hiddenRound = r; else { lastRound = r; if (isBoss) bossRound = r; }
}
const shopLv = act.shopLevelDataDict[modeId] || {};
const levels = Object.keys(shopLv).map(Number).sort((a, b) => a - b);
modes[modeId] = {
modeId, name: m.name, code: m.code, sortId: m.sortId, type, difficulty: m.modeDifficulty,
inScope: type === 'SINGLE' || type === 'MULTI',
color: `#${String(m.modeColor || '').replace(/^#/, '')}`, iconId: m.modeIconId, backgroundId: m.backgroundId || null,
desc: m.desc, effectDescList: m.effectDescList || [], unlockText: m.unlockText || null,
specialPhaseTime: m.specialPhaseTime,
activeBondIds: m.activeBondIdList || [], inactiveBondIds: m.inactiveBondIdList || [], inactiveEnemyKeys: m.inactiveEnemyKey || [],
lastRound, bossRound, hiddenRound,
rounds, spRounds: roundNums.filter((r) => rounds[r].isSpPrepare),
combatTimeLimit, enemyScale,
bossHpScale: type === 'SINGLE'
? { bloodPointKey: DIFF_KEYS[m.modeDifficulty] || null, solo: 0.25, soloAssumed: true, unaffectedByEnemyScale: true }
: { bloodPointKey: DIFF_KEYS[m.modeDifficulty] || null, coop: 1, aliveScaling: false, unaffectedByEnemyScale: true },
upgradePrices: levels.slice(0, -1).map((l) => shopLv[l].initialUpgradePrice),
maxShopLevel: levels.length ? levels[levels.length - 1] : 6,
shopSlots: Object.fromEntries(levels.map((l) => [l, { chess: shopLv[l].charChessCount, item: shopLv[l].itemCount }])),
levelTagColors: Object.fromEntries(levels.map((l) => [l, shopLv[l].levelTagBgColor])),
stages: Object.values(stages).filter((s) => s.active && s.modes.includes(modeId)).map((s) => s.id),
bossWeights: Object.fromEntries(Object.values(rounds).filter((x) => x.isBoss && !x.isHidden).flatMap((x) => Object.keys(x.bossTemplates)).map((b) => [b, act.bossInfoDict[b].weight])),
hiddenBossWeights: Object.fromEntries(Object.values(rounds).filter((x) => x.isHidden).flatMap((x) => Object.keys(x.bossTemplates)).map((b) => [b, act.bossInfoDict[b].weight])),
};
}
const priceTable = act.shopCharChessInfoData || {};
const chessPrice = {}, chessSell = {}, chessStatus = {};
for (const [tier, rows] of Object.entries(priceTable)) {
const n = rows.find((x) => !x.isGolden) || {}, g = rows.find((x) => x.isGolden) || {};
chessPrice[tier] = { normal: n.purchasePrice, golden: g.purchasePrice };
chessSell[tier] = { normal: n.chessSoldPrice, golden: g.chessSoldPrice };
chessStatus[tier] = {
normal: { phase: phaseIdx(n.evolvePhase), level: n.charLevel, skillLevel: n.skillLevel, equipLevel: n.equipLevel, eliteIconId: n.eliteIconId },
golden: { phase: phaseIdx(g.evolvePhase), level: g.charLevel, skillLevel: g.skillLevel, equipLevel: g.equipLevel, eliteIconId: g.eliteIconId },
};
}
const titleRules = {
comment_1: { stat: 'bossDamage', rule: 'max', onlyOnWin: true, text: '对敌方领袖造成伤害最高(仅胜利时)' },
comment_2: { stat: 'activatedLayers', rule: 'max', text: '激活的盟约层数总和最高' },
comment_3: { stat: 'lpRemaining', rule: 'max', text: '目标生命值损失最少' },
comment_4: { stat: 'merges', rule: 'max', text: '晋升精锐次数最多' },
comment_5: { stat: 'itemsEquipped', rule: 'max', text: '装备配发数量最多' },
comment_6: { stat: 'fundsSpent', rule: 'max', text: '消耗资金最多' },
};
const trophies = ctx.research.core?.trophiesPerClear;
const steps = Array.isArray(ac.enterStepList) ? ac.enterStepList : Object.values(ac.enterStepList || {});
const step = (t) => steps.find((x) => x.stepType === t);
return {
season: SEASON,
seasonName: '卫戍协议:盟约',
modes,
economy: {
income: Array.from({ length: 16 }, (_, r) => (r === 0 ? 0 : Math.min(3 + r, incomeCap))),
incomeFormula: `min(3 + round, ${incomeCap})`, incomeCap, incomeCapAlternative: 10, incomeAssumedAfterRound: 3,
leftoverFundsLost: true, leftoverFundsKeptByBands: ac.constData?.noMoneyTipsBand || ['band_cannot'],
chessPrice, chessSell, chessStatus,
refreshPrice: act.constData.shopRefreshPrice,
freeze: { scope: 'allUnsoldSlots', price: 0, consumedAtRoundStart: true, refreshWhileFrozenRerollsAll: true, rewardOfferFreezable: false },
shopClearedAtCombatStart: 'unfrozenSlots',
itemSellable: false, itemDestroyRefund: 0,
benchSize: act.constData.maxDeckChessCnt, tempSize: 5, deployCap: act.constData.maxBattleChessCnt,
storeCntMax: act.constData.storeCntMax,
equipPerChess: 2, maxArtsPerRound: 2,
poolCopies: { 1: 12, 2: 14, 3: 18, 4: 16, 5: 8, 6: 5 },
poolCopiesOverrides: { chess_char_6_11_a: 4 },
goldenCopies: 3,
mergeCount: 3,
mergeCountOverrides: Object.fromEntries(Object.entries(act.charChessDataDict).filter(([, c]) => !c.isGolden && c.upgradeNum && c.upgradeNum !== 3).map(([id, c]) => [id, c.upgradeNum])),
itemMergeCount: 2,
rewardOffer: { count: 3, tierOffset: 1, maxTier: 6, price: 0, refreshable: false, freezable: false, expiresAtRoundEnd: true },
handFillOrder: 'rightToLeft',
shopOdds: { model: 'copyWeighted', note: 'each slot draws 1 copy uniformly from remaining pool copies of unbanned visible chess with tier <= shop level; items: same tier shares, then uniform within tier [ASSUMED]' },
borrowCount: act.constData.borrowCount,
fallbackBondId: act.constData.fallbackBondId,
defaultBandId: 'band_bldsk', defaultStartLp: bands.band_bldsk?.totalHp ?? 28,
},
lpCapPerRound: act.constData.costPlayerHpLimit ?? 10,
bossOvertimeAfter: 150, bossOvertimeDrainPerSec: 1,
bossHpScale: {
formula: 'co-op: bloodPoint[difficulty] (one pool shared by the split leaders, no alive-player scaling — "敌方领袖的总生命值不变"); solo: bloodPoint[difficulty] × solo [ASSUMED]',
coop: 1, solo: 0.25, soloAssumed: true, aliveScaling: false, unaffectedByEnemyScale: true,
},
hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'], checkedAfterRound: 14 },
dp: { init: 10, perSec: 1, max: 99 },
unite: { maxHelpers: 2, helperOrder: 'unitsOnField>activeBond>undownedUnits; pair: unitsOnField>activeBond>activeLayers>undownedUnits, first = right field (PRTS 帮助)', layerGainsEnabled: false, keepsHpSpPositions: true, leakedEnemyFullHp: true,
templates: { 1: templateIdOf(act.constData.escapedBattleTemplateMapSinglePlayer), 2: templateIdOf(act.constData.escapedBattleTemplateMapMultiPlayer) } },
finalAssault: { pairing: 'seatOrderPairs', oddPlayerAlone: true, movableBossPerAlivePlayerSide: true, layerGainsEnabled: false },
timers: {
infoCheck: step('INFO_CHECK')?.time ?? 25, infoCheckHint: step('INFO_CHECK')?.hintTime ?? 5,
bandDraft: step('BAND_CHECK')?.time ?? 50, bandDraftHint: step('BAND_CHECK')?.hintTime ?? 15, bandTurn: 12,
battleCheck: step('BATTLE_CHECK')?.time ?? 3,
spFirst: 30, spTurn: act.modeDataDict.mode_multi_normal?.specialPhaseTime ?? 16,
soloPrepTimeData: 300, soloSpTimeData: act.modeDataDict.mode_single_normal?.specialPhaseTime ?? 150,
chatCd: ac.constData?.chatCD ?? 1, chatBubble: ac.constData?.chatTime ?? 3, broadcastDelay: ac.constData?.broadcastBeginDelay ?? 1,
enterSteps: (Array.isArray(ac.enterStepList) ? ac.enterStepList : Object.values(ac.enterStepList || {})).map((s) => ({ step: s.stepType, time: s.time, hint: s.hintTime, title: s.title })),
},
bans: { FUNNY: { core: 0, addon: 1 }, NORMAL: { core: 3, addon: 4 }, HARD: { core: 3, addon: 4 }, ABYSS: { core: 3, addon: 4 }, TRAINING: { core: 0, addon: 0 },
rule: 'banned iff every bond of a visible non-DIY chess is in (drawn set D ∪ mode.inactiveBondIds); core = isCore bonds, addon = other bonds with weight > 0; uniform draw' },
bandDraft: { skipsPerPlayer: 1, order: 'random', duplicatesAllowed: true, timeoutBandId: 'band_bldsk' },
titles: Object.values(act.playerTitleDataDict || {}).map((t) => ({ id: t.id, picId: t.picId, name: t.txt, ...(titleRules[t.id] || {}) })),
titleRule: 'each player gets at most one title; each title is used at most once per match; assign by the category where the player ranks best relative to teammates [ASSUMED]',
tips: (Array.isArray(ac.gameTipsList) ? ac.gameTipsList : Object.values(ac.gameTipsList || {})).map((t) => ({ tip: String(t.tip).trim(), weight: t.weight })),
broadcasts: (Array.isArray(ac.broadcastList) ? ac.broadcastList : Object.values(ac.broadcastList || {})).map((b) => ({
id: b.id, type: b.type, priority: b.priority, text: stripRich(b.desc), textRaw: b.desc, params: b.paramList || [],
})),
trophies: {
byRoundsPassed: [
{ maxRound: 4, FUNNY: 0, NORMAL: 0, HARD: 0, ABYSS: 0 },
{ maxRound: 8, FUNNY: 1, NORMAL: 1, HARD: 1, ABYSS: 1 },
{ maxRound: 11, FUNNY: 2, NORMAL: 2, HARD: 2, ABYSS: 2 },
{ maxRound: 13, FUNNY: 2, NORMAL: 3, HARD: 3, ABYSS: 3 },
{ maxRound: 14, FUNNY: 3, NORMAL: 4, HARD: 5, ABYSS: 6 },
],
hiddenCore: { FUNNY: null, NORMAL: 5, HARD: 7, ABYSS: 8 },
multiOnly: true, source: trophies ? 'research 01-core-data trophiesPerClear' : 'built-in',
medals: (Array.isArray(ac.medalDataList) ? ac.medalDataList : Object.values(ac.medalDataList || {})).map((m) => ({ count: m.medalCount, iconId: m.medalIconId })),
},
roundScores: (Array.isArray(ac.roundScoreDataList) ? ac.roundScoreDataList : Object.values(ac.roundScoreDataList || {})).map((r) => ({ round: r.round, score: r.score })),
rewards: {
itemId: (Array.isArray(act.baseRewardDataList) ? act.baseRewardDataList : Object.values(act.baseRewardDataList || {}))[0]?.item?.id || null,
baseByRoundsPassed: (Array.isArray(act.baseRewardDataList) ? act.baseRewardDataList : Object.values(act.baseRewardDataList || {})).map((r) => ({ round: r.round, count: r.item?.count ?? 0, dailyPoint: r.dailyMissionPoint ?? 0 })),
formula: 'baseByRoundsPassed[roundsPassed].count * difficultyFactor[difficulty] * modeFactor[type]',
difficultyFactor: act.difficultyFactorInfo, modeFactor: act.modeFactorInfo,
},
constants: {
maxLevelCnt: ac.constData?.maxLevelCnt ?? 15, bossTrailerStartRound: ac.constData?.bossTrailerStartRound ?? 3,
singleReconnectTime: ac.constData?.singleReconnectTime ?? 86400, trainingModeId: act.constData.trainingModeId || null,
discountColor: ac.constData?.discountColor || '#59f4ca', premiumColor: ac.constData?.premiumColor || '#ff5454', normalColor: ac.constData?.normalColor || '#ffc600',
pingConds: (ac.constData?.pingConds || []).map((p) => ({ minMs: p.cond, textRaw: p.txt })),
},
};
}
// ===== validation ===============================================================================
/** Recursively find non-finite numbers (NaN/Infinity would silently become null in JSON). */
function findNonFinite(obj, path, out) {
if (typeof obj === 'number') { if (!Number.isFinite(obj)) out.push(path); return; }
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) { obj.forEach((v, i) => findNonFinite(v, `${path}[${i}]`, out)); return; }
for (const [k, v] of Object.entries(obj)) findNonFinite(v, `${path}.${k}`, out);
}
/**
* Cross-file referential integrity checks. Returns a list of error strings (empty = OK).
* The same invariants are asserted by test/data.test.js.
*/
function validateAll(f) {
const errors = [];
const err = (m) => errors.push(m);
const { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens } = f;
for (const [name, obj] of Object.entries(f)) {
const bad = [];
findNonFinite(obj, name, bad);
bad.slice(0, 5).forEach((p) => err(`non-finite number at ${p}`));
}
const visible = Object.values(chess).filter((c) => !c.isGolden && c.visible);
if (Object.keys(bonds).length !== 23) err(`expected 23 bonds, got ${Object.keys(bonds).length}`);
if (Object.keys(bands).length !== 40) err(`expected 40 bands, got ${Object.keys(bands).length}`);
if (visible.length !== 112) err(`expected 112 visible non-DIY chess, got ${visible.length}`);
for (const c of Object.values(chess)) {
if (!chess[c.baseId]) err(`chess ${c.chessId}: baseId missing`);
if (c.goldenId && !chess[c.goldenId]) err(`chess ${c.chessId}: goldenId missing`);
for (const b of c.bonds) if (!bonds[b]) err(`chess ${c.chessId}: bond ${b} missing`);
for (const g of c.garrisonIds) if (!garrisons[g]) err(`chess ${c.chessId}: garrison ${g} missing`);
for (const t of c.tokens) if (!tokens[t]) err(`chess ${c.chessId}: token ${t} missing`);
for (const t of c.talents || []) if (t.tokenKey && !c.tokens.includes(t.tokenKey)) err(`chess ${c.chessId}: talent token ${t.tokenKey} not in tokens`);
if (c.isDiy) continue;
if (!c.stats) err(`chess ${c.chessId}: no stats`);
if (!c.skill) err(`chess ${c.chessId}: no resolvable skill`);
if (!Array.isArray(c.rangeGrid)) err(`chess ${c.chessId}: no range grid`);
// DESIGN §16 loadout choices
if (!Array.isArray(c.skills) || c.skills.filter((s) => s.isDefault).length !== 1 || c.skills.find((s) => s.isDefault)?.skillId !== c.skill?.skillId) err(`chess ${c.chessId}: skills[] without exactly one default = skill`);
if (c.modules && (c.modules.filter((m) => m.isDefault).length !== (c.module?.active ? 1 : 0) || !c.statsBase || !c.traitBase || !c.talentsBase)) err(`chess ${c.chessId}: inconsistent module choices`);
}
for (const b of Object.values(bonds)) {
for (const m of b.members) if (!chess[m]) err(`bond ${b.bondId}: member ${m} missing`);
if (!b.thresholds.length) err(`bond ${b.bondId}: no thresholds`);
if (!effects[b.effectId]) err(`bond ${b.bondId}: effect ${b.effectId} missing`);
}
for (const it of Object.values(items)) {
if (!effects[it.effectId]) err(`item ${it.id}: effect missing`);
if (it.goldenId && !items[it.goldenId]) err(`item ${it.id}: golden missing`);
if (it.giveBondId && !bonds[it.giveBondId]) err(`item ${it.id}: giveBond ${it.giveBondId} missing`);
if (it.requiresBondId && !bonds[it.requiresBondId]) err(`item ${it.id}: requiresBond ${it.requiresBondId} missing`);
}
for (const b of Object.values(bands)) if (!effects[b.effectId]) err(`band ${b.bandId}: effect missing`);
for (const w of Object.values(waves)) {
for (const sp of w.spawns) {
if (sp.action) continue;
if (!enemies[sp.key]) err(`wave ${w.id}: enemy ${sp.key} missing`);
if (!w.routes[sp.routeIndex]) err(`wave ${w.id}: route ${sp.routeIndex} missing`);
}
for (const [bn, phases] of Object.entries(w.branches)) for (const sp of phases.flat()) {
if (sp.action) continue;
if (!enemies[sp.key]) err(`wave ${w.id} branch ${bn}: enemy ${sp.key} missing`);
if (!w.extraRoutes[sp.routeIndex]) err(`wave ${w.id} branch ${bn}: extraRoute ${sp.routeIndex} missing`);
}
for (const k of Object.keys(w.overrides)) if (!enemies[k]) err(`wave ${w.id}: override for unknown enemy ${k}`);
}
for (const s of Object.values(stages)) {
if (s.rows.length !== 19 || s.rows.some((r) => r.length !== 21)) err(`stage ${s.id}: not 19x21`);
if (s.rows.some((r) => r.includes('?'))) err(`stage ${s.id}: unknown tile glyph`);
if (s.name !== s.id && /[A-Za-z]/.test(s.name)) err(`stage ${s.id}: Latin text in player-facing name "${s.name}"`);
}
for (const m of Object.values(config.modes)) {
for (const [r, rd] of Object.entries(m.rounds)) {
const tpls = rd.template ? [rd.template] : Object.values(rd.bossTemplates || {});
if (!tpls.length) err(`mode ${m.modeId} round ${r}: no template`);
for (const t of tpls) if (!waves[t]) err(`mode ${m.modeId} round ${r}: template ${t} missing`);
}
for (const s of m.stages) if (!stages[s]) err(`mode ${m.modeId}: stage ${s} missing`);
for (const b of [...m.activeBondIds, ...m.inactiveBondIds]) if (!bonds[b]) err(`mode ${m.modeId}: bond ${b} missing`);
}
for (const b of Object.values(bosses)) if (!enemies[b.enemyKey]) err(`boss ${b.bossId}: enemy missing`);
for (const e of Object.values(factions.entries)) {
for (const k of [e.key, ...e.N.map((x) => x.key), ...e.E.map((x) => x.key)]) if (!enemies[k]) err(`faction ${e.key}: enemy ${k} missing`);
}
for (const card of choices.cards.bounty) for (const a of card.adds) if (a.enemyKey && !enemies[a.enemyKey]) err(`bounty ${card.effectId}: enemy ${a.enemyKey} missing`);
for (const [pid, p] of Object.entries(choices.pools)) {
for (const id of [...(p.items || []), ...(p.weighted || []).map((x) => x[0])]) if (!id || !(items[id] || chess[id])) err(`pool ${pid}: unresolved entry ${id}`);
}
for (const t of Object.values(tokens)) {
if (!t.stats) err(`token ${t.tokenId}: no stats`);
for (const [o, v] of Object.entries(t.variants || {})) if (!Array.isArray(v.sources) || !v.sources.length) err(`token ${t.tokenId}@${o}: no sources`);
}
for (const e of Object.values(enemies)) {
const s = e.stats;
// DESIGN §5.5 contract: rangeRadius > 0 ⇔ attacks units it is not blocked by (never for MELEE).
if (!(s.rangeRadius >= 0) || (e.applyWay === 'MELEE' && s.rangeRadius !== 0)) err(`enemy ${e.key}: rangeRadius ${s.rangeRadius} (${e.applyWay})`);
// bat 0 would mean "attack every tick"; aspd 0 is official only for a few skill-driven leaders.
if (!(s.maxHp > 0) || !(s.bat > 0) || !(s.aspd >= 0) || !(s.moveSpeed >= 0) || !(s.lpr >= 0)) err(`enemy ${e.key}: bad core stats ${JSON.stringify({ maxHp: s.maxHp, bat: s.bat, aspd: s.aspd, moveSpeed: s.moveSpeed, lpr: s.lpr })}`);
}
return errors;
}
// ===== main =====================================================================================
async function main() {
const t0 = Date.now();
const ctx = await loadContext();
log('building…');
const { chess, tokenOwners } = buildChess(ctx);
const effects = buildEffects(ctx);
const bonds = buildBonds(ctx, chess, effects);
const garrisons = buildGarrisons(ctx, chess);
const items = buildItems(ctx, effects);
const bands = buildBands(ctx, effects);
const enemies = buildEnemies(ctx);
const tokens = buildTokens(ctx, chess, tokenOwners, enemies);
const waves = buildWaves(ctx, enemies);
const stages = buildStages(ctx, ctx.act.modeDataDict);
const factions = buildFactions(ctx, enemies);
const bosses = buildBosses(ctx, enemies, waves);
const choices = buildChoices(ctx, effects, items, chess);
const config = buildConfig(ctx, waves, stages, bands);
const files = { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens };
const errors = validateAll(files);
let total = 0;
const sizes = {};
const texts = {};
for (const [name, obj] of Object.entries(files)) {
texts[name] = JSON.stringify(obj);
sizes[name] = Buffer.byteLength(texts[name]);
total += sizes[name];
}
if (total > 6 * 1024 * 1024) errors.push(`total data size ${total} exceeds 6 MB`);
// Integrity errors keep the previous (valid) output untouched unless --force.
const write = !errors.length || OPTS.force;
if (write) {
await mkdir(OPTS.out, { recursive: true });
for (const [name, text] of Object.entries(texts)) {
// Atomic per file: a crash mid-write never leaves a truncated JSON behind.
const dest = join(OPTS.out, `${name}.json`);
const tmp = `${dest}.tmp-${process.pid}`;
await writeFile(tmp, text);
await rename(tmp, dest);
}
}
const report = {
counts: {
chess: Object.keys(chess).length,
visibleChess: Object.values(chess).filter((c) => !c.isGolden && c.visible).length,
bonds: Object.keys(bonds).length, garrisons: Object.keys(garrisons).length, items: Object.keys(items).length,
bands: Object.keys(bands).length, effects: Object.keys(effects).length, enemies: Object.keys(enemies).length,
waves: Object.keys(waves).length, stages: Object.keys(stages).length, bosses: Object.keys(bosses).length,
tokens: Object.keys(tokens).length, factionEntries: Object.keys(factions.entries).length,
},
sizes, totalBytes: total, out: OPTS.out, written: write, warnings, errors,
};
await mkdir(dirname(OPTS.report), { recursive: true });
await writeFile(OPTS.report, JSON.stringify(report, null, 2));
if (write) log(`wrote ${Object.keys(files).length} files to ${OPTS.out} (${(total / 1024 / 1024).toFixed(2)} MB) in ${Date.now() - t0} ms`);
else console.error(`integrity errors: ${OPTS.out} left unchanged (re-run with --force to write anyway)`);
log(Object.entries(report.counts).map(([k, v]) => `${k}=${v}`).join(' '));
if (warnings.length) {
log(`${warnings.length} warning(s):`);
for (const w of warnings) log(` - ${w}`);
}
if (errors.length) {
console.error(`${errors.length} error(s):`);
for (const e of errors) console.error(` x ${e}`);
process.exitCode = 1;
}
}
main().catch((e) => {
console.error('build-data failed:', e && e.stack ? e.stack : e);
process.exitCode = 1;
});