808 lines
53 KiB
JavaScript
808 lines
53 KiB
JavaScript
// 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:/);
|
||
}
|
||
});
|