Files
ark-stronghold-protocol/tools/build-data.mjs
T

3829 lines
231 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, backups (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 · backups · 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 { isDeepStrictEqual } from 'node:util';
import { Grid, DEPLOY_REFUSED_TILES } from '../server/sim/grid.js';
import { bandBondIds } from '../shared/bandBonds.js';
import { statusKey, composeUnitRecord, standInRecord, unitForm } from '../shared/standIn.js';
import { extendedGrid } from '../shared/loadoutRecord.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,
manifest: await loadManifest(),
};
}
/**
* The committed asset manifest data/assets.json (tools/fetch-assets.mjs; docs/ASSETS.md) — the enemies' attack clip
* lengths (enemyAttackAnim); null when absent or unreadable (no `attackAnim` then, with a warning).
*/
async function loadManifest() {
const abs = join(ROOT, 'data', 'assets.json');
if (!existsSync(abs)) { warn('data/assets.json not found; enemies get no attackAnim'); return null; }
try { return JSON.parse(await readFile(abs, 'utf8')); } catch (e) { warn(`data/assets.json unreadable: ${e.message}`); return null; }
}
// ===== 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' };
/**
* A skill whose rangeId is its new ATTACK range — "攻击范围扩大 / 改变 / 缩小 / 缩短", "攻击距离+1 / 加长 / 缩短", "攻击范围与
* 溅射范围扩大" — and not a 技能范围 of its own (PRTS 卫戍协议/帮助 技能操作: "拥有技能范围的技能(非攻击距离增加)").
* "攻击范围内…" (an effect on the attack range) does not match.
*/
const ATTACK_RANGE_CHANGE = /攻击(?:范围|距离)(?:与溅射范围)?(?:扩大|增大|改变|改为|缩小|缩短|加长|增加|\+)/;
/**
* A deliberate deviation from the official 技能策略 (DESIGN §21.29): the owner's decision of 2026-10-03 after community
* feedback ("反馈的人太多了"; GitHub issue #4, PR #12). The 下半 data's TANK class row (`TANK | | | 0 | TAKE_DAMAGE`,
* added with 下半 — 上半 had no TANK row) makes every MANUAL 重装 skill wait for a hit; these six offensive skills cast
* with an enemy in range instead — the basic strategy, DEFAULT. Keyed by the NORMAL chess id (the elite follows through
* its baseId) and the skill id, never by the skill id alone: 灰毫 S1 skcom_atk_up[3] is a generic skill other chess
* carry too (惊蛰, 幽灵鲨, 耶拉, 莫斯提马, 莱恩哈特). The record keeps the official row in `rawRule` (TAKE_DAMAGE) for
* traceability; validateAll fails the build when an entry no longer meets a TAKE_DAMAGE row or its skill. 深巡 S1
* 侵袭破坏应对 keeps TAKE_DAMAGE.
* 余 S2 厚礼上宾 joined on 2026-10-04 (the owner's decision after GitHub issue #32 item 1, DESIGN §22.10: players saw it fire
* from a ranged hit with nobody to pull). His attack range is his own tile (0-1), so the basic strategy would not see an
* enemy he could pull: his S2 takes SKILL_RANGE on its own 技能范围 (x-1) — the official strategy of a MANUAL skill with a
* 技能范围 when no class row applies, "仅在技能范围内存在敌人(无视其不可选中)时释放技能" — `customRangeGrid` = that range.
* A DEFAULT entry is the basic strategy, so the owner's ACTIVE_RANGE rule applies on top of it (resolveTrigger; the
* owner's decision of 2026-10-05): 深巡 S2, whose running range 3-2 strictly contains her 2-2, casts with an enemy in the
* 3-2 (`rule` ACTIVE_RANGE, `rawRule` still TAKE_DAMAGE).
*/
const TRIGGER_DEVIATIONS = Object.freeze({
chess_char_1_04_a: { skchr_udflow_2: 'DEFAULT' }, // 深巡 S2 行动能力剥夺
chess_char_1_20_a: { skchr_liskam_2: 'DEFAULT' }, // 雷蛇 S2 反击电弧
chess_char_2_18_a: { 'skcom_atk_up[3]': 'DEFAULT', skchr_ashlok_2: 'DEFAULT' }, // 灰毫 S1 攻击力强化·γ型, S2 专注轰击
chess_char_5_08_a: { skchr_horn_2: 'DEFAULT', skchr_horn_3: 'DEFAULT' }, // 号角 S2 暴风号令, S3 终极防线
chess_char_6_03_a: { skchr_yu_2: 'SKILL_RANGE' }, // 余 S2 厚礼上宾 (its x-1)
});
/**
* The same deviation for the 重装 stand-ins (补位 / 自选 prototypes, data/backups.json forms): the owner's decision of
* 2026-10-05 (the approved 补位 plan, handoff 0.2.0-补位方案 §一.5): 预备干员-重装 and Mechanist cast with an enemy in
* range — DEFAULT — instead of the TANK class row's TAKE_DAMAGE. Keyed by the stand-in's charId (a form belongs to no
* chess) and the skill id, every skill of both: a stand-in fields the skill its chess names and a prototype in a 自选
* slot follows the same rule. `rawRule` keeps TAKE_DAMAGE; validateAll fails the build when an entry no longer meets a
* TAKE_DAMAGE row on every form of its unit.
*/
const STANDIN_TRIGGER_DEVIATIONS = Object.freeze({
char_602_cdfend: { 'skcom_def_up[1]': 'DEFAULT', 'skcom_def_up[2]': 'DEFAULT', 'skcom_def_up[3]': 'DEFAULT' }, // 预备干员-重装 防御力强化 α/β/γ
char_610_acfend: { skchr_acfend_1: 'DEFAULT', skchr_acfend_2: 'DEFAULT', skchr_acfend_3: 'DEFAULT' }, // Mechanist S1 结构稳定 / S2 不变性原理 / S3 应力倒置
});
/**
* The attack range a MANUAL skill gives while it runs, when its text changes the attack range (ATTACK_RANGE_CHANGE): its
* own rangeId grid, else the operator's range grown by `ability_range_forward_extend` ("攻击距离+N": 空弦 S3, 史尔特尔
* S2 / S3, 异客 S2 — the kits' targeting.rangeExtend, extendedGrid as Battle._refreshRange); null for any other skill and
* for "被动效果:攻击范围扩大" (引星棘刺 S3: her own range while she carries it — shared/loadoutRecord.js attackRangeGrid).
*/
function activeAttackGrid(skill, baseGrid) {
const desc = skill.desc || '';
if (!ATTACK_RANGE_CHANGE.test(desc) || /被动效果:攻击范围扩大/.test(desc)) return null;
if (skill.rangeGrid?.length) return skill.rangeGrid.map((p) => p.slice());
const n = Math.round(Number(skill.bb?.ability_range_forward_extend) || 0);
return n > 0 && baseGrid?.length ? extendedGrid(baseGrid, n) : null;
}
/** True when grid `a` holds every tile of grid `b` and at least one more (`[dRow, dCol]` pairs, facing RIGHT). */
function strictlyContains(a, b) {
const sa = new Set(a.map(([r, c]) => `${r},${c}`)), sb = new Set(b.map(([r, c]) => `${r},${c}`));
return sa.size > sb.size && [...sb].every((k) => sa.has(k));
}
/**
* The rules the owner's ACTIVE_RANGE replaces (resolveTrigger), both of which wait for an enemy in the INITIAL range:
* DEFAULT, the basic strategy (no row, or a DEFAULT deviation of the tables — 深巡 S2, the owner's decision of
* 2026-10-05), and SEARCH, the 解放者 / 阵法术师 / 安洁莉娜 S2·S3 row "不受基础策略影响,在初始攻击范围内存在敌人时释放技能"
* (the owner's decision of 2026-10-05 too).
*/
const ACTIVE_RANGE_OVER = new Set(['DEFAULT', 'SEARCH']);
/**
* Resolve the auto-cast rule of a skill record (PRTS 卫戍协议/帮助 §作战阶段 技能操作 — the official skill strategies;
* DESIGN §5.6):
* - charId rows first (exact skillIndex, or −1 = every skill of the operator);
* - then the class rows, subProfession before profession. They name whole classes — PRTS "重装干员", "先锋-战术家、
* 先锋-执旗手、辅助-吟游者分支干员", "近卫-解放者、术师-阵法术师分支干员" — so they apply to EVERY skill index: the rows'
* skillIndex 0 is not "skill 1 only" (the 阵法术师 row has to cover 薄绿's default S2 — a phalanx never attacks while
* its skill is off, so the basic strategy could never cast it; "不受技能范围影响" of the 重装 row speaks of their skills
* with a 技能范围, which only S2/S3 have). They apply to MANUAL skills only: the strategies automate the manual 开启,
* an AUTO skill fires by its own rule (PRTS 古米 S1 备注: "此技能在存在生命值不满的可治疗角色时可触发");
* - else, for an operator's MANUAL skill with a 技能范围 (a rangeId that is not an attack-range change): SKILL_RANGE,
* "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能", customRangeGrid = the skill range;
* - else DEFAULT (the basic strategy: ready + about to attack / heal);
* - last, the deliberate deviations (TRIGGER_DEVIATIONS, per chess and skill; STANDIN_TRIGGER_DEVIATIONS, per stand-in
* unit and skill): `rule` from the table, `rawRule` the official row (a SKILL_RANGE deviation takes the skill's own
* range as `customRangeGrid`);
* - and the owner's rule of 2026-10-05, a deliberate deviation like §21.29 (community report 「有的干员开技能后的攻击范围比
* 平时攻击范围大,但是怪走到平时的攻击范围内才会开技能」): an operator's MANUAL skill on the basic strategy (DEFAULT — by
* no row, or by a DEFAULT deviation of the tables: 深巡 S2's 3-2 over her 2-2) or on the SEARCH row (解放者 / 阵法术师 /
* 安洁莉娜: 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2, 安洁莉娜 S3) — ACTIVE_RANGE_OVER, both by the owner's decisions
* of 2026-10-05 — whose attack range while it runs (activeAttackGrid) strictly contains the operator's own range casts as
* soon as an enemy — a heal skill: an injured ally — is inside that larger range: ACTIVE_RANGE, `customRangeGrid` = the
* running range, `rawRule` the official row. Other rows keep their rule (TAKE_DAMAGE waits for a hit, CUSTOM_RANGE /
* SKILL_RANGE / SP_FULL / … have their own range or none).
* @param {object} skill record from buildSkill (skillId, skillType, desc, rangeGrid, bb)
* @param {{operator?: boolean, chessId?: string, unitCharId?: string, baseGrid?: number[][]|null}} opts operator = a
* chess (the 技能范围 strategy is written for 干员; summons keep DEFAULT); chessId = the chess's NORMAL id
* (TRIGGER_DEVIATIONS key); unitCharId = the character of a backups.json unit form (STANDIN_TRIGGER_DEVIATIONS key);
* baseGrid = the operator's own range at that status (ACTIVE_RANGE)
*/
function resolveTrigger(ctx, char, charId, skillIdx, skill, { operator = false, chessId = null, unitCharId = null, baseGrid = null } = {}) {
const rows = Object.values(ctx.ac.skillTriggerDataList || {});
const manual = skill.skillType === 'MANUAL';
const pick =
rows.find((r) => r.charId === charId && (r.skillIndex === skillIdx || r.skillIndex === -1)) ||
(manual && rows.find((r) => !r.charId && r.subProfessionId && r.subProfessionId === char.subProfessionId)) ||
(manual && rows.find((r) => !r.charId && !r.subProfessionId && r.profession === char.profession)) ||
null;
if (!pick && operator && manual && skill.rangeGrid && !ATTACK_RANGE_CHANGE.test(skill.desc || '')) {
return { rule: 'SKILL_RANGE', rawRule: 'DEFAULT', customRangeGrid: skill.rangeGrid.map((p) => p.slice()) };
}
const rawRule = pick ? pick.skillTriggerType : 'DEFAULT';
// the 重装 exception also covers every 自选 重装 (a backups.json unit of the TANK class whose MANUAL skill meets the
// class row's TAKE_DAMAGE casts with an enemy in range — the owner's decision of 2026-10-05, as for the stand-ins)
const deviation = chessId ? TRIGGER_DEVIATIONS[chessId]?.[skill.skillId]
: unitCharId ? STANDIN_TRIGGER_DEVIATIONS[unitCharId]?.[skill.skillId]
?? (char.profession === 'TANK' && manual && rawRule === 'TAKE_DAMAGE' ? 'DEFAULT' : null) : null;
if (deviation === 'SKILL_RANGE') {
if (!skill.rangeGrid) warn(`trigger deviation ${chessId} ${skill.skillId}: SKILL_RANGE without a 技能范围`);
return { rule: deviation, rawRule, customRangeGrid: skill.rangeGrid ? skill.rangeGrid.map((p) => p.slice()) : null };
}
const rule = deviation || TRIGGER_RENAME[rawRule] || rawRule;
if (ACTIVE_RANGE_OVER.has(rule) && operator && manual && baseGrid?.length) {
const active = activeAttackGrid(skill, baseGrid);
if (active && strictlyContains(active, baseGrid)) return { rule: 'ACTIVE_RANGE', rawRule, customRangeGrid: active };
}
if (deviation) return { rule: deviation, rawRule, customRangeGrid: null };
let customRangeGrid = null;
if (rawRule === 'CUSTOM_RANGE_SEARCH_ENEMY') {
const rid = ctx.ac.skillRangeDict?.[skill.skillId];
customRangeGrid = rangeGrid(ctx, rid);
if (!customRangeGrid) warn(`skill ${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 !== 'fortress') || 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,
// the official stand-in fields of the shop row, verbatim (both forms; data/backups.json, shared/standIn.js)
backup: {
charId: shop.backupCharId ?? null, tmplId: shop.backupTmplId ?? null, skillIndex: shop.backupCharSkillIndex ?? null,
uniEquipId: shop.backupCharUniEquipId ?? null, potRank: shop.backupCharPotRank ?? null,
},
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;
// 高台 is not a data flag. The trait line 「可以放置于远程位」 is on every 钩索师 / 推击手 variant and is not
// trusted: only elite 歌蕾蒂娅 carrying HOK-Y may stand there, decided at place time (shared/highGround.js,
// owner's decision 2026-10-04, reversing DESIGN §22.6).
// 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 (resolveTrigger: charId rows by index, class rows for every MANUAL skill, 技能范围, and the
// deliberate deviations of TRIGGER_DEVIATIONS keyed by the normal chess id).
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 s = buildSkill(ctx, se.skillId, skillLevel, null, `chess ${chessId}`);
if (!s) return;
s.trigger = resolveTrigger(ctx, char, shop.charId, i, s, { operator: true, chessId: baseId, baseGrid: rec.rangeGrid });
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 };
}
// ===== backups (补位 stand-ins, 自选 DIY slots) ===================================================
/**
* 自选 picks beyond the 6★ the slot requirement names (diyChessDict says only TIER_6): PRTS 「卫戍协议:盟约
* 下半/PRTS盟约记录」 干员数据库 "可选干员范围为除所有预设干员以外的玩家拥有的六星干员、所有六星原型干员,第5阶可额外从6名四星
* 原型干员(先锋、特种职业除外)中选取" — per slot tier, the extra prototype rarity and the professions left out.
*/
const DIY_EXTRA_PROTOTYPES = Object.freeze({ 5: Object.freeze({ rarity: 4, excludedProfessions: Object.freeze(['PIONEER', 'SPECIAL']) }) });
/**
* 自选 owned picks left out of the data and the pool by the owner's decision of 2026-10-05: the collab operators
* (copyright) — every 6★ whose `mainPower` or a `subPower` names one of these 联动 teams: rainbow (灰烬, 艾拉), action4
* (麒麟R夜刀), mujica (丰川祥子), sees (结城理), laios (玛露西尔). The excel does not exclude them (research 0.2.0 §2.2).
*/
const DIY_EXCLUDED_TEAMS = Object.freeze(['rainbow', 'action4', 'mujica', 'sees', 'laios']);
/**
* …and every 6★ of a 联动寻访, by the prefix of its character_table `displayNumber` (the collab series: MH Monster Hunter —
* 麒麟R夜刀 MH02, 焰狐龙梓兰 MH05, whose team reserve6 names no collab —, RS Rainbow Six, AM Ave Mujica, PS Persona, DD
* Dungeon Meshi); every other prefix in the pool is a faction (LM, NM, RE, RL, …).
*/
const DIY_EXCLUDED_NUMBER_PREFIXES = Object.freeze(['MH', 'RS', 'AM', 'PS', 'DD']);
/**
* The skill a prototype carries in a 自选 slot when no 补位 row of the slot's tier names it (only 预备干员-医疗 at tier 5:
* it stands in at tier 3 only). PRTS 卫戍协议 says the prototypes' "技能携带规则与系统补位时一致"; [ASSUMED] (the owner's
* decision of 2026-10-05) the selection of its 补位 rows at that tier, and for this one S3 — every other 4★ reserve's
* tier-5 row — with no module.
*/
const DIY_PROTOTYPE_FALLBACK_SKILL = 2;
const rarityOf = (char) => Number(String(char?.rarity).replace('TIER_', '')) || null;
const statusCmp = (a, b) => a.phase - b.phase || a.level - b.level || a.skillLevel - b.skillLevel || a.equipLevel - b.equipLevel;
/**
* The character part of a data/backups.json unit (DATA.md §18): who it is, its art ids (the elite form uses the E2 art
* when it exists, as a chess does) and its module names (a composed record's `module` names the module on the normal
* form too, where no module phase is built).
*/
function buildUnitHead(ctx, charId) {
const { charTable, uniequip } = ctx;
const char = charTable[charId];
const moduleNames = {};
for (const id of uniequip.charEquip?.[charId] || []) {
const meta = uniequip.equipDict?.[id];
if (meta) moduleNames[id] = { name: meta.uniEquipName || null, typeName: `${meta.typeName1 || ''}${meta.typeName2 ? '-' + meta.typeName2 : ''}` };
}
return {
charId, name: char.name, appellation: char.appellation, rarity: rarityOf(char), profession: char.profession,
subProfessionId: char.subProfessionId, subProfessionName: uniequip.subProfDict?.[char.subProfessionId]?.subProfessionName || null,
position: char.position, nationId: char.nationId || null, isNotObtainable: !!char.isNotObtainable,
assets: {
avatar: charId, avatarGolden: hasE2Art(ctx, charId, 'avatar') ? `${charId}_2` : charId,
portrait: `${charId}_1`, portraitGolden: `${charId}_${hasE2Art(ctx, charId, 'portrait') ? 2 : 1}`,
spine: charId, subProfIcon: `sub_${char.subProfessionId}_icon`,
},
moduleNames,
};
}
/**
* A unit form (DATA.md §18): the character at one training status with nothing selected — the operator fields of a
* chess record: stats / trait / talents without a module, every skill unlocked at the status at its skill level (the
* trigger resolved per skill as for a chess), its summons, and at `equipLevel > 0` every ADVANCED module of that level.
* The helpers and rules of buildChess; checkUnitFormParity proves the two agree on every PRESET chess.
* @param {{ chessId?: string|null, skillIndex?: number|null }} [opts] `chessId`: the NORMAL chess id of a
* TRIGGER_DEVIATIONS entry (parity check only — a stand-in's form belongs to no chess: its deviations are the
* unit's, STANDIN_TRIGGER_DEVIATIONS by charId); `skillIndex`: a skill listed even when locked (buildChess always
* lists the default)
*/
function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null } = {}) {
const { charTable, uniequip, battleEquip } = ctx;
const char = charTable[charId];
const { phase, level, skillLevel, equipLevel } = status;
const label = chessId ? `chess ${chessId}` : `unit ${charId}@${statusKey(status)}`;
const attrs = interpolateAttrs(char, phase, level);
if (!attrs) warn(`${label}: cannot interpolate attributes`);
const stats = statsFrom(attrs, {});
const rangeId = char.phases?.[phase]?.rangeId || null;
const base = traitRecord(ctx, char, phase, level, [], chessId || charId);
const skills = [];
(char.skills || []).forEach((se, i) => {
if (!se?.skillId || (i !== skillIndex && !unlocked(se.unlockCond, phase, level))) return;
const s = buildSkill(ctx, se.skillId, skillLevel, null, label);
if (!s) return;
s.trigger = resolveTrigger(ctx, char, charId, i, s, { operator: true, chessId, unitCharId: chessId ? null : charId, baseGrid: rangeGrid(ctx, rangeId) });
s.index = i;
s.overrideTokenKey = se.overrideTokenKey || null;
skills.push(s);
});
const talents = mergeTalentChanges(baseTalentList(ctx, char, phase, level, label), []);
const modules = [];
if (equipLevel > 0) {
for (const id of uniequip.charEquip?.[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(`${label}: 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 || charId) : null;
if (tr && JSON.stringify(tr.classify) !== JSON.stringify(base.classify)) warn(`${label}: module ${id} changes the combat classification (not applied by loadouts)`);
modules.push({
uniEquipId: id, name: meta.uniEquipName || null,
typeName: `${meta.typeName1 || ''}${meta.typeName2 ? '-' + meta.typeName2 : ''}`,
typeIcon: meta.typeIcon || null, icon: meta.uniEquipIcon || id, level: equipLevel,
attr: moduleAttr(ph),
traitOverride: tr ? tr.trait : null,
talentChanges: moduleTalentChanges(ctx, parts.op, phase, level, label),
});
}
}
// summons: every id the character lists or a skill / talent / module talent names that character_table has — the
// composed record keeps the ones its selection produces (shared/standIn.js composeUnitRecord)
const displayTokens = Object.keys(char.displayTokenDict || {});
const tokenIds = [...displayTokens, ...skills.map((s) => s.overrideTokenKey), ...talents.map((t) => t.tokenKey),
...modules.flatMap((m) => m.talentChanges.map((t) => t.tokenKey))];
const form = {
status: { phase, level, skillLevel, equipLevel },
stats, immunities: stats ? immunitiesOf(attrs) : null,
rangeId, rangeGrid: rangeGrid(ctx, rangeId),
...base.classify,
trait: base.trait,
skills, talents,
displayTokens,
tokens: [...new Set(tokenIds.filter((id) => id && charTable[id]))].sort(naturalCmp),
};
if (equipLevel > 0) form.modules = modules;
return form;
}
/**
* data/backups.json (DATA.md §18; DESIGN 0.2.0 draft): the data of 补位 and 自选.
* - `units`: every character a 补位 or 自选 piece fields, with a form for every status it fights at. First the 17
* 原型干员 a NORMAL chess names as `backup.charId` (预备干员 char_600–607, the 6★ 罗德岛特派高级资深干员 char_608–615 and
* 领主·Sharp char_617) at the statuses of the chess it stands in for (normal and elite) and of the DIY slots it may fill;
* then every owned-6★ pick (`diy.ownedPool`) at the DIY slot statuses — E2 Lv1 skill rank 4 without a module, E2 Lv60
* rank 7 with every module at stage 1 (tier 5) and at stage 3 (tier 6). No unit for a PRESET chess (特许: always the
* real operator; its backup is itself) or a DIY slot (no backup).
* - `tokens`: the summons of the owned picks (buildDiyTokens: tokens.json records whose `variants` are keyed by owner
* form, `<charId>@<statusKey>`).
* - `diy`: the slots (tier, elite id, the shop level that lists them, the rarity requirement), the prototype picks per
* slot tier (DIY_EXTRA_PROTOTYPES) and the skill / module each carries there (`locked`: its 补位 rows' selection at
* that tier — "技能携带规则与系统补位时一致"), the owned-6★ pool (obtainable, not a roster operator: no chess names it,
* hidden chess included — "不可甄选加入已在名单中的固定干员" — and not a collab, DIY_EXCLUDED_TEAMS: `excluded`) and, for
* every pick, its faction ids (`mainPower` and every `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the
* core bonds whose `powerIdList` meets them, else `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」
* "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的则改为分配协防干员盟约".
*/
function buildBackups(ctx, chess) {
const { act, ac, charTable } = ctx;
const need = new Map(); // charId → Map(statusKey → status)
const addNeed = (charId, st) => {
if (!need.has(charId)) need.set(charId, new Map());
need.get(charId).set(statusKey(st), st);
};
const standsIn = new Map(); // charId → base chess ids
for (const c of Object.values(chess)) {
const b = c.backup;
if (c.chessType !== 'NORMAL' || !b?.charId || b.charId === c.charId) continue;
if (!charTable[b.charId]) { warn(`chess ${c.chessId}: backup ${b.charId} missing from character_table`); continue; }
if (b.potRank !== 0) warn(`chess ${c.chessId}: backup potential ${b.potRank} (stand-in forms are built at potential 0)`);
if (b.tmplId) warn(`chess ${c.chessId}: backup template ${b.tmplId} is not built`);
addNeed(b.charId, c.status);
if (!c.isGolden) standsIn.set(b.charId, [...(standsIn.get(b.charId) || []), c.chessId]);
}
const standInIds = [...standsIn.keys()].sort(naturalCmp);
const shopLevelOf = {};
for (const d of Object.values(act.shopLevelDisplayDataDict || {})) for (const id of d.charChessDiySlotIdList || []) shopLevelOf[id] = d.shopLevel;
const slots = {};
const prototypes = {};
const locked = {};
const requirements = new Set();
const diyStatuses = new Map(); // statusKey → status of every DIY slot record (both forms)
for (const s of Object.values(chess).filter((c) => c.isDiy && !c.isGolden).sort((x, y) => naturalCmp(x.chessId, y.chessId))) {
const golden = chess[s.goldenId];
if (!golden) continue;
requirements.add(s.diyRequirement);
slots[s.chessId] = { tier: s.tier, goldenId: s.goldenId, shopLevel: shopLevelOf[s.chessId] ?? null, requirement: s.diyRequirement };
for (const rec of [s, golden]) diyStatuses.set(statusKey(rec.status), rec.status);
const extra = DIY_EXTRA_PROTOTYPES[s.tier];
const picks = standInIds.filter((id) => {
const ch = charTable[id];
return ch.rarity === s.diyRequirement || (!!extra && rarityOf(ch) === extra.rarity && !extra.excludedProfessions.includes(ch.profession));
});
if (prototypes[s.tier] && JSON.stringify(prototypes[s.tier]) !== JSON.stringify(picks)) warn(`DIY tier ${s.tier}: slots disagree on the prototype picks`);
prototypes[s.tier] = picks;
for (const id of picks) { addNeed(id, s.status); addNeed(id, golden.status); }
// the skill / module a prototype carries in a slot of this tier: its 补位 rows' (the NORMAL base chess of the tier
// whose backup it is), else DIY_PROTOTYPE_FALLBACK_SKILL without a module [ASSUMED]
locked[s.tier] = {};
for (const id of picks) {
const rows = Object.values(chess).filter((c) => c.chessType === 'NORMAL' && !c.isGolden && c.tier === s.tier && c.backup?.charId === id)
.sort((x, y) => naturalCmp(x.chessId, y.chessId));
const kinds = new Set(rows.map((c) => `${c.backup.skillIndex}|${c.backup.uniEquipId ?? ''}`));
if (kinds.size > 1) warn(`DIY tier ${s.tier} prototype ${id}: its 补位 rows carry different selections (${[...kinds].join(', ')}); the first row's is used`);
locked[s.tier][id] = rows.length
? { skillIndex: rows[0].backup.skillIndex, uniEquipId: rows[0].backup.uniEquipId ?? null, from: rows.map((c) => c.chessId) }
: { skillIndex: DIY_PROTOTYPE_FALLBACK_SKILL, uniEquipId: null, from: [] };
}
}
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const teamsOf = (ch) => [ch.mainPower?.teamId ?? ch.teamId, ...(ch.subPower || []).map((p) => p?.teamId)].filter(Boolean);
const excludedTeams = new Set(DIY_EXCLUDED_TEAMS);
const legal6 = Object.keys(charTable).filter((id) => {
const ch = charTable[id];
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
&& !ch.isNotObtainable && !roster.has(id);
}).sort(naturalCmp);
const collabNumber = (ch) => DIY_EXCLUDED_NUMBER_PREFIXES.some((p) => new RegExp(`^${p}\\d`).test(ch.displayNumber || ''));
const excluded = legal6.filter((id) => teamsOf(charTable[id]).some((t) => excludedTeams.has(t)) || collabNumber(charTable[id]));
for (const t of DIY_EXCLUDED_TEAMS) if (!excluded.some((id) => teamsOf(charTable[id]).includes(t))) warn(`DIY_EXCLUDED_TEAMS: no owned-6★ pick of team ${t}`);
const ownedPool = legal6.filter((id) => !excluded.includes(id));
for (const id of ownedPool) for (const st of diyStatuses.values()) addNeed(id, st);
const units = {};
for (const id of [...standInIds, ...ownedPool]) {
const unit = buildUnitHead(ctx, id);
unit.standsIn = (standsIn.get(id) || []).sort(naturalCmp);
unit.forms = {};
for (const st of [...need.get(id).values()].sort(statusCmp)) {
const form = buildUnitForm(ctx, id, st);
if (form.tokens.length && unit.standsIn.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`);
unit.forms[statusKey(st)] = form;
}
units[id] = unit;
}
for (const id of need.keys()) if (!units[id]) warn(`unit ${id}: needed but neither a stand-in nor an owned pick`);
const tokens = buildDiyTokens(ctx, units, ownedPool);
const coreBonds = Object.values(ac.bondInfoDict || {}).filter((b) => b.isPower && (b.powerIdList || []).length);
const fallback = act.constData.fallbackBondId;
const operators = {};
for (const id of [...new Set([...ownedPool, ...Object.values(prototypes).flat()])].sort(naturalCmp)) {
const ch = charTable[id];
const powers = [];
for (const p of [ch.mainPower ?? { nationId: ch.nationId, groupId: ch.groupId, teamId: ch.teamId }, ...(ch.subPower || [])]) {
for (const k of ['nationId', 'groupId', 'teamId']) if (p?.[k] && !powers.includes(p[k])) powers.push(p[k]);
}
const hit = coreBonds.filter((b) => b.powerIdList.some((x) => powers.includes(x))).map((b) => b.bondId);
operators[id] = {
name: ch.name, rarity: rarityOf(ch), profession: ch.profession, subProfessionId: ch.subProfessionId,
obtainable: !ch.isNotObtainable, powers, bonds: hit.length ? hit : [fallback],
};
}
return { units, tokens, diy: { slots, prototypes, locked, ownedPool, excluded, operators } };
}
/**
* The stand-in forms come from buildUnitForm, not buildChess: prove the two agree. Every PRESET chess is its own backup
* (charShopChessDatas: backupCharId = charId with the default skill and module), so its own unit form composed with
* that selection (shared/standIn.js composeUnitRecord) must give back the chess record field for field — a chess field
* that is neither an identity field (IDENTITY_FIELDS) nor set by composeUnitRecord fails here as well.
* @returns {string[]} errors
*/
function checkUnitFormParity(ctx, chess) {
const errors = [];
for (const c of Object.values(chess)) {
if (c.chessType !== 'PRESET' || c.backup?.charId !== c.charId) continue;
if (c.backup.skillIndex !== c.skill?.index || (c.backup.uniEquipId ?? null) !== (c.module?.id ?? null)) {
errors.push(`unit parity ${c.chessId}: the backup skill / module differ from the chess default`);
continue;
}
const form = buildUnitForm(ctx, c.charId, c.status, { chessId: c.baseId, skillIndex: c.skill.index });
const rec = composeUnitRecord(c, buildUnitHead(ctx, c.charId), form, { skillIndex: c.backup.skillIndex, moduleId: c.backup.uniEquipId });
if (!isDeepStrictEqual(rec, c)) {
const keys = [...new Set([...Object.keys(rec || {}), ...Object.keys(c)])].filter((k) => !isDeepStrictEqual(rec?.[k], c[k]));
errors.push(`unit parity ${c.chessId}: buildUnitForm + composeUnitRecord differ from buildChess in ${keys.join(', ')}`);
}
}
return errors;
}
// ===== 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 skill = sId ? buildSkill(ctx, sId, skillLevel, null, label) : null;
if (skill) {
skill.trigger = resolveTrigger(ctx, char, tokenId, sIdx, 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,
};
}
/**
* Abnormal effects (异常效果) summons hold from the start that no official table carries — the PRTS summon pages
* (召唤物 备注 "持有…"; user playtest #6 item 18). tokens.json `abnormal`; the sim gives the unit the matching flags
* (Battle._setupUnit):
* healFree — 禁疗 (HEAL_FREE, PRTS 异常效果 "无法成为治疗类能力的目标,且受到的治疗量变为0"): “小自在”, “耀阳”, 斯卡蒂的海嗣,
* 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态) (圣聆初雪 S2's frozen target);
* isolated — 孤立 (ALLY_TARGET_FREE, "无法被同阵营选中": no heal and no ally selection reaches it): “炎佑” (PRTS “炎佑”
* 天赋 "特殊机制|我方单位,孤立,可同时攻击3个目标"), 从不混淆的方向 (备注 "持有无敌、孤立…").
*/
const TOKEN_ABNORMAL = Object.freeze({
token_10015_dusk_drgn: ['healFree'], // “小自在”
token_10019_nearl2_sword: ['healFree'], // “耀阳”
token_10017_skadi2_dedant: ['healFree'], // 斯卡蒂的海嗣 (also 无敌)
token_10011_beewax_oblisk: ['healFree'], // 沙之碑
token_10030_mlyss_wtrman: ['healFree'], // 流形
token_10028_vigil_wolf: ['healFree'], // 狼群
token_10012_rosmon_shield: ['healFree'], // 迷迭香的战术装备
token_10040_siege2_vlion: ['healFree'], // 黄金盟誓
token_10058_sbell2_icetgt: ['healFree'], // 保护目标(冻结状态) (圣聆初雪 S2; also 无法撤退)
token_10039_ulpia_block: ['isolated'], // 从不混淆的方向 (also 无敌)
enemy_9012_acloon: ['isolated'], // “炎佑”
});
/**
* The tokens.json record of a summon (buildTokens; the 自选 picks' summons, buildDiyTokens) from its per-owner
* `variants` (key → variant; `owners` = the keys, in order; the defaults are the first owner's variant).
* Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
* §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
* state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
* 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
* 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
* placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
* missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
* 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
* 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
* (`produced`: a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant
* (`sources`, `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
* takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
* SKILL_SUMMON_START_DEPLOY).
* `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
* 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
* ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere).
* `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function summonRecord(ctx, tokenId, char, variants, produced) {
const displayType = ctx.ac.shopStateTokenDict?.[tokenId]?.tokenDisplayType || null;
const owners = Object.keys(variants);
const first = variants[owners[0]];
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
return {
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, placeable: displayType !== 'HIDDEN' && produced, ownerRange,
owners,
// Defaults = first owner's variant; per-owner data in variants[owner].
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,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
}
/**
* The summons of the 自选 owned picks (data/backups.json `tokens`, DATA.md §18): one summonRecord per token whose
* `variants` are keyed by the owner FORM, `<charId>@<statusKey>` (shared/diy.js diyTokenOwner), never by a chess id — a
* DIY piece is a slot, and two players may fill the same slot with different operators. A variant is the token at the
* owner's status (the owner's phase / level / skill level, as for a chess owner) for the owner's FIRST skill and no
* module; `bySkill[i]` the token skill, count and sources under each other skill, `byModule[id]` the module token
* attributes / trait / talents under each module of the form — the chess variant shape (DESIGN §16), applied with the
* pick as the owner's loadout (simdata getToken, content/tokens.js withLoadout). `count` / `sources` come from the
* owner's no-module talents and the selected skill, as buildChess's `tokenUse`.
* @param {object} units backups `units`
* @param {string[]} owned the owned-6★ picks (their forms are the DIY slot statuses)
*/
function buildDiyTokens(ctx, units, owned) {
const { charTable, battleEquip } = ctx;
const owners = new Map(); // tokenId → [{ key, variant, produced }]
const makes = (list) => list.includes('talent') || list.includes('skill');
for (const charId of owned) {
for (const [key, form] of Object.entries(units[charId]?.forms || {})) {
if (!form.tokens.length) continue;
const { phase, level, skillLevel, equipLevel } = form.status;
/** token id → its sources under `skill` (buildChess tokenUse), and the count a talent / the skill sends */
const tokenUse = (skill) => {
const skTok = skill?.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 form.displayTokens) add(id, 'display');
if (skTok) add(skTok, 'skill');
for (const t of form.talents) if (t.tokenKey) add(t.tokenKey, 'talent');
const count = (id) => {
const tal = form.talents.find((t) => t.tokenKey === id && typeof t.bb.cnt === 'number');
const skillCnt = skTok === id ? skill?.bb?.cnt : undefined;
return tal ? tal.bb.cnt : typeof skillCnt === 'number' ? skillCnt : null;
};
const sources = (id) => ['talent', 'skill', 'display'].filter((x) => use.get(id)?.has(x));
return { count, sources };
};
const [first, ...alts] = form.skills;
for (const tokenId of form.tokens) {
const tchar = charTable[tokenId];
const label = `token ${tokenId}@${charId}@${key}`;
const at = (o) => tokenVariant(ctx, tokenId, tchar, { phase, level, skillIndex: first.index, skillLevel, modulePhase: null, moduleTokenParts: [], label, ...o });
const u0 = tokenUse(first);
const v = { ...at({}), count: u0.count(tokenId), sources: u0.sources(tokenId) };
let produced = makes(v.sources);
if (alts.length) {
v.bySkill = {};
for (const s of alts) {
const u = tokenUse(s);
v.bySkill[s.index] = { skill: at({ skillIndex: s.index }).skill, count: u.count(tokenId), sources: u.sources(tokenId) };
produced = produced || makes(v.bySkill[s.index].sources);
}
}
if (equipLevel > 0 && form.modules?.length) {
v.byModule = {};
for (const m of form.modules) {
const ph = battleEquip[m.uniEquipId]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
const tv = at({ modulePhase: ph, moduleTokenParts: splitModuleParts(ph).token });
v.byModule[m.uniEquipId] = { stats: tv.stats, immunities: tv.immunities, trait: tv.trait, talents: tv.talents };
}
}
if (!owners.has(tokenId)) owners.set(tokenId, []);
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced });
}
}
}
const out = {};
for (const tokenId of [...owners.keys()].sort(naturalCmp)) {
const list = owners.get(tokenId);
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced));
}
return out;
}
/**
* Build data/tokens.json: summons of chess (per-owner variants), bond summons (炎佑) and band map
* characters (band_amedic 预备干员-医疗 / Touch). `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function buildTokens(ctx, chess, tokenOwners, enemies) {
const { charTable } = ctx;
const out = {};
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 makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced);
}
// 炎佑 (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, ownerRange: 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,
abnormal: [...TOKEN_ABNORMAL.enemy_9012_acloon], 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, ownerRange: 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, abnormal: [], 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 ====================================================================================
/**
* Items the official shop (调度中心) never sells although trapShopChessDatas lists them with `hideInShop` false: they
* only come from effects (user playtest #4, first-hand: "几个特殊的维式重锤是干员洛洛或者维多利亚阵营获得的,商店是不卖的。
* 变异针…也是有一个策略自带的,商店不卖"). The official shop pool is server-side (no client table tells), so the list is
* explicit, by normal item id → where the item comes from. items.json marks both qualities `shopExcluded` (+
* `shopExcludedBy`), and every draw of "shop items" skips them (sim/simdata.js isShopItem: the shop item slot, the
* 道具补给 / 机密商店 cards, pool_equip_normal / _shop_1 / _kathe / _narant):
* 战栗 / 坚固 / 加速 / 灼燃维式重锤 — 维多利亚 <每叠加25层> "获得一件带有随机特殊效果的维式重锤" (pool_equip_vict) and
* 洛洛 特质 "<获得时>随机制造1件洛洛的定制品" (pool_equip_rockr)
* 突变细胞 — strategy 昆图斯 【不稳定要素】 "第3回合获得1件特殊装备<突变细胞>" (band_quintus)
* Every other effect-granted item stays sold: the plain 维式重锤 is (user), and 变形同构体 / 骑士储蓄罐 (strategy items)
* appear in the official 机密商店 supply (Bahamut bsn=33651 snA=12294 screenshot i.meee.com.tw/Mb2mtd9.png).
*/
const SHOP_EXCLUDED_ITEMS = Object.freeze({
chess_item_2_03_e_a: '维多利亚盟约每25层 / 洛洛的定制品', // 战栗维式重锤
chess_item_3_09_e_a: '维多利亚盟约每25层 / 洛洛的定制品', // 坚固维式重锤
chess_item_3_10_e_a: '维多利亚盟约每25层 / 洛洛的定制品', // 加速维式重锤
chess_item_4_09_e_a: '维多利亚盟约每25层 / 洛洛的定制品', // 灼燃维式重锤
chess_item_5_08_e_a: '策略【不稳定要素】(昆图斯)', // 突变细胞
});
/**
* Rules the official item text leaves out, by normal item id (both qualities): `note` = a player-facing line shown under
* the effect in the item card (items.json `note`), `implFormula` replaces research 04's formula.
* 突变细胞 — not consumed (player feedback after 0.1.0): PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原干员销毁,
* 获得一名高一阶的随机初始干员(最高六阶)", and a destroyed operator's equipment comes off (PRTS 卫戍协议/帮助 "佩戴的
* 装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)或装备合并为进阶品质时自动卸除"); the text never says 销毁 for
* the cell (every consumable item's does), and players re-inject it every round ("之后就是一直打针,扎到核心卡或者叠层
* 手干员就换人扎", bilibili cv47000418; "这个道具可以无限使用", cg.163.com guide 2025-11-15). The new operator is gained
* into the 整备区, never onto the carrier's tile — official footage (bilibili BV1vzyVBuEN9 ≈ 8:24, BV1Qkw1zMEoR ≈ 7:25):
* at the next prep the tile is empty, one more deployment is left and the new operator waits on the bench (PRTS 帮助:
* what a player gains goes to the 手牌区; pointed out in PR #2).
*/
const ITEM_RULES = Object.freeze({
chess_item_5_08_e_a: {
note: '生效时原干员销毁,突变细胞与其他装备退回整备区,可再次配发;随后获得一名高一阶的随机初始干员(最高6阶),进入整备区,需要重新部署',
implFormula: 'After the battle: the carrier is destroyed wherever it stands (a board tile is freed); its equipment, the '
+ 'cell included, returns to the hand first (overflow temp; the cell is not consumed); then a random NORMAL operator '
+ 'one tier higher (max 6; an elite carrier too) is gained like any gained operator: the hand, overflow temp, a '
+ 'completed merge as usual (the elite on a consumed deployed copy\'s tile, never the carrier\'s). Never merges.',
},
});
/**
* 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;
const excluded = Object.hasOwn(SHOP_EXCLUDED_ITEMS, baseId) ? SHOP_EXCLUDED_ITEMS[baseId] : null;
const rule = Object.hasOwn(ITEM_RULES, baseId) ? ITEM_RULES[baseId] : null;
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,
shopExcluded: !!excluded, shopExcludedBy: excluded,
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: rule?.implFormula || ri?.implFormula || null,
note: rule?.note || 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. `bondIds` (the bonds a strategy is built around: shared/bandBonds.js
* over its text and blackboards, needing bonds.json and choices.json pools) is added in main() once those are built.
*/
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;
}
/**
* enemy_database attribute → enemies.json stat. The two element resistances (PRTS 元素 / 游戏数据基础 / enemy pages):
* `epResistance` (EP_RESISTANCE) = 损伤抵抗, % off element gauge fills → `elementRes`; `epDamageResistance` = 元素抗性,
* % off 元素伤害 (element HP damage) → `elementDmgRes` (sim: server/sim/damage.js).
*/
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;
}
/**
* Huge units' hit areas (巨型单位:受击判定区域) by prefab (enemy_database `prefabKey`: a season `_2` copy with the same
* prefab shares it) → enemies.json `hitArea`: a rectangle `w` tiles along the columns (长) × `h` along the rows (宽),
* centred on the unit's position moved `dx` columns right and `dy` rows up (row 0 = the bottom). The collider lives in
* the prefab, not in the game tables — values from PRTS (user playtest #5 item 10):
* 假想敌:胄 / 假想敌:管 / 盐风主教昆图斯 "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0";
* 假想敌:管 隐秘核心 (enemy_9021_acduml_2, its own prefab) "…向上偏移1.0,向右偏移1.0";
* 卫戍协议:盟约 下半/PRTS盟约记录 "卫戍协议中的阿利斯泰尔,帝国余晖和“萨米的意志”为特殊版本,拥有与普通版本不同的受击判定
* 区域(为长4.95宽2.95的长方形,向上偏移1)".
* [ASSUMED] 胄's hidden-core copy (enemy_9013_acstmk_2, prefab enemy_9013_acstmk) keeps the upward offset although its
* PRTS section lists none — one prefab, one collider. 假想敌:铳 and 卢西恩 are regular units (no such talent).
*/
const HIT_AREAS = Object.freeze({
enemy_9013_acstmk: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9021_acduml: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9021_acduml_2: { w: 4.95, h: 2.95, dx: 1, dy: 1 },
enemy_1521_dslily: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9032_aclionk: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9033_acdeer: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
});
/**
* 静态刚体 (static rigidbody) enemies → enemies.json `staticBody: true`: pushes and pulls never move them (player report
* after 0.1.0, "飞机可以被薄绿的技能拉走"). PRTS 特殊机制 静态刚体: "该单位的Unity刚体的刚体类型为部分静态(Kinematic)或静态
* (Static)。使用该类刚体的单位可以进入失衡状态并启用物理,但物理层面上无法产生任何速度或移动 … ※与失衡免疫不同 … ※是否为静态
* 刚体与单位的行动方式无关" (example: 妖怪). Like HIT_AREAS the rigidbody lives in the prefab, not in the game tables: the
* keys whose PRTS page lists "{{特殊机制|静态刚体}}" in its 天赋 (the page of every enemy of data/enemies.json, read 2026-10-03) —
* every air unit of the mode except “炎佑” (its page lists none; weight 10, so no push or pull moves it anyway), plus
* the ground boss 盐风主教昆图斯.
*/
const STATIC_BODIES = Object.freeze(new Set([
'enemy_1005_yokai', 'enemy_1005_yokai_2', 'enemy_1005_yokai_3', // 妖怪 / 妖怪MKII / 威龙
'enemy_1017_defdrn', 'enemy_1040_bombd', 'enemy_1041_lazerd', 'enemy_1041_lazerd_2', 'enemy_1042_frostd', // 御4 / 暴鸰 / 法术大师A1 / A2 / 寒霜
'enemy_1112_emppnt', 'enemy_1112_emppnt_2', // 帝国炮火先兆者 / 帝国炮火中枢先兆者
'enemy_1269_nhfly', 'enemy_1321_wdarft', // 枯朽之种 / 枯朽萃聚使徒
'enemy_1355_mrfly', 'enemy_1355_mrfly_2', 'enemy_1407_hummbd', // 护障 / 护障·P / 远眺
'enemy_1430_lrrook', 'enemy_1521_dslily', // 愧悔魂灵圣杯 / 盐风主教昆图斯 (WALK)
'enemy_9009_acfort', 'enemy_9014_acstma', 'enemy_9015_acstmb', 'enemy_9016_acstmr', // 假想敌:黑云 / “斩胄之剑” / “破胄之锤” / 刺胄之弹
'enemy_10028_vtswd', 'enemy_10029_vtshld', 'enemy_10030_vtwand', // 未装配刀片 / 防护背心 / 冲击式施术单元
'enemy_10040_cnvbln', // 节日气球
'enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2', // “萨科塔之翼” / “萨科塔之眼” / “萨科塔昂首”
]));
/**
* Official drawn size of enemy models (user playtest #6 item 9: 威龙 far too large) → enemies.json `modelScale`.
* The official client scales every Spine model in its battle prefab (`dyn/battle/prefabs/enemies/<prefab>.prefab`,
* bundles battle/enm_pfb_*.ab): world size = skeleton units × SkeletonDataAsset.scale (0.01 for all 1731 enemy
* skeletons, refs/arts/enm_art_*.ab) × the transform scale of Graphic / FaceSwitcher / Spine above the renderer. That
* transform product is MODEL_SCALE_STANDARD = 0.27 for 1454 of the 2147 enemy prefabs (2080 have exactly one Spine
* renderer; also the FaceSwitcher of the operators' battle skins), and differs for the rest: the drones are shrunk (威龙 0.16 — its skeleton is 35 % wider than
* 妖怪's, drawn at 0.20), the small 岁 relics enlarged (铜灯盘 0.5, 青铜镜 0.6). The renderer draws every skeleton at one
* UNIT.modelScale, so an enemy's `modelScale` = its prefab's product / 0.27 (4 decimals; absent when 1).
* Values: tools/local-extract/enemy_scales.py over the local client (2026-10-01), keyed by prefab (enemy_database
* `prefabKey`, "enemy_" dropped) and grouped by the product; every other prefab of data/enemies.json is 0.27.
*/
const MODEL_SCALE_STANDARD = 0.27;
const MODEL_SCALES = new Map([
[0.16, ['1005_yokai_3']],
[0.18, ['1042_frostd']],
[0.19, ['1112_emppnt', '1112_emppnt_2']],
[0.2, ['1005_yokai', '1040_bombd', '1041_lazerd', '1041_lazerd_2']],
[0.216, ['1067_snslime']],
[0.22, ['1005_yokai_2', '1017_defdrn']],
[0.23, ['1158_divman', '1161_tidmag', '1161_tidmag_2']],
[0.24, ['1009_lurker', '1019_jshoot', '1019_jshoot_2', '1043_zomsbr', '1071_dftman', '1072_dlancer', '1116_liprr', '1116_liprr_2',
'1118_lidbox_2', '1160_hvyslr', '1160_hvyslr_2', '1162_magmot', '1165_duhond', '1165_duhond_2', '1168_dumage', '1168_dumage_2',
'1183_mlasrt', '1195_sfyin', '1195_sfyin_2', '1197_sfshu', '1197_sfshu_2', '1199_sfjin', '1203_sfhu', '1203_sfhu_2', '1207_sfji',
'1207_sfji_2', '1209_sfden', '1209_sfden_2', '1267_nhpbr', '1267_nhpbr_2', '1269_nhfly', '1270_nhstlk', '1270_nhstlk_2',
'1272_nhtank', '1272_nhtank_2', '1273_stmgun_2', '1275_dwlock_2', '1500_skulsr', '2002_bearmi', '2003_rockman', '2004_balloon',
'2005_axetro', '2008_flking', '2034_sythef']],
[0.25, ['1166_dusbr', '1166_dusbr_2', '1169_duphlx', '1169_duphlx_2', '1229_darmy', '1229_darmy_2']],
[0.26, ['1000_gopro_2', '1023_jmage', '1025_reveng', '1026_aghost', '1046_agent', '1249_lysdb_2', '1251_lysyta', '1251_lysyta_2',
'1252_lysytb_2', '1254_lypa_2', '1283_sgkill', '1283_sgkill_2', '1516_jakill', '1517_xi', '2001_duckmi']],
[0.28, ['1006_shield', '1010_demon', '1010_demon_2', '1061_zomshd', '1062_rager_2', '1069_icebrk_2', '1119_vofsd', '1170_dushld',
'1170_dushld_2', '1172_dugago', '1172_dugago_2', '1174_duholy', '1174_duholy_2', '1175_dushdo_2', '2025_syufo']],
[0.29, ['1081_sotisd', '1513_dekght', '1513_dekght_2']],
[0.297, ['2009_csaudc']],
[0.3, ['1001_bigbo', '1045_hammer', '1045_hammer_2', '1121_lifbos', '1121_lifbos_2', '1501_demonk', '1535_wlfmster']],
[0.31, ['1006_shield_2']],
[0.34, ['1006_shield_3']],
[0.35, ['1092_mdgint']],
[0.4, ['1196_msfyin', '1196_msfyin_2', '1198_msfshu', '1198_msfshu_2', '1202_msfzhi', '1202_msfzhi_2']],
[0.5, ['1208_msfji', '1208_msfji_2', '1210_msfden', '1210_msfden_2']],
[0.6, ['1200_msfjin', '1200_msfjin_2', '1204_msfhu', '1204_msfhu_2']],
]);
const MODEL_SCALE_BY_PREFAB = new Map();
for (const [v, list] of MODEL_SCALES) for (const k of list) MODEL_SCALE_BY_PREFAB.set(`enemy_${k}`, Math.round((v / MODEL_SCALE_STANDARD) * 1e4) / 1e4);
/**
* An enemy's attack clip → enemies.json `attackAnim` { clip, dur, hit } (GitHub #58: an unblocked ranged enemy stands for
* its attack clip, server/sim/ai.js attackStand): the clip the client plays for its attacks (the asset manifest's
* `anims.attack.loop` of the enemy's model — not an Idle stand-in, `via: 'idle'`), its length and its first strike
* frame (`hits`: the clip's OnAttack event; absent when it has none — the sim then takes half the clip). Read from the
* committed data/assets.json (written by tools/fetch-assets.mjs from the Spine skeletons; docs/ASSETS.md), so the sim
* never reads client files; an enemy whose model the manifest lacks gets none (the sim falls back to ATTACK_PAUSE).
*/
function enemyAttackAnim(manifest, spineId) {
const sp = manifest?.enemies?.[spineId]?.spine;
const a = sp?.anims?.attack;
if (!a || typeof a.loop !== 'string' || a.via === 'idle') return null;
const dur = sp.animations?.[a.loop];
if (!(typeof dur === 'number' && dur > 0)) return null;
const h = sp.hits?.[a.loop];
const hit = Array.isArray(h) && Number.isFinite(h[0]) ? Math.min(dur, Math.max(0, h[0])) : null;
return hit != null ? { clip: a.loop, dur, hit } : { clip: a.loop, dur };
}
/** The handbook's 「不停止移动」 attack (“十字路口”量产型's 四向攻击) → enemies.json `attackMoves`: it never stops to attack. */
const attacksOnTheMove = (abilities) => abilities.some((a) => a.text.includes('不停止移动'));
/**
* The 鸭爵 strategy's swapped-in enemies (`round_start_all_player_change_enemy_2` enemylist — the act2 versions, *_2)
* cost BAND_SWAP_LPR at the protection point, not the database's lifePointReduce 0 (the roguelike 宝藏 rule): PRTS
* 卫戍协议:盟约 下半/PRTS盟约记录 §策略 鸭爵 备注 "…但进入保护目标点将减少1点目标生命值,且在最终回合和隐秘核心回合中仍然生效"
* (player feedback after 0.1.0, #7). Normal rounds count leaks whatever their `lpr`; the Final Assault / Hidden Core
* LP and the detail card's 目标价值 read it.
*/
const BAND_SWAP_LPR = 1;
function bandSwapEnemyKeys(act) {
const out = new Set();
for (const buffs of Object.values(act.effectBuffInfoDataDict || {})) for (const b of buffs || []) {
if (b?.key !== 'round_start_all_player_change_enemy_2') continue;
for (const kv of b.blackboard || []) if (kv.key === 'enemylist') for (const k of String(kv.valueStr || '').split(',')) if (k.trim()) out.add(k.trim());
}
return out;
}
/**
* 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 swapKeys = bandSwapEnemyKeys(act);
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),
};
if (swapKeys.has(key)) stats.lpr = BAND_SWAP_LPR;
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);
const hitArea = HIT_AREAS[mv(data.prefabKey) || key] || null;
const modelScale = MODEL_SCALE_BY_PREFAB.get(mv(data.prefabKey) || key) ?? null;
const attackAnim = enemyAttackAnim(ctx.manifest, mv(data.prefabKey) || key);
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}`),
// SP pool of SP-cost skills (e.g. 假想敌:黑云 技力上限 3 = 全弹发射 hits); only when the database has one
...(data.spData ? { sp: { type: data.spData.spType ?? null, maxSp: data.spData.maxSp ?? 0, initSp: data.spData.initSp ?? 0, increment: data.spData.increment ?? 0 } } : {}),
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,
...(hitArea ? { hitArea: { ...hitArea } } : {}),
...(STATIC_BODIES.has(key) ? { staticBody: true } : {}),
...(modelScale != null && modelScale !== 1 ? { modelScale } : {}),
...(attackAnim ? { attackAnim } : {}),
...(attacksOnTheMove(abilities) ? { attackMoves: true } : {}),
};
}
for (const k of STATIC_BODIES) if (!out[k]) warn(`STATIC_BODIES: ${k} is not an enemy of the mode`);
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']);
/**
* The deploy type the game applies to a level tile: its buildableType, except tiles whose mechanism refuses deployment
* (server/sim/grid.js DEPLOY_REFUSED_TILES — 深水区 tile_deepsea: PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)";
* player report after 0.1.0: operators could be placed in 战场#08's pool), which are NONE. The stages.json legend's
* `buildable` is this value; `buildableType` keeps the level's own where they differ.
*/
const effectiveBuildable = (t) => (DEPLOY_REFUSED_TILES.has(t.tileKey) ? 'NONE' : t.buildableType);
/**
* 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;
}
/**
* The 联防 maps' names: the tables and PRTS name neither map, so the label is the remake's own [ASSUMED] (PRTS
* 卫戍协议/帮助 §联防阶段 calls the joined field "一处阵地"); no screen shows it today.
*/
const UNITE_STAGE_NAMES = { 1: '联防阵地(1名玩家)', 2: '联防阵地(2名玩家)' };
/**
* Build data/stages.json: 19×21 terrain grids (row 0 = bottom), legend, devices, special terrain
* parameters, deployable tiles and helper ground paths — the 11 battle stages of stageDatasDict, then the two 联防
* maps: act2autochess constData escapedBattleTemplateMapSinglePlayer / MultiPlayer name the level of the 联防 battle
* (level_act1autochess_escaped_single / _multi), whose own map is the 联防 field — a road, not the round's stage (GitHub
* #41). They carry `kind: 'unite'` and `helpers` (1 / 2), weight 0, no modes, and are never drawn as a match stage.
*/
function buildStages(ctx, modesById) {
const researchStages = ctx.research.maps?.stages || {};
const out = {};
const unite = [['escapedBattleTemplateMapSinglePlayer', 1], ['escapedBattleTemplateMapMultiPlayer', 2]]
.filter(([k]) => ctx.act.constData[k]).map(([k, helpers]) => ({ stageId: templateIdOf(ctx.act.constData[k]), helpers }));
for (const { stageId, helpers } of [...ctx.stageIds.map((id) => ({ stageId: id, helpers: 0 })), ...unite]) {
const sd = helpers ? null : 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);
const buildable = effectiveBuildable(t);
glyphTiles[g] = {
tileKey: t.tileKey, height: t.heightType === 'HIGHLAND' ? 'HIGH' : 'LOW', buildable,
...(buildable !== t.buildableType ? { buildableType: 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');
// PRTS 沼泽控制: a unit in the mire triggers 【陷入沼泽】 "每秒…一次" (the device skill's charge time, spData
// maxChargeTime 1) and an enemy "若其重量大于等于3,改为获得2层" — the skill's `value` (3) is that 重量, not an
// interval; "上述减益于单位不再位于沼泽之中时解除" (clearedOnLeave)
const mireSk = d?.skill ? ctx.skillTable[d.skill.skillId]?.levels?.[Math.max(0, (d.skill.level || 1) - 1)] : null;
const charge = mireSk?.spData?.maxChargeTime;
special.mire = { source: d ? d.key : null, intervalSec: typeof charge === 'number' && charge > 0 ? charge : 1, aspdPerStack: d?.skill?.bb?.attack_speed ?? -0.05, moveMulPerStack: d?.skill?.bb?.move_speed ?? -0.05, maxStacks: d?.skill?.bb?.max_stack_cnt ?? 10, heavyWeight: d?.skill?.bb?.value ?? 3, 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, a 特制水上平台
// makes its 深水区 tile deployable for any unit: "在水上建立可以部署任意单位的平台", sktok_canoe_1).
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 waterPlatformAt = new Set(devices.filter((d) => d.active && d.role === 'waterPlatform').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 bt = effectiveBuildable(t);
const buildable = bt !== '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 (waterPlatformAt.has(k)) { melee.push([r, c]); if (!buildable) byDevice.push([r, c]); continue; }
if (t.heightType === 'LOWLAND' && (bt === 'ALL' || bt === 'MELEE')) melee.push([r, c]);
else if (bt === 'RANGED' || (t.heightType === 'HIGHLAND' && bt === '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: helpers ? UNITE_STAGE_NAMES[helpers] : stageDisplayName(stageId, researchStages[stageId]?.name),
weight: sd ? sd.weight : 0, active: sd ? sd.weight > 0 : false, ...(helpers ? { kind: 'unite', helpers } : {}),
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 };
/**
* Why a bounty card is NOT offered by the 机变 悬赏决策 draft (null = it is), user playtest #6 item 4. The draft's card
* pools are server-side (effectChoiceInfoDict has no event → effect list), so the pool follows the curated PRTS table
* 卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段 "机变阶段·敌人轮选" (it reorders and hides entries of the data on purpose):
* 'perfect' 战术特训 (perfect payout, incl. 无人机护障·P / 法术大师A2·多轮战术特训): listed under
* "※以下悬赏任务仅由法术教鞭生成"
* 'hidden' 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏 (enemyeffect_5..8): commented out of the table (the 鸭爵 strategy
* “神秘顾客” swaps those enemies into the waves instead)
* 'unseen' kill bounties no official draft showed (66 screenshots of 22 official matches, player feedback after
* 0.1.0 report #2 — see BOUNTY_INITIAL_SETS below): the 7 multi-round cards ("之后 / 后续的每场作战": 山海众头目·
* 多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards enemyeffect_3_* and the boss bounties of no seen R9 group
* (凋零骑士, “遗弃者”, 锏, 扎罗, 迷路的巨像) [ASSUMED: not offered]
* The rest is drafted, by the kind of draft it belongs to (`draftPool`, bountyDraftPool): the "接下来两场作战" cards at R3,
* the boss bounties and 源石虫·特训 ("但不获得资金") at R9, the faction / 特异 "下场战斗" cards at R11. A multi-round card's
* official text carries the red "每场" (`multiRound`, `rounds` 99 kept as the data has them; the server makes such a
* card last MULTI_ROUND_BOUNTY_BATTLES = 2 battles and rewrites its text — server/match/choices.js bountyBattles /
* bountyText, the user's call after playtest #6; 教鞭's 法术大师A2·多轮战术特训 is one). The 战术特训 cards are what the
* 教鞭 Art offers (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"; server/sim/content/items/meta.js); nothing in
* act2 offers the 鸭爵 set.
*/
const HIDDEN_BOUNTY_IDS = new Set(['enemyeffect_5', 'enemyeffect_6', 'enemyeffect_7', 'enemyeffect_8']);
function bountyDraftExclusion(e, main) {
if (main.payout !== 'kill') return 'perfect';
if (HIDDEN_BOUNTY_IDS.has(e.effectId)) return 'hidden';
return null;
}
/**
* The official 悬赏决策 drafts (player feedback after 0.1.0, report #2: "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了
* 打不过"), read from 66 screenshots of 22 official co-op 绝境 / 终极 matches at the start of rounds 3, 9 and 11 (collected
* by the user, 2026-10-03, from videos of this season recorded at the end of March 2026; the readings are
* test/fixtures/official-bounty-drafts.json, `seen` below = its match numbers). Every card's title, enemy and coin value is
* an act2autochess (下半) effect. A card's enemy is fixed by its effect (blackboard `enemy_id`); the title only names the
* category and tier, so 悬赏·损伤I is 底海滑动者 in enemyeffect_12_4 and 临时收音师 in enemyeffect_18_1. The players pick
* in turn from ONE shared draft (a taken card stays greyed with the taker's avatar: match 17 R3, match 21 R9).
*
* One rule covers all three rounds: the draft's event is a fixed list of cards, and the draft shows 6 different cards of
* it (positions shuffled): R3 lists hold 6 cards (all shown), R9 lists up to 9 (every R9 group seen in 3 or more drafts —
* and only those — shows exactly 9 different cards: 鼠王 group, 喷气人 group, 复仇者 group), R11 lists 7 (the three R11 groups
* seen in 2–4 drafts show exactly 7: matches 1 / 6 / 21 / 22 each leave out a different one of the same 7 cards). Within a
* list a card is drawn with weight 1 + the drafts of its group it appeared in (`hits`; a card no draft of the group has
* shown yet, 1) [ASSUMED: smooths the official counts — in the 鼠王 group 鼠王 showed in 4 of 14, 杰斯顿 in 12].
* initial R3 (enemy_initial_1..10): six "接下来两场作战" cards, always 3 × I (1 coin) + 2 × II + 1 × III (22 of 22).
* 9 of the 10 fixed sets seen (BOUNTY_INITIAL_SETS: 18 ×5, 19 / the 10_5 set / the 17_6 set ×3, 17 / two more
* ×2, two ×1 — consistent with a uniform pick among 10). Hand-made: a set can hold two series-20 cards (12) or
* one card of series 17 (13 / 19 / 21). The unseen 10th set: BOUNTY_INITIAL_RULE [ASSUMED] — its III is one of
* the only two two-battle cards no draft showed (法术大师A1 10_6, 鼎沸 20_6).
* boss R9 (bossInitial_1..6): the boss bounties "X·悬赏" and 源石虫·特训 (16 of 22), all "下场作战". Six groups, one
* per event (BOUNTY_BOSS_GROUPS); 庞贝 and 鼠王 are never in one draft. The 鼠王 group (W 碎骨 弑君者 大鲍勃 源石虫
* 鼠王 杰斯顿 “自在” 陷落雪祀) came in 14 of the 22 matches, so the event is not picked uniformly: something of
* the match decides — every match on the dark grey board (7) had it (if independent of the board, 2 %); the
* leader, the map or the difficulty (终极 opened 2026-03-27) are candidates. Picked by the matches each group
* came in [ASSUMED]. A named boss does not always come with its partners (杰斯顿 alone in 12 / 15 / 21, 复仇者
* and 萨卡兹百夫长 without 邪魔的利刃 in 20). The groups hold 9, 9, 9, 9, 8 and 6 cards: the three seen in 3 or
* more drafts show 9 (the 庞贝 / 鼠王 base of 6 + three named bosses); the three single-draft groups are
* completed only with the base cards they lack [ASSUMED] — 腐败骑士 group 9, 泥岩 + 澪 group 8, and match 4
* (the base alone) 6, so that one always shows the same six. Match 4 may instead be a draft of another group
* (most likely the 泥岩 group: 1 in 28 for a uniform 6 of 8), leaving one event unseen — open.
* 凋零骑士, “遗弃者”, 锏, 扎罗 and 迷路的巨像 never appeared: not offered [ASSUMED].
* hunter R11 (R11 is a 悬赏决策 in 14 of the 22 matches): six "下场战斗" cards, the faction _7 / _8 cards and series 16
* (16_1..8 the 特异III giants), at most one per faction series 10–15 (14 of 14). 7 groups seen
* (BOUNTY_HUNTER_GROUPS): three complete (7 cards), four with 6 cards seen and a 7th drawn by the rule (`open`;
* the group of match 15 shows no giant, so its 7th is one). The list is one of these 7, uniform [ASSUMED],
* because that invents no list. The data does not say which of the 15 events bounty_hunter_1..15 R11 fires:
* in effectChoiceInfoDict bounty_hunter_1..7 sit after bossInitial_1..6, and bounty_hunter_8..15 sit after
* artifact_paid_4 / 5 and right before hardbuff_select (the tactic event only 绝境 / 终极 have, the modes
* with an R11) — read by blocks, as SHOP_DRAFT reads the shop events, R11 would be the 8 events 8..15. The
* sample fits 7, 8 or 9 events about equally (14 drafts showing exactly 7 lists: 7 events 37 %, 8 events
* 45 %, 9 events 39 %) but not 15 (7 lists or fewer 6 %); the counts 4 / 2 / 3 / 1 / 2 / 1 / 1 fit a uniform
* pick (χ² = 4, 6 df). With 8 events, a list nobody has seen would come in about 1 R11 bounty draft in 8 —
* open. No list is built from nothing: the R11 cards no draft showed (12_8 异光体孽生者, 16_2 “越长尘”, 16_6
* 高准度伦蒂尼姆城防自行炮) come only as an `open` card (BOUNTY_HUNTER_RULE: one giant for a list without one,
* else tier I / II cards of the free faction series, at most two of 16_9..12), in about 1 R11 bounty draft in
* 15.
* In none of the 59 bounty drafts: the 7 multi-round cards (山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards
* enemyeffect_3_* (法术大师A2·悬赏 …), 战术特训 (法术教鞭 only) and the 鸭爵 set; no card twice in one draft.
*/
const EE = (s) => `enemyeffect_${s}`;
const seriesIds = (n) => [1, 2, 3, 4, 5, 6].map((i) => EE(`${n}_${i}`));
/** The R3 sets seen. */
const BOUNTY_INITIAL_SETS = [
{ cards: seriesIds(18), seen: [1, 3, 5, 9, 17] },
{ cards: seriesIds(19), seen: [2, 15, 22] },
{ cards: seriesIds(17), seen: [7, 14] },
{ cards: ['10_5', '11_4', '12_5', '13_6', '15_4', '20_3'].map(EE), seen: [4, 10, 16] },
{ cards: ['10_4', '11_5', '12_6', '14_4', '15_5', '20_2'].map(EE), seen: [6, 18] },
{ cards: ['10_4', '11_5', '12_4', '14_6', '15_4', '20_4'].map(EE), seen: [8] },
{ cards: ['10_4', '11_6', '12_4', '13_4', '14_5', '15_5'].map(EE), seen: [11, 20] },
{ cards: ['10_4', '11_4', '12_5', '15_6', '20_1', '20_5'].map(EE), seen: [12] },
{ cards: ['11_4', '12_4', '13_5', '14_4', '15_5', '17_6'].map(EE), seen: [13, 19, 21] },
];
/**
* The unseen R3 set [ASSUMED]: six two-battle cards of these series with the seen sets' tiers, at most `perSeries` of one
* series (the 20_1 / 20_5 set has two), its tier-III card one of `prefer` (the two-battle cards no draft showed).
*/
const BOUNTY_INITIAL_RULE = { series: [10, 11, 12, 13, 14, 15, 20], tiers: [1, 1, 1, 2, 2, 3], perSeries: 2, prefer: ['10_6', '20_6'].map(EE) };
// R9 cards by name (enemyeffect_b_N, 源石虫·特训 enemyeffect_5_1)
const R9 = {
W: 'b_10', 碎骨: 'b_1', 弑君者: 'b_3', 大鲍勃: 'b_13', 庞贝: 'b_12', 鼠王: 'b_11', 源石虫: '5_1', 杰斯顿: 'b_9', 自在: 'b_14', 陷落雪祀: 'b_23',
喷气人: 'b_8', 纠缠藤蔓: 'b_24', 澪: 'b_19', 邪魔的利刃: 'b_22', 复仇者: 'b_21', 萨卡兹百夫长: 'b_2', 腐败骑士: 'b_5', 墓碑: 'b_17', 巨大的丑东西: 'b_20', 泥岩: 'b_4',
};
/** A group from [name, hits] pairs (hits = the drafts of the group the card appeared in). */
const r9Group = (pairs, seen) => ({ cards: pairs.map(([n]) => EE(R9[n])), hits: pairs.map(([, h]) => h), seen });
/** The R9 groups (one per bossInitial event), picked by the matches they came in. */
const BOUNTY_BOSS_GROUPS = [
r9Group([['W', 11], ['碎骨', 12], ['弑君者', 11], ['大鲍勃', 11], ['源石虫', 10], ['鼠王', 4], ['杰斯顿', 12], ['自在', 7], ['陷落雪祀', 6]], [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21]),
r9Group([['W', 3], ['碎骨', 1], ['弑君者', 1], ['大鲍勃', 3], ['庞贝', 1], ['源石虫', 2], ['喷气人', 3], ['纠缠藤蔓', 3], ['澪', 1]], [1, 5, 22]),
r9Group([['W', 3], ['碎骨', 2], ['弑君者', 1], ['庞贝', 2], ['源石虫', 1], ['澪', 1], ['邪魔的利刃', 2], ['复仇者', 3], ['萨卡兹百夫长', 3]], [6, 20, 'bahamut-12294']),
r9Group([['W', 1], ['碎骨', 1], ['弑君者', 0], ['大鲍勃', 0], ['庞贝', 0], ['源石虫', 1], ['腐败骑士', 1], ['墓碑', 1], ['巨大的丑东西', 1]], [3]),
r9Group([['W', 0], ['碎骨', 0], ['弑君者', 1], ['大鲍勃', 1], ['庞贝', 1], ['源石虫', 1], ['泥岩', 1], ['澪', 1]], [2]),
r9Group([['W', 1], ['碎骨', 1], ['弑君者', 1], ['大鲍勃', 1], ['庞贝', 1], ['源石虫', 1]], [4]),
];
/** The R11 groups seen: 7 cards, or 6 + `open` cards drawn by BOUNTY_HUNTER_RULE. */
const hunterGroup = (ids, hits, seen, open = 0) => ({ cards: ids.map(EE), hits, seen, ...(open ? { open } : {}) });
const BOUNTY_HUNTER_GROUPS = [
hunterGroup(['16_4', '16_10', '15_7', '12_7', '13_8', '10_7', '11_7'], [4, 4, 3, 3, 4, 3, 3], [1, 6, 21, 22]), // 温顺的武装大驮兽 …
hunterGroup(['16_3', '15_7', '13_8', '16_9', '14_7', '11_7', '10_7'], [2, 2, 2, 1, 2, 2, 1], [3, 13]), // 狂暴宿主组长 …
hunterGroup(['16_5', '15_8', '12_7', '14_8', '10_7', '16_11', '11_7'], [3, 3, 3, 2, 3, 3, 1], [10, 12, 16]), // 萨卡兹骸骨拷打者 …
hunterGroup(['16_7', '16_9', '14_7', '16_11', '11_7', '13_7'], [1, 1, 1, 1, 1, 1], [11], 1), // 乌顶巨角卢鲁 …
hunterGroup(['16_8', '16_10', '14_8', '10_7', '16_12', '13_7'], [2, 2, 2, 2, 2, 2], [14, 19], 1), // 伊利昂的木驮兽 …
hunterGroup(['16_1', '10_8', '11_8', '15_7', '13_7', '14_7'], [1, 1, 1, 1, 1, 1], [17], 1), // 泥岩巨像 …
hunterGroup(['10_8', '12_7', '13_7', '14_7', '11_7', '16_12'], [1, 1, 1, 1, 1, 1], [15], 1), // no giant seen (枯朽萃聚使徒 …)
];
/**
* The `open` card of a seen R11 list [ASSUMED]: up to `size` cards — one tier-III giant (a list without one gets one) +
* tier I / II cards, at most one per series of `onePerSeries` and `maxSeries16` of 16_9..12 — over the R11 cards by tier
* (buildChoices).
*/
const BOUNTY_HUNTER_RULE = { size: 7, onePerSeries: [10, 11, 12, 13, 14, 15], maxSeries16: 2 };
/**
* The official 机密商店 (R11 of matches 2, 4, 5 and 8; the user: "机密商店按官方改成可以重复吧"): six item cards, free, and
* the same item can be offered twice (盟约之币 ×2 in 4 and 5, 变形同构体 ×2 in 8). Every one of the 4 has exactly two tier-VI
* items, at least one tier V and at least one 盟约之币 — never a tier-I / II item besides 盟约之币; the other two cards came
* out V ×3, IV ×2, III ×1, 盟约之币 ×2. So: six slots, each drawn on its own (with replacement) — VI, VI, V, 盟约之币 and
* twice a pick of V 3 / IV 2 / III 1 / 盟约之币 2 [ASSUMED: the slot split]; an item within its tier with weight 1 + the
* official 机密商店 cards it showed on (`seen`: 变形同构体 on 4 of the 8 tier-VI cards) [ASSUMED]. Solo shows 3 of the 6
* [ASSUMED]. Only at R11 (`rounds`; R11 is 绝境 / 终极 only, the rounds of the screenshots): the 机密商店 of 标准 R3 / R9
* and 险境 R3 / R6 / R9 has no screenshot, and the data's shop events sit in three blocks (artifact_paid_1 by the R3
* events, _2 / _3 by bossInitial / bounty_hunter_1..7, _4 / _5 by bounty_hunter_8..15 and hardbuff_select — the same
* block reading leaves open which hunter events R11 fires, see `hunter` above), so those keep the previous draw —
* any normal shop item of tiers I–VI per card, with replacement (the same item can come twice there too) [ASSUMED].
*/
const SHOP_DRAFT = {
rounds: [11],
slots: [{ 6: 1 }, { 6: 1 }, { 5: 1 }, { coin: 1 }, { 5: 3, 4: 2, 3: 1, coin: 2 }, { 5: 3, 4: 2, 3: 1, coin: 2 }],
coin: '盟约之币',
seen: { 变形同构体: 4, 天师古鼎: 1, 人事部文档: 1, 家族徽章: 1, 铳骑之威: 1, 天马之盔: 2, 双模机械臂: 2, 商业包装方案: 2, 博士投影: 1, 护盾无人机: 1, 寻呼模块: 1, 骑士储蓄罐: 1, 盟约之币: 6 },
matches: [2, 4, 5, 8],
};
/**
* The official 战术决策 (R11 of matches 7, 9, 18 and 20; the user: "战术决策也按官方改成可以重复吧"): six cards, and the same
* card can be offered twice (补给 ×2 in match 7; the other three show six different cards). All 24 cards are ally cards
* (allybuff_select_*: 盟誓, 驰援, 列装 / 财富 / 补给 / 整备 / 升华, 锐利): no 排斥 / 责罚 / 裁决 debuff (four drafts without
* one: about 0.04 % for the previous draw, 6 different cards of the 26 ally + 9 debuff cards) and no 模拟战场演变 terrain
* card. 列装 / 财富 / 补给 / 整备 / 升华 made 12 of the 24 cards (a uniform draw of the 26 ally cards: about 0.07 %), so
* the draw is weighted. At R11 (`rounds`; 绝境 / 终极 only, the round of the screenshots): each card drawn on its own (with
* replacement) from the ally cards (`kinds`), weight 1 + the official cards it showed on (`seen`) [ASSUMED: the weights;
* terrain cards left out — the maps of the 4 matches are unknown]; solo shows 3 [ASSUMED]. Other rounds (标准 / 险境, no
* screenshot) keep every card, uniform, terrain cards only for the match stage — with replacement too [ASSUMED].
* Open: a slot structure like SHOP_DRAFT's. Every one of the 4 drafts has at least one 驰援, at least one 盟誓 and at least
* two of 列装 / 财富 / 补给 / 整备 / 升华; under these independent draws a draft has 驰援 + 盟誓 about 70 % of the time (all
* four: about 24 %) and all three about 41 % (all four: about 3 %). The pattern was spotted after the fact, so it is kept as
* a question rather than slots [ASSUMED: independent draws]; more R11 战术决策 screenshots would settle it.
*/
const TACTIC_DRAFT = {
rounds: [11],
kinds: ['ally'],
seen: { 补给: 3, 列装: 3, 升华: 3, 莫斯提马的盟誓: 2, 银灰的盟誓: 2, 阿戈尔驰援: 2, 财富: 2, 谢拉格驰援: 1, 锐利: 1, 整备: 1, 玛恩纳的盟誓: 1, 斯卡蒂的盟誓: 1, 萨尔贡驰援: 1, 叙拉古驰援: 1 },
matches: [7, 9, 18, 20],
};
/** The kind of a round's 悬赏决策: R3 (and 险境 R6, [ASSUMED]) initial, R9 boss, R11 hunter. */
const bountyDraftOf = (r) => (r < 8 ? 'initial' : r < 11 ? 'boss' : 'hunter');
/** The draft kind a bounty card belongs to by its shape, null when no draft offers it (see above). */
function bountyDraftPool(e, main, draftExcluded) {
if (draftExcluded) return null;
if (main.rounds === 2) return 'initial';
if (main.rounds !== 1) return null; // multi-round
if (/^enemyeffect_b_\d+$/.test(e.effectId) || e.effectId === 'enemyeffect_5_1') return 'boss';
if (/^enemyeffect_1[0-5]_[78]$/.test(e.effectId) || /^enemyeffect_16_\d+$/.test(e.effectId)) return 'hunter';
return null; // the pre-series cards enemyeffect_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}`);
const coin = e.enemyPrice || main.coin;
let draftExcluded = bountyDraftExclusion(e, main);
if (draftExcluded === 'hidden' && !/鸭爵|高普尼克|流泪小子|圆仔/.test(e.name || '')) warn(`bounty ${e.effectId} "${e.name}": expected one of the 鸭爵 set`);
let draftPool = bountyDraftPool(e, main, draftExcluded);
// a boss bounty is offered only through a seen R9 group (BOUNTY_BOSS_GROUPS)
if (draftPool === 'boss' && !BOUNTY_BOSS_GROUPS.some((g) => g.cards.includes(e.effectId))) draftPool = null;
if (!draftExcluded && !draftPool) draftExcluded = 'unseen';
const sm = /^enemyeffect_(\d+)_\d+$/.exec(e.effectId);
bounty.push({
effectId: e.effectId, name: e.name, desc: e.desc, tier, coin,
payout: main.payout, rounds: main.rounds, multiRound, enemyKey: main.enemyKey, count: main.count, adds,
draft: !draftExcluded, draftExcluded, draftPool, series: sm ? Number(sm[1]) : null,
});
}
bounty.sort((a, b) => naturalCmp(a.effectId, b.effectId));
// the official draft structures (see BOUNTY_INITIAL_SETS) only name cards of their own kind
const bountyById = new Map(bounty.map((c) => [c.effectId, c]));
const checkKind = (ids, kind, what) => { for (const id of ids) if (bountyById.get(id)?.draftPool !== kind) warn(`bounty draft ${what}: ${id} is not a "${kind}" card`); };
for (const s of BOUNTY_INITIAL_SETS) checkKind(s.cards, 'initial', 'R3 set');
for (const g of BOUNTY_BOSS_GROUPS) checkKind(g.cards, 'boss', 'R9 group');
for (const g of BOUNTY_HUNTER_GROUPS) checkKind(g.cards, 'hunter', 'R11 group');
for (const g of [...BOUNTY_INITIAL_SETS, ...BOUNTY_BOSS_GROUPS, ...BOUNTY_HUNTER_GROUPS]) {
if (new Set(g.cards).size !== g.cards.length) warn(`bounty draft group ${g.cards.join(' ')}: a card twice`);
if (g.hits && g.hits.length !== g.cards.length) warn(`bounty draft group ${g.cards.join(' ')}: hits do not match the cards`);
}
for (const s of BOUNTY_INITIAL_SETS) if (s.cards.map((id) => bountyById.get(id)?.tier).sort().join('') !== '111223') warn(`bounty draft R3 set ${s.cards.join(' ')}: not I I I II II III`);
for (const g of BOUNTY_HUNTER_GROUPS) {
const giants = g.cards.filter((id) => bountyById.get(id)?.tier === 3).length;
if (giants > 1 || g.cards.length + (g.open || 0) !== BOUNTY_HUNTER_RULE.size) warn(`bounty draft R11 group ${g.cards.join(' ')}: not ${BOUNTY_HUNTER_RULE.size} cards with at most one giant`);
}
// the R11 rule draws over the R11 cards by tier: III the giants, II / I the rest
const hunterTier = (t) => bounty.filter((c) => c.draftPool === 'hunter' && c.tier === t).map((c) => c.effectId);
if (hunterTier(1).length + hunterTier(2).length + hunterTier(3).length !== bounty.filter((c) => c.draftPool === 'hunter').length) warn('bounty: an R11 card of no tier I–III');
const withWeights = ({ hits, ...g }) => ({ ...g, weights: hits.map((h) => h + 1) });
// 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));
// the official 战术决策 (TACTIC_DRAFT) names cards of its `kinds` only
const tacticByName = (name) => {
const hits = tactic.filter((c) => c.name === name);
if (hits.length !== 1) warn(`tactic draft card "${name}": ${hits.length} cards of that name`);
if (hits[0] && !TACTIC_DRAFT.kinds.includes(hits[0].kind)) warn(`tactic draft card "${name}" is a ${hits[0].kind} card`);
return hits[0]?.effectId ?? null;
};
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] };
// the 悬赏决策 of a round is the kind of official draft its round has (bountyDraftOf: R3 initial, R9 boss, R11 hunter)
const bountyEventRe = { initial: /^enemy_initial_/, boss: /^bossInitial_/, hunter: /^bounty_hunter_/ };
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') {
// R11 of the 22 official co-op matches (player feedback after 0.1.0 report #2, the screenshots of BOUNTY_INITIAL_SETS):
// 悬赏决策 14, 机密商店 4, 战术决策 4, 道具补给 0 — the weights are those counts [ASSUMED]. Solo 绝境 / 终极 take the
// same weights [ASSUMED: no solo screenshot; extrapolated from co-op — the data has solo 悬赏决策 events for R11,
// bounty_hunter_*_s, which the previous supply 50 / shop 50 never used]
families = r === 11 ? [{ family: 'bounty', weight: 14 }, { family: 'shop', weight: 4 }, { family: 'tactic', weight: 4 }] : [{ 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 bountyDraft = bountyDraftOf(r);
const bountyEvents = eventsOf('bounty', (e) => bountyEventRe[bountyDraft].test(e.id) && e.solo === solo);
m[r] = {
families, cards: solo ? 3 : 6,
supplyTiers: supplyWindow[r] || [1, 6], bountyDraft,
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: 'bountyDrafts[schedule[*].bountyDraft] — one official card list of the round (initial: an R3 set of 6; boss: an R9 group of up to 9; hunter: one of the 7 seen R11 lists of 7), six different cards of it drawn by weight, over cards.bounty entries with draft: true and that draftPool' },
supply: { name: '道具补给', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items in schedule[*].supplyTiers (duplicates allowed)' },
shop: { name: '机密商店', desc: '无需消耗资金,获得装备补给。', cards: 'at shopDraft.rounds (R11): six slots drawn with replacement (VI, VI, V, 盟约之币, 2 × V / IV / III / 盟约之币); other rounds: random normal EQUIP shop items of tiers I–VI (duplicates allowed) — the same item can come twice' },
// desc = the official header (effectChoiceInfoDict buff_select_* / hardbuff_select_* "进行协同调整,做好迎战准备。")
tactic: { name: '战术决策', desc: '进行协同调整,做好迎战准备。', cards: 'cards.tactic, each card drawn on its own (with replacement) — the same card can come twice; at tacticDraft.rounds (R11) the ally cards by tacticDraft.weights, other rounds every card uniform (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 },
// the official 悬赏决策 structures by kind (tools/build-data.mjs BOUNTY_INITIAL_SETS; `seen` = the match numbers of
// test/fixtures/official-bounty-drafts.json; `slots` = the official events, the unseen ones built by `rule`)
bountyDrafts: {
initial: {
events: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo), slots: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo).length,
pick: 'slot', groups: BOUNTY_INITIAL_SETS, rule: BOUNTY_INITIAL_RULE, count: 6,
assumed: ['a uniform pick among the 10 events', 'the unseen 10th set built by `rule`', '险境 R6 drafts like R3'],
},
boss: {
events: eventsOf('bounty', (e) => bountyEventRe.boss.test(e.id) && !e.solo), slots: BOUNTY_BOSS_GROUPS.length,
pick: 'seen', groups: BOUNTY_BOSS_GROUPS.map(withWeights), count: 6,
assumed: ['a group picked by the matches it came in (the per-match cause is open)', 'card weights 1 + hits', 'the single-draft groups completed with their base cards', 'boss bounties of no seen group are not offered'],
},
hunter: {
events: eventsOf('bounty', (e) => bountyEventRe.hunter.test(e.id) && !e.solo), slots: BOUNTY_HUNTER_GROUPS.length,
// one of the 7 seen lists, uniform [ASSUMED]: `slots` 7, so no list is built from nothing (which of the 15 events
// R11 fires is open — by the data's blocks it would be 8..15); `rule` builds the `open` cards only
pick: 'slot', groups: BOUNTY_HUNTER_GROUPS.map(withWeights),
rule: { ...BOUNTY_HUNTER_RULE, giants: hunterTier(3), cards: [...hunterTier(2), ...hunterTier(1)] }, count: 6,
assumed: ['one of the 7 seen lists, picked uniformly (which of the 15 events R11 fires is open)', 'card weights 1 + hits', 'the `open` cards built by `rule`'],
},
},
// the official 机密商店 (SHOP_DRAFT) at `rounds`: `slots` tier → weight (`coin` = 盟约之币), each drawn on its own;
// `itemWeights` = 1 + the official cards an item showed on (other shop items 1)
shopDraft: {
rounds: SHOP_DRAFT.rounds, slots: SHOP_DRAFT.slots, coin: itemByName(SHOP_DRAFT.coin),
itemWeights: Object.fromEntries(Object.entries(SHOP_DRAFT.seen).filter(([n]) => n !== SHOP_DRAFT.coin).map(([n, k]) => [itemByName(n), 1 + k]).sort((a, b) => naturalCmp(a[0], b[0]))),
seen: SHOP_DRAFT.matches, count: 6,
assumed: ['the slot split', 'item weights 1 + seen', 'solo shows 3 of the 6', 'other rounds (标准 / 险境) keep the previous draw: tiers I–VI with replacement'],
},
// the official 战术决策 (TACTIC_DRAFT) at `rounds`: each card drawn on its own (with replacement) from the cards.tactic
// entries of `kinds`, by `weights` (1 + the official cards it showed on; other cards of those kinds 1)
tacticDraft: {
rounds: TACTIC_DRAFT.rounds, kinds: TACTIC_DRAFT.kinds,
weights: Object.fromEntries(Object.entries(TACTIC_DRAFT.seen).map(([n, k]) => [tacticByName(n), 1 + k]).filter(([id]) => id).sort((a, b) => naturalCmp(a[0], b[0]))),
seen: TACTIC_DRAFT.matches, count: 6,
assumed: ['card weights 1 + seen', 'independent draws (no slot structure)', 'no terrain card at R11', 'solo shows 3', 'other rounds (标准 / 险境) keep every card, uniform, with replacement'],
},
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 },
// "获得一件带有随机特殊效果的维式重锤": the 4 hammers with a special effect (never sold, SHOP_EXCLUDED_ITEMS); the
// weights are server-side (PRTS 11-25 note: "装备【灼燃维式重锤】的出现概率调整") — uniform [ASSUMED]
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 },
// 洛洛的定制品 = the same 4 special hammers (user playtest #4, first-hand: "几个特殊的维式重锤是干员洛洛或者维多利亚
// 阵营获得的"); uniform [ASSUMED]
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, aliveFull: 4, aliveAssumed: true, 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 for every field ("所有人将一起对敌方领袖造成伤害"; the mirrored copies of a pair field share it, "两侧的敌方领袖共享生命值(敌方领袖的总生命值不变)"); aliveScaling true would scale it × alive players at the Final Assault / aliveFull (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少", one community note, no proportion: off until confirmed, the alive / 4 proportion [ASSUMED]); solo: bloodPoint[difficulty] × solo (0.25 = one player of four) [ASSUMED]',
coop: 1, solo: 0.25, soloAssumed: true, aliveScaling: false, aliveFull: 4, aliveAssumed: true, 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,
// bandTurn: one turn of the co-op strategy draft = its only countdown (server/match/Match.js BAND_TURN_SECONDS,
// [ASSUMED] — user playtest #4 item 4; the official data only has the whole step's 50 s, kept for reference)
bandDraft: step('BAND_CHECK')?.time ?? 50, bandDraftHint: step('BAND_CHECK')?.hintTime ?? 15, bandTurn: 30,
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, backups } = 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`);
// 高台 legality is loadout-aware (shared/highGround.js), never a field on the record
if (c.placement !== undefined) err(`chess ${c.chessId}: placement is not a data field`);
}
// the deliberate trigger deviations (DESIGN §21.29, §22.10) still override an official TAKE_DAMAGE row, on the normal
// chess and its elite alike (a DEFAULT one may resolve to the owner's ACTIVE_RANGE on top: 深巡 S2)
const deviationHolds = (rule, trig) => trig.rawRule === 'TAKE_DAMAGE' && (trig.rule === rule || (ACTIVE_RANGE_OVER.has(rule) && trig.rule === 'ACTIVE_RANGE'));
for (const [baseId, skillsOf] of Object.entries(TRIGGER_DEVIATIONS)) {
const recs = Object.values(chess).filter((c) => c.baseId === baseId);
if (recs.length !== 2) err(`trigger deviation ${baseId}: expected the normal and the elite record, got ${recs.length}`);
for (const [skillId, rule] of Object.entries(skillsOf)) {
for (const c of recs) {
const s = (c.skills || []).find((x) => x.skillId === skillId);
if (!s) err(`trigger deviation ${c.chessId}: no skill ${skillId}`);
else if (!deviationHolds(rule, s.trigger)) err(`trigger deviation ${c.chessId} ${skillId}: ${s.trigger.rawRule} → ${s.trigger.rule}, expected TAKE_DAMAGE → ${rule}`);
else if (rule === 'SKILL_RANGE' && (!s.rangeGrid?.length || JSON.stringify(s.trigger.customRangeGrid) !== JSON.stringify(s.rangeGrid))) err(`trigger deviation ${c.chessId} ${skillId}: SKILL_RANGE needs the skill's own range as customRangeGrid`);
}
}
}
// the owner's ACTIVE_RANGE rule (2026-10-05): a MANUAL skill of the basic strategy (DEFAULT by its row or a deviation)
// or of the SEARCH row whose running range strictly contains the record's own range, and no other
for (const c of Object.values(chess)) {
for (const s of c.skills || []) {
if (s.trigger?.rule !== 'ACTIVE_RANGE') continue;
const over = TRIGGER_DEVIATIONS[c.baseId]?.[s.skillId] || s.trigger.rawRule;
if (s.skillType !== 'MANUAL' || !ACTIVE_RANGE_OVER.has(over) || !c.rangeGrid?.length || !strictlyContains(s.trigger.customRangeGrid || [], c.rangeGrid)) {
err(`chess ${c.chessId} ${s.skillId}: ACTIVE_RANGE needs a MANUAL basic-strategy or SEARCH skill whose customRangeGrid strictly contains the record's range`);
}
}
}
// 补位 / 自选 (data/backups.json, DATA.md §18): a PRESET chess is its own backup; every NORMAL chess (both forms)
// composes into its stand-in (shared/standIn.js standInRecord) with the chess's skill and module; every DIY slot is a
// DIY chess and every prototype pick of its tier has a form for both slot statuses; derived bonds exist
if (backups) {
for (const c of Object.values(chess)) {
const b = c.backup;
if (c.chessType === 'PRESET' && b?.charId !== c.charId) err(`chess ${c.chessId}: a PRESET chess's backup must be itself`);
if (c.chessType === 'DIY' && b?.charId !== null) err(`chess ${c.chessId}: a DIY slot has no backup`);
if (c.chessType !== 'NORMAL') continue;
const r = standInRecord(c, backups);
if (!r) { err(`chess ${c.chessId}: no stand-in ${b?.charId}@${statusKey(c.status)} in backups.json`); continue; }
if (!r.stats || !Array.isArray(r.rangeGrid) || !r.trait) err(`chess ${c.chessId}: stand-in ${b.charId} without stats / range / trait`);
if (r.skill?.index !== b.skillIndex || r.skills.filter((s) => s.isDefault).length !== 1) err(`chess ${c.chessId}: stand-in skill ${b.skillIndex} is not unlocked at ${statusKey(c.status)}`);
if (b.uniEquipId && c.status.equipLevel > 0 && !r.module?.active) err(`chess ${c.chessId}: stand-in module ${b.uniEquipId} has no level ${c.status.equipLevel}`);
if (r.tokens.length) err(`chess ${c.chessId}: stand-in ${b.charId} summons ${r.tokens.join(', ')} (no tokens.json variant)`);
}
for (const [id, s] of Object.entries(backups.diy.slots)) {
const slot = chess[id], golden = chess[s.goldenId];
if (!slot?.isDiy || slot.isGolden || slot.tier !== s.tier || !golden?.isDiy) { err(`DIY slot ${id}: not a DIY chess pair of tier ${s.tier}`); continue; }
if (!backups.diy.prototypes[s.tier]?.length) err(`DIY slot ${id}: no prototype picks`);
for (const p of backups.diy.prototypes[s.tier] || []) {
for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`);
// the locked selection exists at both slot forms (the module at the elite's stage)
const lk = backups.diy.locked?.[s.tier]?.[p];
if (!lk) { err(`DIY slot ${id}: prototype ${p} has no locked selection`); continue; }
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (f && !f.skills.some((x) => x.index === lk.skillIndex)) err(`DIY slot ${rec.chessId}: prototype ${p} has no skill ${lk.skillIndex} at ${statusKey(rec.status)}`);
if (f && lk.uniEquipId && rec.status.equipLevel > 0 && !f.modules.some((m) => m.uniEquipId === lk.uniEquipId)) err(`DIY slot ${rec.chessId}: prototype ${p} has no module ${lk.uniEquipId} at ${statusKey(rec.status)}`);
}
}
// every owned pick: a form at both slot statuses with every skill, and at the elite every module of the character
for (const p of backups.diy.ownedPool) {
const u = backups.units[p];
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (!f) { err(`DIY slot ${rec.chessId}: owned pick ${p} has no form ${statusKey(rec.status)}`); continue; }
if (f.skills.length !== 3) err(`DIY slot ${rec.chessId}: owned pick ${p} has ${f.skills.length} skills at ${statusKey(rec.status)}`);
const ids = Object.keys(u.moduleNames).filter((m) => u.moduleNames[m].typeName !== 'ORIGINAL');
if (rec.status.equipLevel > 0 && JSON.stringify(f.modules.map((m) => m.uniEquipId)) !== JSON.stringify(ids)) err(`DIY slot ${rec.chessId}: owned pick ${p} modules ${f.modules.map((m) => m.uniEquipId)} ≠ ${ids}`);
for (const t of f.tokens) if (!backups.tokens?.[t]?.variants?.[`${p}@${statusKey(rec.status)}`]) err(`DIY pick ${p}@${statusKey(rec.status)}: summon ${t} has no variant in backups.json tokens`);
}
}
}
for (const [id, o] of Object.entries(backups.diy.operators)) for (const b of o.bonds) if (!bonds[b]) err(`DIY pick ${id}: bond ${b} missing`);
for (const id of Object.keys(backups.tokens || {})) if (tokens[id]) err(`backups.json token ${id} is also a tokens.json record`);
// the 重装 stand-ins' deviations (STANDIN_TRIGGER_DEVIATIONS) still override an official TAKE_DAMAGE row, on every form
for (const [charId, skillsOf] of Object.entries(STANDIN_TRIGGER_DEVIATIONS)) {
const forms = Object.values(backups.units[charId]?.forms || {});
if (!forms.length) err(`stand-in trigger deviation ${charId}: no unit in backups.json`);
for (const [skillId, rule] of Object.entries(skillsOf)) {
for (const f of forms) {
const s = f.skills.find((x) => x.skillId === skillId);
if (!s) err(`stand-in trigger deviation ${charId}@${statusKey(f.status)}: no skill ${skillId}`);
else if (!deviationHolds(rule, s.trigger)) err(`stand-in trigger deviation ${charId}@${statusKey(f.status)} ${skillId}: ${s.trigger.rawRule} → ${s.trigger.rule}, expected TAKE_DAMAGE → ${rule}`);
}
}
}
}
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 b of Object.values(bands)) for (const id of b.bondIds || []) if (!bonds[id]) err(`band ${b.bandId}: bond ${id} 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}"`);
}
// the 联防 field of 1 / 2 helpers is the map of its template (server/match/unite.js uniteStageId)
for (const [n, id] of Object.entries(config.unite.templates)) {
if (stages[id]?.kind !== 'unite' || stages[id].helpers !== Number(n) || stages[id].active) err(`unite template ${id}: no inactive 联防 stage for ${n} helper(s)`);
}
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 id of Object.keys(SHOP_EXCLUDED_ITEMS)) if (!items[id] || items[id].itemType !== 'EQUIP' || items[id].isGolden) err(`SHOP_EXCLUDED_ITEMS: ${id} is not a normal EQUIP item`);
for (const [id, fl] of Object.entries(TOKEN_ABNORMAL)) {
if (!tokens[id]) err(`TOKEN_ABNORMAL: ${id} is not a token`);
for (const f of fl) if (f !== 'healFree' && f !== 'isolated') err(`TOKEN_ABNORMAL: ${id}: unknown effect ${f}`);
}
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 backups = buildBackups(ctx, chess);
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);
// the bonds each strategy is built around (DESIGN §21.26): the bot skips, and the strategy draft marks 本局禁用, a band
// whose bond the mode switches off
for (const b of Object.values(bands)) b.bondIds = bandBondIds(b, { bonds, pools: choices.pools });
const config = buildConfig(ctx, waves, stages, bands);
const files = { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens, backups };
const errors = [...validateAll(files), ...checkUnitFormParity(ctx, chess)];
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,
backupUnits: Object.keys(backups.units).length,
backupForms: Object.values(backups.units).reduce((n, u) => n + Object.keys(u.forms).length, 0),
backupTokens: Object.keys(backups.tokens).length,
diyOwnedPicks: backups.diy.ownedPool.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;
});