Files
ark-stronghold-protocol/test/data.test.js
T
sganggs 8a43624935 feedback5 WE: 联防 is played on its own map, the escaped template level (GitHub #41, item 3) — act2autochess constData escapedBattleTemplateMapSinglePlayer / MultiPlayer = level_act1autochess_escaped_single / _multi name the level of the 联防 battle, and its map is the field: two road halves (cols 3–9 and 11–17, rows 9–12) joined at col 10, no water, crates or devices (until 0.1.3 the round's stage, opened to both halves). tools/build-data.mjs builds both into data/stages.json after the 11 battle stages (kind 'unite', helpers 1 / 2, weight 0, inactive, no modes, the name 联防阵地(N名玩家) the remake's own label [ASSUMED]; validation ties config.unite.templates to them); regenerated offline, a JSON compare shows only the two added records. unite.js uniteStageId picks the map of the helper count (escaped_single's routes enter at col 10, escaped_multi's at col 18 through (9,10)); match/unitePhase.js fields the 联防 battle on it (the round's stage only with data that lacks it); each helper on its prep tiles, the first of two shifted 8 columns onto the right half as before (the official halves; player_map_lr_offset 8). Client: screens/game.js draws the shown field's own map and restores the board's on leaving it (a 2-line change in a file of the R5 split; render/app.js enterBattle already loads m.field stageId); the dev mock's 联防 follows. The 联防 BGM (PR #110) is unchanged. Tests: test/match/feedback5-unite-map.test.js (both combat modes, 1 and 2 helpers, degraded data), test/data.test.js, test/render/tiles.test.js. Docs: DATA §12, META §4, DESIGN §3, PLAYING §5. Golden: fields unite-1 / unite-2 (the battle's stage → the 联防 map) and the 6 co-op match scenarios with a 联防 round (coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun); roster / bonds unchanged (tools/golden.mjs leaves the 联防 maps out of its stage list)
2026-10-05 18:17:08 +08:00

808 lines
53 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.
// test/data.test.js — integrity tests for the generated game data (data/*.json, task F1).
//
// Loads every file produced by tools/build-data.mjs and checks shapes, counts and cross-file
// referential integrity (bonds ↔ chess, garrisons, items/bands ↔ effects, wave spawns ↔ enemies,
// config rounds ↔ waves, stages 19×21, finite numbers…), plus regression tests for defects found in
// review (module token parts, enemy rangeRadius/undefined-field semantics, token sources).
// When the official-data cache (.cache/gamedata) is present, two more suites run: an independent
// re-derivation of every chess/enemy stat from the raw tables, and an offline rebuild that must
// reproduce data/ byte-for-byte (catches a stale data/ after a build-script change).
// Run: node --test test/data.test.js (build first with `node tools/build-data.mjs` if data/ is missing).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync, statSync, mkdtempSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
// DATA_DIR lets the suite validate an alternative build output (e.g. `--out /tmp/x`).
const DATA = process.env.DATA_DIR || join(ROOT, 'data');
const CACHE = join(ROOT, '.cache', 'gamedata');
const HAS_CACHE = ['excel/activity_table.json', 'excel/character_table.json', 'excel/skill_table.json', 'excel/battle_equip_table.json',
'levels/enemydata/enemy_database.json', 'levels/activities/act1autochess/level_autochess_enemy_data.json']
.every((rel) => existsSync(join(CACHE, rel)));
const FILES = ['config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices', 'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'backups'];
/** Load one data file (fails with a helpful message when the build has not run). */
function load(name) {
const p = join(DATA, `${name}.json`);
assert.ok(existsSync(p), `data/${name}.json missing — run: node tools/build-data.mjs`);
return JSON.parse(readFileSync(p, 'utf8'));
}
const D = Object.fromEntries(FILES.map((f) => [f, load(f)]));
const { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens } = D;
const isFiniteNum = (v) => typeof v === 'number' && Number.isFinite(v);
const isInt = (v) => Number.isInteger(v);
const isPair = (p) => Array.isArray(p) && p.length === 2 && p.every(isInt);
const normalChess = Object.values(chess).filter((c) => !c.isGolden);
const visible = normalChess.filter((c) => c.visible);
/** Every number reachable in an object is finite (JSON cannot hold NaN, but null stats would slip). */
function assertStatsFinite(stats, label, keys) {
assert.ok(stats && typeof stats === 'object', `${label}: stats missing`);
for (const k of keys) assert.ok(isFiniteNum(stats[k]), `${label}: stats.${k} = ${stats[k]}`);
}
test('all data files load and are non-empty; total size < 6 MB', () => {
let total = 0;
for (const f of FILES) {
total += statSync(join(DATA, `${f}.json`)).size;
assert.ok(Object.keys(D[f]).length > 0, `${f} is empty`);
}
assert.ok(total < 6 * 1024 * 1024, `total ${total} bytes`);
});
test('numbers: every stats/bb/enemyScale object holds only finite numbers (no null/NaN leaks)', () => {
const bad = [];
const walk = (x, path) => {
if (!x || typeof x !== 'object') return;
if (Array.isArray(x)) { x.forEach((v, i) => walk(v, `${path}[${i}]`)); return; }
for (const [k, v] of Object.entries(x)) {
if ((k === 'stats' || k === 'bb' || k === 'enemyScale') && v && typeof v === 'object' && !Array.isArray(v)) {
for (const [sk, sv] of Object.entries(v)) {
if (sv === null || (typeof sv === 'number' && !Number.isFinite(sv))) bad.push(`${path}.${k}.${sk}`);
}
}
walk(v, `${path}.${k}`);
}
};
for (const f of FILES) walk(D[f], f);
assert.deepEqual(bad.slice(0, 10), [], `${bad.length} bad numeric fields`);
});
test('chess: 266 records, 112 visible non-DIY (16/17/19/22/19/19 per tier)', () => {
assert.equal(Object.keys(chess).length, 266);
assert.equal(visible.length, 112);
const perTier = {};
for (const c of visible) perTier[c.tier] = (perTier[c.tier] || 0) + 1;
assert.deepEqual(perTier, { 1: 16, 2: 17, 3: 19, 4: 22, 5: 19, 6: 19 });
assert.equal(normalChess.filter((c) => c.isDiy).length, 4);
assert.equal(normalChess.filter((c) => c.isHidden).length, 17);
});
test('chess: ids, golden pairs and references resolve', () => {
for (const [id, c] of Object.entries(chess)) {
assert.equal(c.chessId, id);
assert.ok(chess[c.baseId], `${id}: baseId`);
assert.equal(chess[c.baseId].isGolden, false, `${id}: baseId must be normal`);
assert.ok(c.goldenId && chess[c.goldenId]?.isGolden, `${id}: goldenId`);
assert.equal(c.tier, chess[c.baseId].tier, `${id}: tier differs from base`);
assert.ok(c.tier >= 1 && c.tier <= 6);
for (const b of c.bonds) assert.ok(bonds[b], `${id}: bond ${b}`);
for (const g of c.garrisonIds) assert.ok(garrisons[g], `${id}: garrison ${g}`);
for (const t of c.tokens) assert.ok(tokens[t], `${id}: token ${t}`);
assert.ok(isFiniteNum(c.price) && isFiniteNum(c.sellPrice), `${id}: price`);
}
assert.equal(chess.chess_char_2_11_a.upgradeNum, 2, '风丸 merges with 2 copies');
});
test('chess: every non-DIY chess has stats, range, classification and a resolvable skill', () => {
const statKeys = ['maxHp', 'atk', 'def', 'res', 'cost', 'blockCnt', 'bat', 'aspd', 'respawnTime', 'spRecovery', 'moveSpeed', 'tauntLevel', 'massLevel'];
for (const c of Object.values(chess)) {
if (c.isDiy) { assert.equal(c.visible, false); continue; }
assertStatsFinite(c.stats, c.chessId, statKeys);
assert.ok(c.stats.maxHp > 0 && c.stats.bat > 0 && c.stats.aspd > 0, `${c.chessId}: positive stats`);
assert.ok(Array.isArray(c.rangeGrid) && c.rangeGrid.every(isPair), `${c.chessId}: rangeGrid`);
assert.ok(['phys', 'arts', 'heal', 'true'].includes(c.dmgType), `${c.chessId}: dmgType ${c.dmgType}`);
assert.ok(['melee', 'ranged', 'heal', 'none'].includes(c.attackKind), `${c.chessId}: attackKind`);
assert.ok(['arrow', 'bolt', 'orb', 'none'].includes(c.projectile), `${c.chessId}: projectile`);
assert.equal(typeof c.canHitFly, 'boolean');
assert.ok(c.skill && typeof c.skill.skillId === 'string', `${c.chessId}: skill`);
assert.ok(c.skill.level === (c.isGolden ? 7 : 4), `${c.chessId}: skill level ${c.skill.level}`);
assert.ok(typeof c.skill.trigger?.rule === 'string', `${c.chessId}: trigger`);
assert.ok(typeof c.skill.desc === 'string' && !/\{[a-z_@.\[\]]+(:[0-9.%]+)?\}/i.test(c.skill.desc), `${c.chessId}: unresolved skill placeholder`);
for (const [k, v] of Object.entries(c.skill.bb)) assert.ok(isFiniteNum(v), `${c.chessId}: skill bb ${k}`);
assert.ok(c.trait && typeof c.trait.desc === 'string', `${c.chessId}: trait`);
assert.ok(Array.isArray(c.talents));
assert.ok(c.assets && c.assets.avatar && c.assets.spine, `${c.chessId}: assets`);
if (c.isGolden) assert.equal(c.module ? c.module.active || c.module.id === null : true, true);
}
// Spot checks against official numbers (隐现 E1 Lv55: HP 1123, ATK 399).
assert.equal(chess.chess_char_1_01_a.stats.maxHp, 1123);
assert.equal(chess.chess_char_1_01_a.stats.atk, 399);
assert.equal(chess.chess_char_1_01_a.targetPriority, 'fly');
});
test('bonds: 23 bonds with valid members, thresholds and effects', () => {
assert.equal(Object.keys(bonds).length, 23);
assert.equal(Object.values(bonds).filter((b) => b.isCore).length, 8);
const modes = new Set(['BOARD', 'BOARD_AND_DECK', 'BOARD_ALL_CHESS']);
for (const b of Object.values(bonds)) {
assert.ok(modes.has(b.countMode), `${b.bondId}: countMode ${b.countMode}`);
assert.ok(b.thresholds.length >= 1, `${b.bondId}: thresholds`);
for (let i = 0; i < b.thresholds.length; i++) {
assert.ok(isInt(b.thresholds[i]) && b.thresholds[i] > 0);
if (i) assert.ok(b.thresholds[i] > b.thresholds[i - 1], `${b.bondId}: ascending`);
}
for (const m of b.members) {
assert.ok(chess[m] && !chess[m].isGolden, `${b.bondId}: member ${m}`);
assert.ok(chess[m].bonds.includes(b.bondId), `${b.bondId}: member ${m} lacks bond`);
}
assert.ok(effects[b.effectId], `${b.bondId}: effect`);
}
assert.deepEqual(bonds.yanShip.thresholds, [3, 6, 9]);
assert.deepEqual(bonds.egirShip.thresholds, [3, 5]);
assert.deepEqual(bonds.suntShip.thresholds, [2, 5]);
assert.equal(bonds.soloShip.maxCount, 1);
// Every chess bond membership is mirrored in the bond member list.
for (const c of normalChess) for (const b of c.bonds) assert.ok(bonds[b].members.includes(c.chessId), `${c.chessId} not in ${b}.members`);
});
test('garrisons: all referenced exist; 43 distinct effect keys', () => {
const keys = new Set(Object.values(garrisons).map((g) => g.effectKey));
assert.equal(keys.size, 43);
for (const g of Object.values(garrisons)) {
assert.ok(typeof g.eventType === 'string' && typeof g.desc === 'string', g.garrisonId);
for (const o of g.owners) assert.ok(chess[o], `${g.garrisonId}: owner ${o}`);
}
});
test('items: 115 item chess with valid effects, bonds and golden links', () => {
assert.equal(Object.keys(items).length, 115);
assert.equal(Object.values(items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden).length, 56);
assert.equal(Object.values(items).filter((i) => i.itemType === 'MAGIC').length, 3);
for (const it of Object.values(items)) {
assert.ok(effects[it.effectId], `${it.id}: effect ${it.effectId}`);
if (it.goldenId) assert.ok(items[it.goldenId]?.isGolden, `${it.id}: golden`);
if (it.giveBondId) assert.ok(bonds[it.giveBondId], `${it.id}: giveBond`);
if (it.requiresBondId) assert.ok(bonds[it.requiresBondId], `${it.id}: requiresBond`);
assert.ok(isInt(it.tier) && it.tier >= 1 && it.tier <= 6, `${it.id}: tier`);
assert.ok(isFiniteNum(it.price), `${it.id}: price`);
assert.ok(typeof it.trapId === 'string' && it.iconId === it.trapId);
}
});
test('bands: 40 strategies with effects and starting LP', () => {
assert.equal(Object.keys(bands).length, 40);
for (const b of Object.values(bands)) {
assert.ok(effects[b.effectId], `${b.bandId}: effect`);
assert.ok(isInt(b.totalHp) && b.totalHp >= 20 && b.totalHp <= 45, `${b.bandId}: totalHp`);
assert.ok(b.name && b.iconId);
}
assert.equal(bands.band_bldsk.totalHp, 28);
});
test('effects: every buff has a key and finite numeric blackboard', () => {
for (const e of Object.values(effects)) {
for (const b of e.buffs) {
assert.equal(typeof b.key, 'string', e.effectId);
for (const [k, v] of Object.entries(b.bb)) assert.ok(isFiniteNum(v), `${e.effectId}.${k}`);
}
}
});
test('enemies: stats are finite and motion/dmgType valid', () => {
for (const e of Object.values(enemies)) {
assertStatsFinite(e.stats, e.key, ['maxHp', 'atk', 'def', 'res', 'moveSpeed', 'bat', 'aspd', 'rangeRadius', 'blockCnt', 'massLevel', 'lpr', 'hpRecoveryPerSec', 'tauntLevel']);
assert.ok(['WALK', 'FLY'].includes(e.stats.motion), `${e.key}: motion ${e.stats.motion}`);
assert.ok(['phys', 'arts', 'true', 'heal', 'none', 'element'].includes(e.stats.dmgType), `${e.key}: dmgType ${e.stats.dmgType}`);
for (const s of e.summons) assert.ok(enemies[s], `${e.key}: summon ${s}`);
}
});
test('waves: every template resolves spawns, routes and enemies', () => {
assert.equal(Object.keys(waves).length, 38);
for (const w of Object.values(waves)) {
assert.ok(w.routes.length > 0, `${w.id}: routes`);
for (const r of [...w.routes, ...w.extraRoutes]) {
if (!r) continue;
assert.ok(isPair(r.start) && isPair(r.end), `${w.id}: route endpoints`);
for (const p of r.checkpoints) assert.ok(isPair(p), `${w.id}: checkpoint`);
}
for (const sp of w.spawns) {
assert.ok(isFiniteNum(sp.time) && sp.time >= 0, `${w.id}: time`);
assert.ok(isInt(sp.count) && sp.count >= 1, `${w.id}: count`);
assert.ok(isFiniteNum(sp.interval) && sp.interval >= 0, `${w.id}: interval`);
if (sp.action) continue;
assert.ok(enemies[sp.key], `${w.id}: enemy ${sp.key}`);
assert.ok(w.routes[sp.routeIndex], `${w.id}: route ${sp.routeIndex}`);
}
for (const [bn, phases] of Object.entries(w.branches)) {
for (const sp of phases.flat()) {
if (sp.action) continue;
assert.ok(enemies[sp.key], `${w.id}/${bn}: enemy ${sp.key}`);
assert.ok(w.extraRoutes[sp.routeIndex], `${w.id}/${bn}: extraRoute ${sp.routeIndex}`);
}
}
for (const k of Object.keys(w.overrides)) assert.ok(enemies[k], `${w.id}: override ${k}`);
if (w.kind === 'normal') assert.ok(isFiniteNum(w.maxPlayTime) && w.maxPlayTime > 0);
}
});
test('config: modes, rounds and templates', () => {
const inScope = Object.values(config.modes).filter((m) => m.inScope);
assert.equal(inScope.length, 8);
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 || {});
assert.ok(tpls.length > 0, `${m.modeId} r${r}: template`);
for (const t of tpls) assert.ok(waves[t], `${m.modeId} r${r}: ${t}`);
if (!rd.isBoss) assert.ok(isFiniteNum(rd.combatTimeLimit), `${m.modeId} r${r}: combatTimeLimit`);
if (m.type === 'SINGLE') assert.equal(rd.prepTime, null, `${m.modeId}: solo prep untimed`);
if (m.type === 'MULTI') assert.ok(isFiniteNum(rd.prepTime), `${m.modeId} r${r}: prepTime`);
const es = m.enemyScale[r];
assert.ok(es && isFiniteNum(es.atk) && isFiniteNum(es.hp) && isFiniteNum(es.speed), `${m.modeId} r${r}: enemyScale`);
}
for (const b of [...m.activeBondIds, ...m.inactiveBondIds]) assert.ok(bonds[b], `${m.modeId}: bond ${b}`);
for (const s of m.stages) assert.ok(stages[s]?.active, `${m.modeId}: stage ${s}`);
if (m.inScope) {
assert.equal(m.upgradePrices.length, 5);
assert.ok(m.stages.length > 0);
}
}
const M = config.modes;
assert.equal(M.mode_single_funny.lastRound, 9);
assert.equal(M.mode_single_funny.hiddenRound, null);
assert.equal(M.mode_multi_funny.lastRound, 14);
assert.equal(M.mode_multi_funny.hiddenRound, null);
for (const id of ['mode_single_normal', 'mode_single_hard', 'mode_single_abyss', 'mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss']) {
assert.equal(M[id].lastRound, 14, id);
assert.equal(M[id].hiddenRound, 15, id);
}
assert.deepEqual(M.mode_multi_hard.spRounds, [3, 9, 11]);
assert.deepEqual(M.mode_multi_normal.upgradePrices, [5, 8, 11, 12, 13]);
assert.equal(config.economy.income[1], 4);
assert.equal(config.economy.income[3], 6);
assert.equal(config.economy.benchSize, 10);
assert.equal(config.economy.deployCap, 8);
assert.equal(config.economy.mergeCountOverrides.chess_char_2_11_a, 2);
assert.equal(config.economy.poolCopiesOverrides.chess_char_6_11_a, 4);
assert.deepEqual(config.hiddenCore.single, 350);
assert.deepEqual(config.hiddenCore.multi, 1200);
assert.equal(config.titles.length, 6);
});
test('stages: 19 rows × 21 cols with known glyphs; paths are contiguous', () => {
const glyphs = new Set('#XrRfphbaASEIOmgdi'.split(''));
for (const s of Object.values(stages)) {
assert.equal(s.rows.length, 19, `${s.id}: rows`);
for (const row of s.rows) {
assert.equal(row.length, 21, `${s.id}: cols`);
for (const ch of row) assert.ok(glyphs.has(ch), `${s.id}: glyph ${ch}`);
}
assert.equal(s.rows[9][2], 'E', `${s.id}: objective at (9,2)`);
assert.equal(s.rows[9][10], 'S', `${s.id}: gate at (9,10)`);
for (const paths of [s.groundPaths, s.groundPathsWithDevices]) {
for (const [k, p] of Object.entries(paths)) {
for (let i = 1; i < p.length; i++) {
const dr = Math.abs(p[i][0] - p[i - 1][0]), dc = Math.abs(p[i][1] - p[i - 1][1]);
assert.ok(dr <= 1 && dc <= 1 && dr + dc > 0, `${s.id} ${k}: step ${i}`);
const g = s.rows[p[i][0]][p[i][1]];
assert.ok(s.tiles[g]?.groundPassable, `${s.id} ${k}: impassable ${g}`);
}
}
}
assert.ok(s.groundPaths['9,10->9,2'], `${s.id}: lower gate path`);
}
assert.equal(Object.values(stages).filter((s) => s.active).length, 8);
});
test('bosses, factions, tokens and choices resolve', () => {
assert.equal(Object.keys(bosses).length, 10);
for (const b of Object.values(bosses)) {
assert.ok(enemies[b.enemyKey], `${b.bossId}: enemy`);
for (const v of Object.values(b.bloodPoint)) assert.ok(isFiniteNum(v) && v > 0, `${b.bossId}: bloodPoint`);
for (const t of Object.values(b.templates)) assert.ok(waves[t.template], `${b.bossId}: ${t.template}`);
}
assert.equal(Object.values(bosses).filter((b) => b.hidden).length, 3);
assert.equal(Object.keys(factions.entries).length, 67);
for (const e of Object.values(factions.entries)) {
assert.equal(typeof e.fly, 'boolean', `faction ${e.key}: fly`);
assert.ok(!('kS' in e), `faction ${e.key}: no k copies (research 08 §7-1)`);
for (const x of [{ key: e.key }, ...e.N, ...e.E]) {
assert.ok(enemies[x.key], `faction ${e.key}: ${x.key}`);
assert.ok(!('k' in x), `faction ${e.key}: ${x.key} has no k`);
assert.equal(enemies[x.key].isFlyEnemy, e.fly, `faction ${e.key}: ${x.key} shares the entry's movement class`);
}
}
for (const k of Object.values(factions.templateSlots)) assert.ok(enemies[k], `template slot ${k}`);
// the official generator's parameters (research 08 §2)
const g = factions.generation;
assert.equal(g.maxLevelCnt, 15);
assert.equal(g.specialEnemyNum, 3);
assert.equal(g.fillType, 'SPECIAL');
assert.deepEqual([g.minReplacedEnemyCount, g.maxReplacedEnemyCount, g.firstHalfMaxRound, g.minActionIntervalRatio], [1, 5, 7, 0.05]);
assert.deepEqual(Object.values(g.typeSlots), [3, 3, 3, 3, 3, 3]);
assert.deepEqual(Object.values(g.placeholders).map((p) => p.slot).sort(), ['E', 'EF', 'N', 'NF', 'S', 'SF'], 'T / TF are tokens, not placeholders');
assert.ok(!('kFormula' in g));
for (const t of Object.values(tokens)) {
assertStatsFinite(t.stats, t.tokenId, ['maxHp', 'atk', 'def', 'res', 'bat', 'aspd']);
for (const o of t.owners) assert.ok(chess[o]?.tokens.includes(t.tokenId), `${t.tokenId}: owner ${o}`);
}
assert.ok(tokens.enemy_9012_acloon, '炎佑 present');
assert.equal(Object.keys(choices.events).length, 109);
for (const s of Object.values(choices.schedule)) {
for (const r of Object.values(s.rounds)) {
assert.ok(r.families.length > 0);
for (const ids of Object.values(r.events)) for (const id of ids) assert.ok(choices.events[id], `event ${id}`);
}
}
for (const c of choices.cards.bounty) {
assert.ok(effects[c.effectId]);
for (const a of c.adds) assert.ok(enemies[a.enemyKey], `bounty ${c.effectId}: ${a.enemyKey}`);
assert.ok([1, 2, 3].includes(c.tier));
}
for (const c of choices.cards.tactic) assert.ok(effects[c.effectId]);
for (const [id, p] of Object.entries(choices.pools)) {
for (const x of [...(p.items || []), ...(p.weighted || []).map((w) => w[0])]) assert.ok(items[x] || chess[x], `pool ${id}: ${x}`);
}
});
// ---- regression tests for review findings -------------------------------------------------------
test('chess: module parts flagged isToken upgrade the summon, never the operator', () => {
// 缪尔赛思 golden: talent 0 keeps her own text/token/cnt; the 流形 upgrades live on the token variant.
const mlyss = chess.chess_char_6_11_b;
assert.equal(mlyss.talents[0].name, '净水即生命');
assert.equal(mlyss.talents[0].tokenKey, 'token_10030_mlyss_wtrman');
assert.equal(mlyss.talents[0].bb.cnt, 1);
assert.ok(!('scale' in mlyss.talents[0].bb), 'token-only blackboard leaked into the operator talent');
assert.ok(!mlyss.talents.some((t) => t.bb.damage_scale !== undefined || t.bb.sp === 5), 'token talents attached to operator');
const wtr = tokens.token_10030_mlyss_wtrman.variants;
assert.equal(wtr.chess_char_6_11_a.talents[0].bb.scale, 0.9);
assert.equal(wtr.chess_char_6_11_b.talents[0].bb.scale, 1);
assert.ok(wtr.chess_char_6_11_b.talents.some((t) => t.bb.damage_scale === 0.85));
assert.equal(wtr.chess_char_6_11_b.count, 1);
// 浊心斯卡蒂 golden: operator talent 0 still sends 1 海嗣; the 30 s duration belongs to the 海嗣.
const skadi = chess.chess_char_6_04_b;
assert.equal(skadi.talents[0].tokenKey, 'token_10017_skadi2_dedant');
assert.equal(skadi.talents[0].bb.cnt, 1);
assert.ok(!('duration' in skadi.talents[0].bb));
const dedant = tokens.token_10017_skadi2_dedant.variants;
assert.equal(dedant.chess_char_6_04_a.talents[0].bb.duration, 25);
assert.equal(dedant.chess_char_6_04_b.talents[0].bb.duration, 30);
assert.equal(dedant.chess_char_6_04_b.stats.respawnTime, dedant.chess_char_6_04_a.stats.respawnTime - 5, 'module token attribute');
// 伺夜 golden: the wolves' damage reduction is on the wolf, not on 伺夜.
assert.ok(!chess.chess_char_3_19_b.talents.some((t) => t.bb.damage_scale !== undefined));
assert.ok(tokens.token_10028_vigil_wolf.variants.chess_char_3_19_b.talents.some((t) => t.bb.damage_scale === 0.85));
assert.ok(!tokens.token_10028_vigil_wolf.variants.chess_char_3_19_a.talents.some((t) => t.bb.damage_scale !== undefined));
// 耀骑士临光 golden: “耀阳” gets the module trait upgrade.
assert.equal(tokens.token_10019_nearl2_sword.variants.chess_char_6_17_b.trait.bb.atk_scale, 1.15);
assert.equal(tokens.token_10019_nearl2_sword.variants.chess_char_6_17_a.trait.bb.atk_scale, undefined);
// Module talent overrides keep base keys the upgrade does not restate (宴: +100 aspd at 70% HP lost).
assert.equal(chess.chess_char_1_18_b.talents[0].bb.min_attack_speed, 100);
assert.equal(chess.chess_char_1_18_b.talents[0].bb.damage_resistance, 0.25);
});
test('chess/tokens: talent tokens resolve and every token variant says where it comes from', () => {
for (const c of Object.values(chess)) {
for (const t of c.talents) if (t.tokenKey) {
assert.ok(c.tokens.includes(t.tokenKey) && tokens[t.tokenKey], `${c.chessId}: talent token ${t.tokenKey}`);
}
}
// 凛御银灰: the talent's container id maps onto the default skill's token (S2 → eagle2).
const svash = chess.chess_char_5_14_a;
assert.equal(svash.talents[0].tokenKey, 'token_10057_svash2_eagle2');
assert.equal(svash.talents[0].containerTokenKey, 'token_10057_svash2_eagle');
assert.equal(svash.skill.overrideTokenKey, 'token_10057_svash2_eagle2');
const allowed = new Set(['talent', 'skill', 'display']);
for (const t of Object.values(tokens)) {
for (const [owner, v] of Object.entries(t.variants)) {
assert.ok(Array.isArray(v.sources) && v.sources.length && v.sources.every((s) => allowed.has(s)), `${t.tokenId}@${owner}: sources`);
assert.ok(chess[owner]?.tokens.includes(t.tokenId), `${t.tokenId}: owner ${owner}`);
}
}
assert.deepEqual(tokens.token_10057_svash2_eagle1.variants.chess_char_5_14_a.sources, ['display']);
// 夕's skill "cnt" is a charge count, not a token count.
assert.equal(tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a.count, null);
// Placeable (hand) tokens = the manually deployable summons (PRTS 卫戍协议/帮助 §战斗部署; user playtest #6): shop
// state DEFAULT or no shop-state entry (凯瑟琳's device), never HIDDEN, made by some owner loadout (talent or skill) —
// 赫默's 医疗探机 and 巫恋's 诅咒娃娃 included (in battle they wait on their tile for the skill).
const makes = (list) => list.includes('talent') || list.includes('skill');
for (const t of Object.values(tokens)) {
if (t.kind !== 'summon') continue;
const made = Object.values(t.variants).some((v) => makes(v.sources) || Object.values(v.bySkill || {}).some((b) => makes(b.sources)));
assert.equal(t.placeable, t.displayType !== 'HIDDEN' && made, `${t.tokenId} (${t.name}): placeable`);
}
assert.deepEqual(Object.values(tokens).filter((t) => t.placeable).map((t) => t.name).sort(),
['医疗探机', '诅咒娃娃', '斯卡蒂的海嗣', '流形', '狼群', '爬行号·防护单元'].sort());
assert.equal(tokens.enemy_9012_acloon.stats.deployLimit, tokens.enemy_9012_acloon.deployLimit);
});
// ---- operator loadouts: selectable skills & modules (DESIGN §16) ------------------------------------
test('chess: skills[] = every skill unlocked at the status, at the chess skill level; exactly one default = skill', () => {
for (const c of Object.values(chess)) {
if (c.isDiy) { assert.equal(c.skills, undefined, `${c.chessId}: DIY has no skills`); continue; }
assert.ok(Array.isArray(c.skills) && c.skills.length >= 1 && c.skills.length <= 3, `${c.chessId}: skills`);
const idx = c.skills.map((s) => s.index);
assert.deepEqual(idx, [...idx].sort((a, b) => a - b), `${c.chessId}: skills ordered by index`);
assert.equal(new Set(idx).size, idx.length, `${c.chessId}: unique skill indices`);
// E1 unlocks S1–S2, E2 S1–S3 (tier 1–2 normal chess are E1)
assert.ok(idx.every((i) => i <= c.status.phase), `${c.chessId}: skill ${idx} locked at E${c.status.phase}`);
const defs = c.skills.filter((s) => s.isDefault);
assert.equal(defs.length, 1, `${c.chessId}: one default skill`);
const { isDefault, ...d } = defs[0];
assert.deepEqual(d, c.skill, `${c.chessId}: default entry = skill (back-compat shape)`);
for (const s of c.skills) {
assert.equal(s.level, c.status.skillLevel, `${c.chessId} ${s.skillId}: level`);
assert.equal(typeof s.isDefault, 'boolean');
assert.ok(typeof s.skillId === 'string' && typeof s.iconId === 'string' && typeof s.name === 'string', `${c.chessId}: skill ids`);
assert.ok(typeof s.trigger?.rule === 'string', `${c.chessId} ${s.skillId}: trigger`);
assert.ok(s.rangeGrid === null || s.rangeGrid.every(isPair), `${c.chessId} ${s.skillId}: rangeGrid`);
assert.ok(!/\{[a-z_@.\[\]]+(:[0-9.%]+)?\}/i.test(s.desc), `${c.chessId} ${s.skillId}: unresolved placeholder`);
for (const [k, v] of Object.entries(s.bb)) assert.ok(isFiniteNum(v), `${c.chessId} ${s.skillId}: bb ${k}`);
assert.ok(s.overrideTokenKey === null || c.tokens.includes(s.overrideTokenKey), `${c.chessId} ${s.skillId}: token ${s.overrideTokenKey} listed`);
}
if (c.tier <= 2 && !c.isGolden) assert.ok(idx.length <= 2, `${c.chessId}: E1 chess has no S3`);
}
// Triggers per skill (PRTS 卫戍协议/帮助 技能策略; tools/build-data.mjs resolveTrigger): the class rows cover every MANUAL
// skill of the class and no AUTO one; a MANUAL skill with a 技能范围 of its own (not an attack-range change) is SKILL_RANGE.
const rules = (id) => chess[id].skills.map((s) => s.trigger.rule);
assert.deepEqual(rules('chess_char_1_02_a'), ['TAKE_DAMAGE', 'TAKE_DAMAGE']); // 角峰 (重装, both MANUAL)
assert.deepEqual(rules('chess_char_1_10_b'), ['DEFAULT', 'TAKE_DAMAGE']); // 古米 (S1 自动触发)
assert.deepEqual(rules('chess_char_3_08_a'), ['SEARCH', 'SEARCH']); // 薄绿 (阵法术师: the default S2 too)
assert.deepEqual(rules('chess_char_3_19_a'), ['DEFAULT', 'DEFAULT', 'SP_FULL']); // 伺夜 (战术家: S1/S2 are AUTO)
assert.deepEqual(rules('chess_char_6_11_b'), ['MLYSS_WTRMAN', 'MLYSS_WTRMAN', 'MLYSS_WTRMAN']); // 缪尔赛思 (charId row −1)
assert.deepEqual(rules('chess_char_1_08_a'), ['DEFAULT', 'SKILL_RANGE']); // 德克萨斯 S2 剑雨: 对周围所有敌人
assert.deepEqual(chess.chess_char_1_08_a.skills[1].trigger.customRangeGrid, chess.chess_char_1_08_a.skills[1].rangeGrid);
assert.deepEqual(rules('chess_char_4_22_a'), ['DEFAULT', 'DEFAULT', 'DEFAULT']); // 银灰: "攻击范围缩小 / 扩大" = attack range
assert.deepEqual(rules('chess_char_3_18_a'), ['DEFAULT', 'SKILL_RANGE', 'DEFAULT']); // 忍冬: S2 对周围…, S3 攻击距离+1
// the deliberate deviation from the 重装 row (DESIGN §21.29, the owner's decision; tools/build-data.mjs
// TRIGGER_DEVIATIONS, per chess): six skills cast with an enemy in range, rawRule keeps the official TAKE_DAMAGE
const raws = (id) => chess[id].skills.map((s) => s.trigger.rawRule);
for (const id of ['chess_char_1_04_a', 'chess_char_1_04_b']) { // 深巡: S1 keeps the row, S2 deviates
assert.deepEqual(rules(id), ['TAKE_DAMAGE', 'DEFAULT']);
assert.deepEqual(raws(id), ['TAKE_DAMAGE', 'TAKE_DAMAGE']);
}
for (const id of ['chess_char_1_20_a', 'chess_char_1_20_b']) { // 雷蛇: S1 AUTO, S2 deviates
assert.deepEqual(rules(id), ['DEFAULT', 'DEFAULT']);
assert.deepEqual(raws(id), ['DEFAULT', 'TAKE_DAMAGE']);
}
for (const id of ['chess_char_5_08_a', 'chess_char_5_08_b']) { // 号角: S1 AUTO, S2 / S3 deviate
assert.deepEqual(rules(id), ['DEFAULT', 'DEFAULT', 'DEFAULT']);
assert.deepEqual(raws(id), ['DEFAULT', 'TAKE_DAMAGE', 'TAKE_DAMAGE']);
}
for (const id of ['chess_char_2_18_a', 'chess_char_2_18_b']) { // 灰毫: S1 (generic skcom_atk_up[3]) and S2
assert.deepEqual(rules(id), ['DEFAULT', 'DEFAULT']);
assert.deepEqual(raws(id), ['TAKE_DAMAGE', 'TAKE_DAMAGE']);
}
// 余 S2 厚礼上宾 (DESIGN §22.10, the owner's decision of 2026-10-04): SKILL_RANGE on its own x-1, S1 keeps the row, S3 its
// official charId row
for (const id of ['chess_char_6_03_a', 'chess_char_6_03_b']) {
assert.deepEqual(rules(id), ['TAKE_DAMAGE', 'SKILL_RANGE', 'CUSTOM_RANGE']);
assert.deepEqual(raws(id), ['TAKE_DAMAGE', 'TAKE_DAMAGE', 'CUSTOM_RANGE_SEARCH_ENEMY']);
assert.deepEqual(chess[id].skills[1].trigger.customRangeGrid, chess[id].skills[1].rangeGrid);
}
const deviated = Object.values(chess).flatMap((c) => (c.skills || []).filter((s) => s.trigger.rawRule === 'TAKE_DAMAGE' && s.trigger.rule !== 'TAKE_DAMAGE').map((s) => `${c.baseId} ${s.skillId}`));
assert.equal(deviated.length, 14, 'exactly the six skills of §21.29 and 余 S2, normal + elite');
assert.deepEqual([...new Set(deviated)].sort(), ['chess_char_1_04_a skchr_udflow_2', 'chess_char_1_20_a skchr_liskam_2', 'chess_char_2_18_a skchr_ashlok_2', 'chess_char_2_18_a skcom_atk_up[3]', 'chess_char_5_08_a skchr_horn_2', 'chess_char_5_08_a skchr_horn_3', 'chess_char_6_03_a skchr_yu_2']);
for (const c of Object.values(chess)) {
for (const s of c.skills || []) {
if (s.skillType !== 'MANUAL') assert.ok(!['TAKE_DAMAGE', 'SEARCH', 'SKILL_RANGE'].includes(s.trigger.rule), `${c.chessId} ${s.skillId}: an AUTO / PASSIVE skill takes no strategy row`);
if (s.trigger.rule === 'SKILL_RANGE') assert.ok(s.rangeGrid && s.skillType === 'MANUAL' && !/攻击(范围|距离)(与溅射范围)?(扩大|改变|缩小|缩短|加长|增加|\+)/.test(s.desc), `${c.chessId} ${s.skillId}: SKILL_RANGE only for a 技能范围`);
}
}
assert.deepEqual(chess.chess_char_1_01_a.skills.map((s) => [s.skillId, s.level, s.spCost, s.isDefault]),
[['skchr_inside_1', 4, 14, false], ['skchr_inside_2', 4, 24, true]]); // 隐现 Lv4 (default S2)
const sw = chess.chess_char_3_04_b; // 琳琅诗怀雅: S2 makes the 香槟
assert.deepEqual(sw.skills.map((s) => s.overrideTokenKey), [null, 'token_10031_swire2_gdtrap', null]);
});
test('chess: golden modules[] (+ statsBase/traitBase/talentsBase) compose back to the default record', async () => {
const { composeStats, composeTalents } = await import('../server/sim/simdata.js');
const statKeys = ['maxHp', 'atk', 'def', 'res', 'aspd', 'cost', 'blockCnt', 'respawnTime', 'bat', 'deployLimit', 'deckStack'];
let nMods = 0;
for (const c of Object.values(chess)) {
if (c.isDiy) continue;
if (!c.isGolden || !(c.status.equipLevel > 0)) {
for (const k of ['modules', 'statsBase', 'traitBase', 'talentsBase']) assert.equal(c[k], undefined, `${c.chessId}: normal chess has no ${k}`);
continue;
}
assert.ok(Array.isArray(c.modules), `${c.chessId}: modules`);
const defs = c.modules.filter((m) => m.isDefault);
assert.equal(defs.length, c.module?.active ? 1 : 0, `${c.chessId}: default module iff module active`);
if (defs.length) assert.equal(defs[0].uniEquipId, c.module.id);
const dm = defs[0] ?? null;
// default loadout = the record's own stats / trait / talents
assert.deepEqual(composeStats(c.statsBase, dm?.attr), c.stats, `${c.chessId}: statsBase + default attr = stats`);
assert.deepEqual(dm?.traitOverride ?? c.traitBase, c.trait, `${c.chessId}: trait`);
assert.deepEqual(composeTalents(c.talentsBase, dm?.talentChanges), c.talents, `${c.chessId}: talents`);
assert.equal(new Set(c.modules.map((m) => m.uniEquipId)).size, c.modules.length);
for (const m of c.modules) {
nMods++;
assert.match(m.uniEquipId, /^uniequip_\d{3}_/, `${c.chessId}: module id`);
assert.ok(!/^uniequip_001_/.test(m.uniEquipId), `${c.chessId}: INITIAL (no module) is not a module choice`);
assert.ok(typeof m.name === 'string' && typeof m.typeName === 'string' && m.typeName.length > 0, `${m.uniEquipId}: name/type`);
assert.equal(m.level, c.status.equipLevel, `${m.uniEquipId}: level`);
for (const [k, v] of Object.entries(m.attr)) assert.ok(statKeys.includes(k) && isFiniteNum(v), `${m.uniEquipId}: attr ${k}`);
assert.ok(m.traitOverride === null || (typeof m.traitOverride.desc === 'string' && m.traitOverride.bb), `${m.uniEquipId}: traitOverride`);
for (const t of m.talentChanges) assert.ok(Number.isInteger(t.talentIndex) && t.bb && typeof t.hidden === 'boolean', `${m.uniEquipId}: talentChanges`);
// every choice composes into complete stats
assertStatsFinite(composeStats(c.statsBase, m.attr), `${c.chessId}+${m.uniEquipId}`, statKeys);
}
}
assert.ok(nMods >= 170, `module choices: ${nMods}`); // 184 ADVANCED modules over 129 goldens
// 缪尔赛思 精锐 (E2 60, module Lv3): 梳妆流形 (default) / 落叶四季
const m = chess.chess_char_6_11_b;
assert.deepEqual(m.modules.map((x) => [x.uniEquipId, x.typeName, x.isDefault]), [['uniequip_002_mlyss', 'TAC-X', true], ['uniequip_003_mlyss', 'TAC-Y', false]]);
assert.deepEqual(m.modules[1].attr, { maxHp: 170, atk: 28, def: 28 });
assert.deepEqual([m.statsBase.maxHp, m.statsBase.atk, m.statsBase.def], [1703, 467, 111]);
// module-less goldens: no choices, base = stats
assert.deepEqual(chess.chess_char_5_14_b.modules, []);
assert.deepEqual(chess.chess_char_5_14_b.statsBase, chess.chess_char_5_14_b.stats);
});
test('tokens: owner loadout variants (bySkill per non-default owner skill, byModule per other module / none)', () => {
const allowed = new Set(['talent', 'skill', 'display']);
for (const t of Object.values(tokens)) {
for (const [owner, v] of Object.entries(t.variants || {})) {
const o = chess[owner];
const alt = o.skills.filter((s) => !s.isDefault).map((s) => String(s.index));
assert.deepEqual(Object.keys(v.bySkill || {}), alt, `${t.tokenId}@${owner}: bySkill keys`);
for (const b of Object.values(v.bySkill || {})) {
assert.ok(Array.isArray(b.sources) && b.sources.every((x) => allowed.has(x)), `${t.tokenId}@${owner}: bySkill sources`);
assert.ok(b.count === null || isInt(b.count), `${t.tokenId}@${owner}: bySkill count`);
}
const mods = o.isGolden && o.module?.active ? [...o.modules.filter((m) => !m.isDefault).map((m) => m.uniEquipId), 'none'] : [];
assert.deepEqual(Object.keys(v.byModule || {}), mods, `${t.tokenId}@${owner}: byModule keys`);
for (const b of Object.values(v.byModule || {})) assert.ok(b.stats && b.trait && Array.isArray(b.talents), `${t.tokenId}@${owner}: byModule shape`);
}
}
const w = tokens.token_10030_mlyss_wtrman.variants.chess_char_6_11_b;
assert.equal(w.skill.skillId, 'sktok_mlyss_wtrman_3'); // default S3
assert.equal(w.bySkill[0].skill.skillId, 'sktok_mlyss_wtrman_1'); // 流形 follows the owner's skill slot
assert.equal(w.byModule.none.talents[0].bb.scale, 0.9); // no module: 90% copy
assert.ok(!w.byModule.none.talents.some((x) => x.bb.damage_scale !== undefined));
assert.deepEqual(tokens.token_10022_kazema_shadow.variants.chess_char_2_11_a.bySkill[0].sources, [], '风丸 S1 makes no 纸偶');
assert.deepEqual(tokens.token_10057_svash2_eagle1.variants.chess_char_5_14_a.bySkill[0].sources, ['talent', 'skill', 'display'], '凛御银灰 S1: talent eagle = eagle1');
});
test('enemies: rangeRadius contract (MELEE ⇒ 0) and undefined-field defaults', () => {
for (const e of Object.values(enemies)) {
const s = e.stats;
assert.ok(s.rangeRadius >= 0, `${e.key}: rangeRadius ${s.rangeRadius}`);
if (e.applyWay === 'MELEE') assert.equal(s.rangeRadius, 0, `${e.key}: MELEE enemy must not attack at range`);
assert.ok(isFiniteNum(s.rawRangeRadius), `${e.key}: rawRangeRadius`);
if (e.applyWay !== 'MELEE') assert.equal(s.rangeRadius, Math.max(0, s.rawRangeRadius), `${e.key}: rangeRadius`);
assert.ok(s.maxHp > 0 && s.bat > 0 && s.moveSpeed >= 0 && s.lpr >= 0, `${e.key}: core stats`);
}
// Only three skill-driven leaders have an official (defined) attack speed of 0.
const zeroAspd = Object.values(enemies).filter((e) => e.stats.aspd === 0).map((e) => e.key).sort();
assert.deepEqual(zeroAspd, ['enemy_9017_achunt', 'enemy_9017_achunt_2', 'enemy_9021_acduml', 'enemy_9021_acduml_2', 'enemy_9022_acdumm']);
assert.equal(enemies.enemy_1045_hammer.stats.rangeRadius, 0); // 粉碎攻坚手: MELEE, official 2.5
assert.equal(enemies.enemy_1045_hammer.stats.rawRangeRadius, 2.5);
assert.equal(enemies.enemy_1305_mhslim.stats.rangeRadius, 1.8); // 灼热源石虫: RANGED keeps its radius
assert.equal(enemies.enemy_2016_csphtm.stats.aspd, 100); // 卢西恩: undefined attackSpeed → 100
assert.equal(enemies.enemy_1269_nhfly.stats.bat, 1); // 枯朽之种: undefined bat → 1
assert.equal(enemies.enemy_1229_darmy.stats.lpr, 1); // 萨卡兹王庭军战士: undefined lpr → 1
assert.equal(enemies.enemy_10067_ftsjc.stats.atk, 400); // 灼藤: season override ATK 400
for (const w of Object.values(waves)) {
for (const [k, o] of Object.entries(w.overrides)) {
if (o.stats && 'rangeRadius' in o.stats && (o.applyWay || enemies[k].applyWay) === 'MELEE') assert.equal(o.stats.rangeRadius, 0, `${w.id}: ${k}`);
}
}
});
test('stages: 下半 devices at match start = the non-hidden ones (act1 m02 has no crates); official helper lanes', () => {
for (const s of Object.values(stages)) for (const d of s.devices) assert.equal(d.active, !d.hidden, `${s.id} ${d.alias}`);
const m02 = stages.act1autochess_m02;
for (const a of ['#001', '#002', '#003', '#004']) assert.equal(m02.devices.find((d) => d.alias === `trap_1105_accrate${a}`).active, false, `m02 crate ${a}`);
// research 08 §3.2: upper gate of act1 m02 keeps the top road to col 3; act1 m04 stays on row 11 down to col 4
const lane = (id, k = '12,10->9,2') => stages[id].groundPathsWithDevices[k].map((p) => p.join(',')).join(' ');
assert.equal(lane('act1autochess_m02'), '12,10 12,9 12,8 12,7 12,6 12,5 12,4 12,3 11,3 10,3 9,3 9,2');
assert.equal(lane('act1autochess_m04'), '12,10 12,9 11,9 11,8 11,7 11,6 11,5 11,4 10,4 9,4 9,3 9,2');
assert.equal(lane('act2autochess_m02'), '12,10 12,9 12,8 12,7 11,7 10,7 9,7 9,6 9,5 9,4 9,3 9,2', 'crates on col 9: col 7 (mire)');
assert.equal(lane('act2autochess_m02', '9,10->9,2'), '9,10 9,9 9,8 9,7 9,6 9,5 9,4 9,3 9,2');
});
test('stages: device rangeTiles are the direction-rotated range, inside the grid', () => {
for (const s of Object.values(stages)) {
for (const d of s.devices) {
if (!d.rangeGrid) { assert.equal(d.rangeTiles ?? null, null, `${s.id} ${d.alias}`); continue; }
assert.ok(Array.isArray(d.rangeTiles) && d.rangeTiles.length <= d.rangeGrid.length, `${s.id} ${d.alias}: rangeTiles`);
for (const [r, c] of d.rangeTiles) assert.ok(r >= 0 && r < 19 && c >= 0 && c < 21, `${s.id} ${d.alias}: (${r},${c})`);
assert.ok(d.rangeTiles.some(([r, c]) => r === d.pos[0] && c === d.pos[1]) || !d.rangeGrid.some(([a, b]) => a === 0 && b === 0), `${s.id} ${d.alias}: own tile`);
}
}
// A blower on the row-13 separator facing DOWN blows into rows 12..10 of its column.
const blower = stages.act2autochess_m01.devices.find((d) => d.key === 'trap_013_blower' && d.pos[0] === 13 && d.pos[1] === 5);
assert.deepEqual(blower.rangeTiles, [[13, 5], [12, 5], [11, 5], [10, 5]]);
});
test('stages: player-facing names are clean Chinese "战场#NN…" labels (no research annotations)', () => {
// Regression: research 05 named act1 m01 '战场#01 (upper half #01)'; the English note reached the
// briefing BATTLEFIELD row. Names come from research only, so the build must strip such notes.
for (const s of Object.values(stages)) {
if (s.kind === 'unite') continue; // the two 联防 maps: the remake's own label, checked in the 联防 test below
assert.match(s.name, /^战场#\d{2}(?:\((?:上半|下半)\))?(?: \S.*)?$/, `${s.id}: name ${JSON.stringify(s.name)}`);
assert.doesNotMatch(s.name, /[A-Za-z]/, `${s.id}: Latin text in name ${JSON.stringify(s.name)}`);
assert.equal(s.name, s.name.trim().replace(/\s+/g, ' '), `${s.id}: stray whitespace in name`);
}
assert.equal(stages.act1autochess_m01.name, '战场#01');
assert.equal(stages.act2autochess_m01.name, '战场#05(下半) 源石流发生装置');
});
test('stages: the 联防 maps (GitHub #41) — the escaped template levels, one per helper count, never a match stage', () => {
// act2autochess constData escapedBattleTemplateMapSinglePlayer / MultiPlayer → config.unite.templates { 1, 2 }
assert.deepEqual(config.unite.templates, { 1: 'act1autochess_escaped_single', 2: 'act1autochess_escaped_multi' });
for (const [n, id] of Object.entries(config.unite.templates)) {
const s = stages[id];
assert.ok(s, id);
assert.equal(s.kind, 'unite');
assert.equal(s.helpers, Number(n));
assert.equal(s.active, false);
assert.equal(s.weight, 0);
assert.deepEqual(s.modes, []);
assert.ok(!Object.values(config.modes).some((m) => m.stages.includes(id)), `${id} in no mode's stage list`);
assert.equal(s.name, `联防阵地(${n}名玩家)`);
assert.deepEqual(s.devices, [], 'no crates, platforms or other devices');
assert.deepEqual(s.special, {}, 'no water, mire, smog or infection');
// the official level map (level_act1autochess_escaped_*.json): two road halves joined at col 10, not the round's stage
assert.deepEqual(s.rows.slice(9, 13), ['##ErrrrrrrSrrrrrrrS##', '###rrrrrrr#rrrrrrr###', '###rrrrrrr#rrrrrrr###', '###rrrrrrrSrrrrrrrS##']);
for (let r = 9; r <= 12; r++) for (const c of [19, 20]) assert.equal(s.tiles[s.rows[r][c]].groundPassable, false, `${id} (${r},${c}) is no ground`);
assert.ok(s.groundPaths['9,10->9,2'] && s.groundPaths['9,18->9,2'], 'walkable from both gates');
}
// the two maps are tile for tile the same: they differ in their routes (data/waves.json — 1 helper enters at col 10,
// 2 helpers at col 18 through the checkpoint (9,10))
assert.deepEqual(stages.act1autochess_escaped_single.rows, stages.act1autochess_escaped_multi.rows);
assert.deepEqual(waves.act1autochess_escaped_single.routes.map((r) => r.start), [[9, 10], [9, 10], [12, 10], [9, 10], [9, 10], [9, 10], [9, 10], [9, 10]]);
assert.deepEqual(waves.act1autochess_escaped_multi.routes.map((r) => r.start), [[9, 18], [9, 18], [12, 18], [9, 18], [9, 18], [9, 18], [9, 18], [9, 18]]);
assert.deepEqual(waves.act1autochess_escaped_multi.routes[3].checkpoints, [[9, 10]]);
});
test('official spot checks (hard-coded values from the zh_CN client data)', () => {
const st = (id) => { const s = chess[id].stats; return [s.maxHp, s.atk, s.def, s.res, s.blockCnt, s.cost]; };
assert.deepEqual(st('chess_char_6_11_b'), [1893, 492, 141, 0, 1, 15]); // 缪尔赛思 精锐 (E2 60 + module Lv3)
assert.deepEqual(st('chess_char_6_17_b'), [3593, 1108, 279, 0, 1, 19]); // 耀骑士临光 精锐
assert.deepEqual(st('chess_char_3_08_a'), [1594, 629, 170, 15, 1, 25]); // 薄绿 (E2 1)
assert.deepEqual(st('chess_char_2_11_a'), [1816, 547, 254, 0, 2, 15]); // 风丸 (E1 60)
assert.deepEqual(st('chess_char_4_07_b'), [2383, 604, 359, 0, 1, 13]); // 风笛 精锐
assert.equal(chess.chess_char_6_11_b.module.level, 3);
assert.deepEqual([chess.chess_char_4_07_b.skill.skillId, chess.chess_char_4_07_b.skill.level, chess.chess_char_4_07_b.skill.spCost], ['skchr_bpipe_2', 7, 5]);
const es = (k) => { const s = enemies[k].stats; return [s.maxHp, s.atk, s.def, s.res]; };
assert.deepEqual(es('enemy_1422_lrsldr'), [4700, 300, 100, 40]); // 萨卡兹枯朽前锋 (template N)
assert.deepEqual(es('enemy_1427_lrnazg'), [15000, 900, 500, 50]); // “灵幛” (template E)
assert.deepEqual(es('enemy_1045_hammer'), [10000, 1000, 1000, 0]); // 粉碎攻坚手
assert.deepEqual(bosses.boss_1.bloodPoint, { FUNNY: 247500, NORMAL: 675000, HARD: 1800000, ABYSS: 3600000 });
assert.deepEqual(bosses.boss_10.bloodPoint, { FUNNY: 825000, NORMAL: 1012500, HARD: 3800000, ABYSS: 7600000 });
assert.deepEqual([items.chess_item_1_01_e_a.name, items.chess_item_1_01_e_a.price, items.chess_item_1_01_e_a.params.atk], ['维式重锤', 1, 0.15]);
assert.deepEqual([bands.band_sarkazb.totalHp, bands.band_lisa.totalHp], [45, 20]);
assert.deepEqual(config.modes.mode_multi_abyss.rounds['15'].prepTime, 215);
assert.equal(config.modes.mode_multi_abyss.enemyScale['6'].hp, 2.239488); // 1.2^4 × 1.08
});
/** Read an official cache file (only called when HAS_CACHE). */
const raw = (rel) => JSON.parse(readFileSync(join(CACHE, rel), 'utf8'));
test('independent re-derivation of every chess and enemy stat from the raw official tables', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
const act = raw('excel/activity_table.json').activity.AUTOCHESS_SEASON.act2autochess;
const CT = raw('excel/character_table.json'), BE = raw('excel/battle_equip_table.json'), ST = raw('excel/skill_table.json');
const PH = { PHASE_0: 0, PHASE_1: 1, PHASE_2: 2 };
let n = 0;
for (const [id, cd] of Object.entries(act.charChessDataDict)) {
const c = chess[id];
if (!c || c.isDiy) continue;
const shop = act.charShopChessDatas[act.chessNormalIdLookupDict[id] || id];
const P = CT[shop.charId].phases[PH[cd.status.evolvePhase]];
const k0 = P.attributesKeyFrames[0], k1 = P.attributesKeyFrames[P.attributesKeyFrames.length - 1];
const t = (cd.status.charLevel - k0.level) / (k1.level - k0.level || 1);
const f = (key) => k0.data[key] + (k1.data[key] - k0.data[key]) * t;
const exp = { maxHp: f('maxHp'), atk: f('atk'), def: f('def'), res: f('magicResistance'), aspd: f('attackSpeed'), blockCnt: f('blockCnt'), cost: f('cost') };
const map = { max_hp: 'maxHp', atk: 'atk', def: 'def', magic_resistance: 'res', attack_speed: 'aspd', block_cnt: 'blockCnt', cost: 'cost' };
if (cd.status.equipLevel > 0 && shop.defaultUniEquipId) {
const mp = BE[shop.defaultUniEquipId]?.phases.find((p) => p.equipLevel === cd.status.equipLevel);
for (const b of mp?.attributeBlackboard || []) if (map[b.key]) exp[map[b.key]] += b.value;
}
for (const [key, v] of Object.entries(exp)) assert.ok(Math.abs(v - c.stats[key]) <= 0.5 + 1e-9, `${id} ${c.name}: ${key} ${c.stats[key]} vs official ${v}`);
const lv = ST[CT[shop.charId].skills[shop.defaultSkillIndex].skillId].levels[cd.status.skillLevel - 1];
for (const e of lv.blackboard) if (!(e.valueStr && e.value === 0)) assert.ok(Math.abs(c.skill.bb[e.key] - e.value) < 1e-6, `${id}: skill bb ${e.key}`);
assert.deepEqual([c.skill.spCost, c.skill.initSp, c.skill.duration], [lv.spData.spCost, lv.spData.initSp, lv.duration], `${id}: skill sp`);
// every selectable skill (DESIGN §16) at the chess skill level
const ph = PH[cd.status.evolvePhase];
const unlockedIdx = CT[shop.charId].skills.map((s, i) => [s, i]).filter(([s, i]) => s.skillId && (i === shop.defaultSkillIndex || PH[s.unlockCond?.phase ?? 'PHASE_0'] <= ph)).map(([, i]) => i);
assert.deepEqual(c.skills.map((s) => s.index), unlockedIdx, `${id}: unlocked skills`);
for (const s of c.skills) {
const sl = ST[CT[shop.charId].skills[s.index].skillId].levels[cd.status.skillLevel - 1];
for (const e of sl.blackboard) if (!(e.valueStr && e.value === 0)) assert.ok(Math.abs(s.bb[e.key] - e.value) < 1e-6, `${id} S${s.index + 1}: bb ${e.key}`);
assert.deepEqual([s.spCost, s.initSp, s.duration, s.name], [sl.spData.spCost, sl.spData.initSp, sl.duration, sl.name], `${id} S${s.index + 1}: sp`);
}
for (const m of c.modules || []) {
const mp = BE[m.uniEquipId].phases.find((p) => p.equipLevel === cd.status.equipLevel);
const exp = {};
for (const b of mp.attributeBlackboard) exp[map[b.key] ?? b.key] = (exp[map[b.key] ?? b.key] || 0) + b.value;
for (const [k, v] of Object.entries(exp)) {
const f = { respawn_time: 'respawnTime', base_attack_time: 'bat', max_deploy_count: 'deployLimit', max_deck_stack_cnt: 'deckStack' }[k] ?? k;
assert.ok(Math.abs(m.attr[f] - v) < 1e-6, `${id} ${m.uniEquipId}: attr ${k} ${m.attr[f]} vs official ${v}`);
}
}
n++;
}
assert.equal(n, 258);
const db = new Map(raw('levels/enemydata/enemy_database.json').enemies.map((e) => [e.Key, e.Value]));
const ov = new Map((raw('levels/activities/act1autochess/level_autochess_enemy_data.json').enemyDbRefs || [])
.filter((r) => r.overwrittenData).map((r) => [r.id, r.overwrittenData]));
// the 鸭爵 strategy's swapped-in enemies cost 1 LP at the protection point (PRTS 卫戍协议:盟约 下半/PRTS盟约记录 鸭爵 备注
// "但进入保护目标点将减少1点目标生命值"; the database's lifePointReduce 0 is the roguelike rule)
const swapped = new Set(Object.values(act.effectBuffInfoDataDict).flat().filter((b) => b.key === 'round_start_all_player_change_enemy_2')
.flatMap((b) => b.blackboard.filter((kv) => kv.key === 'enemylist').flatMap((kv) => kv.valueStr.split(','))));
assert.equal(swapped.size, 4);
for (const e of Object.values(enemies)) {
const base = db.get(e.key).find((l) => l.level === 0).enemyData;
const o = ov.get(e.key);
// Season override if defined, else the base value if defined, else the documented default.
const pick = (get, dflt) => {
const x = o && get(o);
if (x && x.m_defined) return x.m_value;
const b = get(base);
return b && b.m_defined ? b.m_value : dflt;
};
const exp = {
maxHp: pick((x) => x.attributes?.maxHp, 0), atk: pick((x) => x.attributes?.atk, 0), def: pick((x) => x.attributes?.def, 0),
res: pick((x) => x.attributes?.magicResistance, 0), moveSpeed: pick((x) => x.attributes?.moveSpeed, 1),
bat: pick((x) => x.attributes?.baseAttackTime, 1), aspd: pick((x) => x.attributes?.attackSpeed, 100),
lpr: swapped.has(e.key) ? 1 : pick((x) => x.lifePointReduce, 1), massLevel: pick((x) => x.attributes?.massLevel, 0),
motion: pick((x) => x.motion, 'WALK'),
};
for (const [key, v] of Object.entries(exp)) {
const got = e.stats[key];
assert.ok(typeof v === 'number' ? Math.abs(v - got) < 1e-6 : v === got, `${e.key} ${e.name}: ${key} ${got} vs official ${v}`);
}
// official attribute power (RandomEnemyGenerater._GetEnemyAttrPower): DATABASE stats (no season override), float32
const at = base.attributes;
const f = Math.fround;
const P = f(f(f(f(at.atk.m_value * 5) + f(at.maxHp.m_value)) + f(at.def.m_value * 3)) + f(3 * at.magicResistance.m_value));
assert.equal(e.attrPower, P, `${e.key} ${e.name}: attrPower`);
}
// 灼藤 / 元核孽生者 count with their database ATK, not the season override 400 (research 08 §2.3)
for (const k of ['enemy_10067_ftsjc', 'enemy_1439_dslntf']) {
const e = enemies[k];
assert.equal(e.stats.atk, 400, `${k}: spawned with the season ATK`);
assert.notEqual(e.attrPower, e.stats.maxHp + 5 * e.stats.atk + 3 * e.stats.def + 3 * e.stats.res, `${k}: power from the database ATK`);
}
});
test('offline rebuild reproduces data/ byte-for-byte (data/ is not stale)', { skip: (!HAS_CACHE && 'no .cache/gamedata') || (process.env.DATA_DIR && 'DATA_DIR set') }, (t) => {
const out = mkdtempSync(join(tmpdir(), 'sp-data-'));
try {
const r = spawnSync(process.execPath, [join(ROOT, 'tools', 'build-data.mjs'), '--offline', '--quiet', '--out', out, '--report', join(out, 'report.json')], { encoding: 'utf8', timeout: 120_000 });
if (r.status !== 0 && /missing cached file/.test(r.stderr)) { t.skip('partial .cache/gamedata (run node tools/build-data.mjs once online)'); return; }
assert.equal(r.status, 0, `build failed: ${r.stderr}`);
for (const f of FILES) {
assert.ok(readFileSync(join(out, `${f}.json`)).equals(readFileSync(join(DATA, `${f}.json`))), `data/${f}.json is stale — run: node tools/build-data.mjs`);
}
} finally {
rmSync(out, { recursive: true, force: true });
}
});
test('build CLI rejects unknown options and missing values without writing anything', () => {
for (const args of [['--bogus'], ['--out'], ['--out', '--offline'], ['--refresh', '--offline']]) {
const r = spawnSync(process.execPath, [join(ROOT, 'tools', 'build-data.mjs'), ...args], { encoding: 'utf8', timeout: 30_000 });
assert.equal(r.status, 2, `${args.join(' ')}: exit ${r.status}`);
assert.match(r.stderr, /usage:/);
}
});