1036 lines
59 KiB
JavaScript
1036 lines
59 KiB
JavaScript
#!/usr/bin/env node
|
||
// tools/golden.mjs — golden-result safety net for behaviour-preserving refactors (test/golden/README.md).
|
||
//
|
||
// Runs a fixed corpus of seeded, deterministic scenarios and reduces each one to a compact digest; the digests are
|
||
// stored in test/golden/<family>.json and test/golden.test.js recomputes and compares them. A refactor that reorders
|
||
// random draws, hook order or iteration order changes a digest and fails loudly (scenario, field, old → new).
|
||
//
|
||
// Usage:
|
||
// node tools/golden.mjs compare the corpus with test/golden/*.json (exit 1 on a difference)
|
||
// node tools/golden.mjs --update recompute and rewrite test/golden/*.json (npm run golden:update)
|
||
// options: --family roster,bonds,fields,matches --fast (the test's default subset) --only <id,id>
|
||
// --jobs N (worker threads, default: up to 4) --list --coverage (skills cast, stages, enemies…)
|
||
// --twice determinism: compute twice in this process (the second pass in reverse order) and compare
|
||
//
|
||
// Families (all scenarios are generated from data/*.json in a fixed order — no randomness outside the seeds):
|
||
// roster 49 battles: every visible chess record (normal + elite) with every selectable skill and module (DESIGN
|
||
// §16; 575 loadouts), 12 per battle with distinct chess ids, on a real stage (all 11, in turn), against the
|
||
// round's real wave (server/match/waves.js buildNormalWave: faction picks, enemy scale) three times over plus
|
||
// every non-leader enemy kind of data/enemies.json twice as extra spawns (half of them bounties); melee
|
||
// operators on the tiles the enemy paths cross, ranged ones beside them, every 7th facing UP / LEFT / DOWN;
|
||
// placeable summons on the board; every equipment item, band, battle-side 机变 card and stage map card in
|
||
// turn; bonds from the board (bondsMeta.computeBonds) with 0–120 layers
|
||
// bonds 46 battles: every bond at its activation threshold (1 layer) and at its top tier (999 layers), 8 operators
|
||
// fields 22 battles: every Final Assault / Hidden Core leader on its pair and its solo template (a shared boss pool,
|
||
// ended at 200 game s) and 联防 fields (1 and 2 helpers, carried HP / SP, a knocked-out operator, two
|
||
// leakers' enemies with a summoned-only kind and a bounty)
|
||
// matches 16 bot-only matches (solo 标准 / 险境 / 绝境 / 终极 ×2 seeds, co-op 2 / 3 / 4, one server-run combat match,
|
||
// two with LP and layers raised at the first prep so they reach the Hidden Core) run to the end in virtual
|
||
// time with the match's default bot rehearsal
|
||
// Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the
|
||
// path browsers and the server's headless fields use) with every option explicit; matches construct Match directly
|
||
// with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest.
|
||
//
|
||
// Digest (battle): end time, ticks, reason, kills / total / leaks, per player (kills, leaks, coins, damage, boss
|
||
// damage, healing, deaths, layer gains), per ally unit (player, board uid or `~n` for units created in battle, def id,
|
||
// loadout, final HP, alive, deployments, kills, damage dealt / taken, healing, attacks, skill casts), per enemy kind
|
||
// (spawned, killed, leaked, damage dealt / taken), hook counts, client event counts (+ fx kinds), content errors, the
|
||
// number of draws of the battle's RNG, and a chained hash of a wire snapshot every game second (one 8-hex value per 10
|
||
// game seconds: the first differing value says when two runs parted). Digest (match): setup (stage, factions, leaders),
|
||
// per round at SETTLE per player [alive, lp, funds when the prep opened, funds, pending funds, shop level, deployed,
|
||
// board hash, activated layers, bonds, kills, total, counted leaks, gold spent], the m.result summary (final lineups,
|
||
// bonds, stats, titles), the draws of every match RNG stream, errors.
|
||
// Insensitive to what does not affect gameplay: object key order (every map is written with sorted keys), engine unit
|
||
// ids (snapshot ids are renumbered by first appearance; units are named by board uid / def id), board piece uids, the
|
||
// order of client events within a tick (counted, not hashed) and wall-clock time (matches run in virtual time).
|
||
// Damage / healing sums are rounded to integers and HP to 2 decimals (float dust from a reassociated sum is not a
|
||
// gameplay change; anything that changes a fight changes the snapshot hashes too).
|
||
|
||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import { availableParallelism } from 'node:os';
|
||
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
|
||
import { getData } from '../server/data.js';
|
||
import { DataSource } from '../server/sim/simdata.js';
|
||
import { buildBattleSpec, createBattleFromSpec } from '../server/sim/spec.js';
|
||
import { createRng, deriveSeed } from '../server/sim/rng.js';
|
||
import { GameData } from '../server/match/gamedata.js';
|
||
import { setupMatchWaves, buildNormalWave, buildBossWave, buildUniteWave, isFlyKey, routeByMotion } from '../server/match/waves.js';
|
||
import { buildDeployMap, positionClass, canPlace, ownerRangeKeys, tileKey } from '../server/match/board.js';
|
||
import { computeBonds, bondSnapshot } from '../server/match/bondsMeta.js';
|
||
import { Match } from '../server/match/Match.js';
|
||
import { VirtualScheduler } from '../server/match/scheduler.js';
|
||
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../shared/loadoutRecord.js';
|
||
import { GEO } from '../shared/constants.js';
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
export const GOLDEN_DIR = join(ROOT, 'test', 'golden');
|
||
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches']);
|
||
|
||
const QUIET = Object.freeze({ warn() {}, error() {}, info() {}, log() {}, debug() {} });
|
||
const data = getData({ log: QUIET });
|
||
const ds = new DataSource(data, null); // what Match and browsers use (no research fallback)
|
||
const gdCache = new Map();
|
||
const gdFor = (modeId) => { let g = gdCache.get(modeId); if (!g) { g = new GameData(data, modeId); gdCache.set(modeId, g); } return g; };
|
||
const byId = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// hashing / numbers
|
||
|
||
const FNV = 0x811c9dc5;
|
||
function fnv(str, h = FNV) {
|
||
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||
return h >>> 0;
|
||
}
|
||
const hex8 = (h) => (h >>> 0).toString(16).padStart(8, '0');
|
||
const r2 = (v) => (Number.isFinite(v) ? Math.round(v * 100) / 100 : v === Infinity ? 'inf' : null);
|
||
const int = (v) => (Number.isFinite(v) ? Math.round(v) : null);
|
||
const sorted = (obj) => { const o = {}; for (const k of Object.keys(obj).sort(byId)) o[k] = obj[k]; return o; };
|
||
|
||
// mulberry32 (server/sim/rng.js) adds 0x6d2b79f5 to its state per draw: draws = (state − initial) × inverse mod 2³²
|
||
const RNG_STEP = 0x6d2b79f5;
|
||
const RNG_INV = (() => { let x = RNG_STEP; for (let i = 0; i < 5; i++) x = Math.imul(x, 2 - Math.imul(RNG_STEP, x)); return x >>> 0; })();
|
||
/** Number of values drawn from a createRng(seed) stream whose state is now `state`. */
|
||
export function rngDraws(seed, state) {
|
||
const s0 = (Number(seed) >>> 0) || 0x9e3779b9;
|
||
return Math.imul(((state >>> 0) - s0) >>> 0, RNG_INV) >>> 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// battle runner
|
||
|
||
/** The DESIGN hook events (counted per battle). Frozen here: a new engine hook never changes a digest by itself. */
|
||
export const HOOKS = Object.freeze([
|
||
'battleStart', 'deploy', 'tick', 'beforeAttack', 'attack', 'hit', 'damaged', 'heal', 'kill', 'death',
|
||
'skillStart', 'skillEnd', 'ammoUsed', 'spGain', 'statusApplied', 'blocked', 'enemySpawn', 'enemyLeak', 'battleEnd',
|
||
'fatal', 'layerGain', 'elementBurst', 'dodge',
|
||
]);
|
||
const SNAP_EVERY = 30; // ticks (1 game second)
|
||
const HASH_WINDOW = 300; // ticks (10 game seconds) per published hash
|
||
const DRAIN_EVERY = 3; // ticks (the match's watcher cadence)
|
||
|
||
/**
|
||
* Run one battle scenario to its end and digest it.
|
||
* @param {object} sc { id, kind, modeId, round, stageId, seed, players, spawns, routes, timeLimit, flags, enemyOverrides,
|
||
* waveId, bossId, boss, rect, fieldId, cap (game seconds; boss / hidden fields end by the pool or here) }
|
||
* @param {{ snapEvery?: number, drainEvery?: number, countHooks?: boolean, recordEvents?: boolean }} [probe] observation
|
||
* knobs for determinism probes (how often the battle is watched, hook listeners, the server's recordEvents: false);
|
||
* none of them may change the battle — only the digest fields that observe them (snaps, events, fx, hooks)
|
||
*/
|
||
export function runBattle(sc, probe = {}) {
|
||
const snapEvery = probe.snapEvery ?? SNAP_EVERY;
|
||
const drainEvery = probe.drainEvery ?? DRAIN_EVERY;
|
||
const spec = buildBattleSpec({
|
||
battleId: sc.id, fieldId: sc.fieldId ?? 'g', kind: sc.kind, seed: sc.seed, modeId: sc.modeId, round: sc.round,
|
||
stageId: sc.stageId, rect: sc.rect, timeLimit: sc.timeLimit, players: sc.players, spawns: sc.spawns, routes: sc.routes,
|
||
flags: sc.flags, enemyOverrides: sc.enemyOverrides ?? {}, waveId: sc.waveId ?? null, bossId: sc.bossId ?? null,
|
||
boss: sc.boss ?? null, content: 'full',
|
||
});
|
||
const b = createBattleFromSpec(spec, ds, { quiet: true, logger: QUIET, recordEvents: probe.recordEvents !== false });
|
||
const hooks = {};
|
||
for (const name of HOOKS) { hooks[name] = 0; if (probe.countHooks !== false) b.on(name, () => { hooks[name]++; }, { priority: -1000 }); }
|
||
const ev = {};
|
||
const fx = {};
|
||
const drain = () => {
|
||
for (const e of b.drainEvents()) {
|
||
ev[e[0]] = (ev[e[0]] || 0) + 1;
|
||
if (e[0] === 'fx') fx[e[1]] = (fx[e[1]] || 0) + 1;
|
||
}
|
||
};
|
||
const ids = new Map(); // engine id → order of first appearance in the snapshots
|
||
const cid = (id) => { let v = ids.get(id); if (v == null) { v = ids.size; ids.set(id, v); } return v; };
|
||
const snaps = [];
|
||
let chain = FNV;
|
||
let win = FNV;
|
||
const cap = Number.isFinite(sc.cap) ? sc.cap : 1e9;
|
||
let ticks = 0;
|
||
while (!b.finished) {
|
||
if (b.time >= cap - 1e-9) { b.forceEnd('forced'); break; }
|
||
b.step();
|
||
ticks++;
|
||
if (drainEvery > 0 && ticks % drainEvery === 0) drain();
|
||
if (snapEvery > 0 && ticks % snapEvery === 0) {
|
||
const s = b.snapshot();
|
||
const canon = {
|
||
t: s.t, dp: s.dp, dps: s.dps ?? null, killed: s.killed, total: s.total, boss: s.boss ?? null,
|
||
units: s.units.map((u) => [cid(u[0]), ...u.slice(1)]),
|
||
down: (s.down || []).map((d) => [cid(d[0]), ...d.slice(1)]),
|
||
elem: (s.elem || []).map((d) => [cid(d[0]), ...d.slice(1)]),
|
||
};
|
||
win = fnv(JSON.stringify(canon), win);
|
||
}
|
||
if (ticks % HASH_WINDOW === 0) { chain = fnv(hex8(win), chain); snaps.push(hex8(chain)); win = FNV; }
|
||
}
|
||
drain();
|
||
if (win !== FNV) { chain = fnv(hex8(win), chain); snaps.push(hex8(chain)); }
|
||
return digestBattle(sc, b, { hooks, ev, fx, snaps, ticks });
|
||
}
|
||
|
||
function digestBattle(sc, b, { hooks, ev, fx, snaps, ticks }) {
|
||
const res = b.result();
|
||
const players = {};
|
||
for (const pid of Object.keys(res.perPlayer || {}).sort(byId)) {
|
||
const pp = res.perPlayer[pid];
|
||
const leaked = pp.leaked || [];
|
||
players[pid] = {
|
||
killed: pp.killed, total: pp.total, perfect: !!pp.perfect,
|
||
leaks: leaked.filter((l) => l.counted !== false).length, leaksUncounted: leaked.filter((l) => l.counted === false).length,
|
||
coins: pp.coins, dmg: int(pp.damageDealt), bossDmg: int(pp.bossDamage), heal: int(pp.healingDone), deaths: pp.deaths,
|
||
layerGains: sorted({ ...(pp.layerGains || {}) }),
|
||
};
|
||
}
|
||
// ally units in creation order, named by board uid (pieces) or `~n` (the n-th unit of that def created in battle)
|
||
const seen = new Map();
|
||
const units = [];
|
||
for (const u of b.allyUnits) {
|
||
let ref;
|
||
if (u.uid != null) ref = u.uid;
|
||
else { const k = `${u.ownerId}|${u.defId}`; const n = (seen.get(k) || 0) + 1; seen.set(k, n); ref = `~${n}`; }
|
||
const lo = u.def && u.def.loadout ? u.def.loadout : null;
|
||
const s = u.stats || {};
|
||
units.push([
|
||
u.ownerId ?? '-', ref, u.kind, u.defId, lo && lo.skillIndex != null ? lo.skillIndex : null, lo && lo.moduleId != null ? lo.moduleId : null,
|
||
r2(u.hp), u.alive ? 1 : 0, u.deploySeq, s.kills ?? 0, int(s.dmg ?? 0), int(s.taken ?? 0), int(s.heal ?? 0), s.attacks ?? 0,
|
||
u.skill && !u.skill.noSkill ? u.skill.activations : null,
|
||
]);
|
||
}
|
||
const enemies = {};
|
||
for (const u of b.units) {
|
||
if (u.side !== 'enemy') continue;
|
||
const e = enemies[u.defId] || (enemies[u.defId] = [0, 0, 0, 0, 0]);
|
||
e[0]++;
|
||
if (u.removeReason === 'killed') e[1]++;
|
||
else if (u.removeReason === 'leak') e[2]++;
|
||
e[3] += (u.stats && u.stats.dmg) || 0;
|
||
e[4] += (u.stats && u.stats.taken) || 0;
|
||
}
|
||
for (const e of Object.values(enemies)) { e[3] = int(e[3]); e[4] = int(e[4]); }
|
||
const errors = [...new Set((b.errors || []).map((e) => `${e.label}|${e.who}|${e.message}`.slice(0, 120)))].sort(byId);
|
||
return {
|
||
kind: sc.kind, stageId: sc.stageId, modeId: sc.modeId, round: sc.round, seed: sc.seed, waveId: sc.waveId ?? null,
|
||
time: r2(res.time), ticks, reason: res.reason, killed: res.killed, total: res.total,
|
||
unspawned: (res.unspawned || []).length, bossHpLeft: res.bossHpLeft != null ? int(res.bossHpLeft) : null,
|
||
rngDraws: rngDraws(b.seed, b.rng.state()),
|
||
errors: { count: b.errorCount ?? (b.errors || []).length, kinds: errors },
|
||
players,
|
||
units,
|
||
enemies: sorted(enemies),
|
||
hooks: HOOKS.map((name) => hooks[name]),
|
||
events: sorted(ev),
|
||
fx: sorted(fx),
|
||
snaps: snaps.join(' '),
|
||
};
|
||
}
|
||
const UNIT_COLS = Object.freeze(['player', 'ref', 'kind', 'def', 'skill', 'module', 'hp', 'alive', 'deploys', 'kills', 'dmg', 'taken', 'heal', 'attacks', 'casts']);
|
||
const ENEMY_COLS = Object.freeze(['spawned', 'killed', 'leaked', 'dmg', 'taken']);
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// corpus helpers (pure functions of data/*.json)
|
||
|
||
const CHESS = Object.values(data.chess).sort((a, b) => byId(a.chessId, b.chessId));
|
||
const VISIBLE = CHESS.filter((c) => c.visible);
|
||
const STAGES = Object.keys(data.stages).sort(byId);
|
||
const BANDS = Object.keys(data.bands).sort(byId);
|
||
const EQUIPS = Object.values(data.items).filter((i) => i.itemType === 'EQUIP').map((i) => i.id).sort(byId);
|
||
const BOSSES = Object.keys(data.bosses).sort((a, b) => Number(a.replace(/\D/g, '')) - Number(b.replace(/\D/g, '')));
|
||
const LEADER_KEYS = new Set();
|
||
for (const bo of Object.values(data.bosses)) { LEADER_KEYS.add(bo.enemyKey); for (const p of bo.parts || []) LEADER_KEYS.add(typeof p === 'string' ? p : p.enemyKey ?? p.key); }
|
||
const ACLOON = 'enemy_9012_acloon'; // the 炎 bond's summon, never an enemy
|
||
const EXTRA_ENEMIES = Object.keys(data.enemies).filter((k) => !LEADER_KEYS.has(k) && k !== ACLOON).sort(byId);
|
||
/** 机变 cards with a battle part (content/choices.js battlePlanOf: 战术决策 operator / enemy / heal effects). */
|
||
const BATTLE_CARD_KEYS = new Set(['global_special_choice_all_activated', 'env_gbuff_new_with_verify', 'env_gbuff_new', 'char_respawntime_mul', 'enemy_attribute_mul', 'enemy_attribute_add', 'global_special_choice_prep_finish_same_row_at_least', 'global_special_choice_prep_finish_bench_at_least', 'global_special_choice_prep_finish_bench_at_most']);
|
||
const BATTLE_CARDS = Object.values(data.effects)
|
||
.filter((e) => e.effectType === 'BUFF_GAIN' && (e.buffs || []).some((b) => BATTLE_CARD_KEYS.has(b.key)) && !(e.buffs || []).some((b) => b.key === 'auto_chess_change_map'))
|
||
.map((e) => e.effectId).sort(byId);
|
||
/** 模拟战场演变 map cards (auto_chess_change_map): { id, overrides: { alias: bool } }, used on the stages that have every alias. */
|
||
const MAP_CARDS = Object.values(data.effects)
|
||
.filter((e) => e.effectType === 'BUFF_GAIN' && (e.buffs || []).some((b) => b.key === 'auto_chess_change_map'))
|
||
.map((e) => ({ id: e.effectId, overrides: Object.fromEntries(Object.entries(e.buffs.find((b) => b.key === 'auto_chess_change_map').bb || {}).map(([k, v]) => [k, !!v])) }))
|
||
.sort((a, b) => byId(a.id, b.id));
|
||
const mapCardsFor = (stageId) => {
|
||
const st = data.stages[stageId];
|
||
const aliases = new Set((st && st.devices ? st.devices : []).map((d) => d.alias).filter(Boolean));
|
||
return MAP_CARDS.filter((c) => Object.keys(c.overrides).length && Object.keys(c.overrides).every((a) => aliases.has(a)));
|
||
};
|
||
|
||
/** Loadout variants of a chess record: default first; every skill and every module (+ 'none') at least once. */
|
||
function variantsOf(rec) {
|
||
const skills = Array.isArray(rec.skills) && rec.skills.length ? rec.skills.map((s) => s.index) : [null];
|
||
const defSkill = rec.skill && Number.isInteger(rec.skill.index) ? rec.skill.index : skills[0];
|
||
const S = [defSkill, ...skills.filter((i) => i !== defSkill)];
|
||
let M = [null];
|
||
if (Array.isArray(rec.modules) && rec.modules.length) {
|
||
const def = rec.modules.find((m) => m.isDefault)?.uniEquipId ?? 'none';
|
||
M = [def, ...rec.modules.map((m) => m.uniEquipId).filter((id) => id !== def)];
|
||
if (!M.includes('none')) M.push('none');
|
||
}
|
||
const n = Math.max(S.length, M.length);
|
||
const out = [];
|
||
for (let v = 0; v < n; v++) out.push({ chessId: rec.chessId, skillIndex: S[v % S.length], moduleId: M[v % M.length], v });
|
||
return out;
|
||
}
|
||
|
||
/** Cursor over a list (deterministic round robin). */
|
||
function cursor(list) {
|
||
let i = 0;
|
||
return { next() { const x = list[i % list.length]; i++; return x; } };
|
||
}
|
||
|
||
const placeName = (unitList) => unitList.map((u) => u.chessId ?? u.tokenId);
|
||
|
||
/** Board tiles of a deploy field's enemy ground paths (stages.json groundPaths) → number of paths through each tile. */
|
||
function pathTraffic(stageId, field, colOffset = 0) {
|
||
const st = data.stages[stageId];
|
||
const out = new Map();
|
||
for (const [key, tiles] of Object.entries((st && st.groundPaths) || {})) {
|
||
const [tr, tc] = key.split('->')[1].split(',').map(Number);
|
||
let toBoard = null;
|
||
if (field === 'normal' && tr >= 9) toBoard = ([r, c]) => [r, c - colOffset];
|
||
else if (field === 'bossL' && tr < 7 && tc <= 10) toBoard = ([r, c]) => [r + 7, c];
|
||
else if (field === 'bossR' && tr < 7 && tc > 10) toBoard = ([r, c]) => [r + 7, 20 - c];
|
||
if (!toBoard) continue;
|
||
for (const t of tiles) {
|
||
const [r, c] = toBoard(t);
|
||
if (r >= 9 && r <= 12 && c >= 2 && c <= 10) out.set(tileKey(r, c), (out.get(tileKey(r, c)) || 0) + 1);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Lay out operators on a player's board (board coordinates rows 9–12, cols 2–10) for deploy field `field` of the
|
||
* stage: melee operators (most blockers first) on the deployable tiles the enemy ground paths cross most, ranged ones
|
||
* next to the paths; operators that do not fit are returned in `rest`. Every 7th operator faces UP / LEFT / DOWN.
|
||
* Placeable summons (gamedata.placeableTokens: 赫默's drone on S2, 斯卡蒂's 海嗣, the tacticians' packs inside the
|
||
* owner's range, 凯瑟琳's shields) take free tiles after the operators.
|
||
*/
|
||
function layout(gd, stageId, wanted, { field = 'normal', colOffset = 0, max = 12, dirs = true, uid0 = 1 } = {}) {
|
||
const map = buildDeployMap(data.stages[stageId], { field });
|
||
const traffic = pathTraffic(stageId, field, colOffset);
|
||
const pathList = [...traffic.keys()].map((k) => k.split(',').map(Number));
|
||
const dist = (r, c) => (pathList.length ? Math.min(...pathList.map(([pr, pc]) => Math.max(Math.abs(pr - r), Math.abs(pc - c)))) : 9);
|
||
const used = new Set();
|
||
const units = [];
|
||
const rest = [];
|
||
const tiles = (cls) => {
|
||
const out = [];
|
||
for (let r = 12; r >= 9; r--) for (let c = 2; c <= 10; c++) if (!used.has(tileKey(r, c)) && canPlace(map, cls, r, c)) out.push([r, c]);
|
||
if (cls === 'melee') return out.sort((a, b) => (traffic.get(tileKey(b[0], b[1])) || 0) - (traffic.get(tileKey(a[0], a[1])) || 0));
|
||
const rank = (r, c) => { const d = dist(r, c); return d === 0 ? 1.5 : d; }; // beside a path first, then on it
|
||
return out.sort((a, b) => rank(a[0], a[1]) - rank(b[0], b[1]));
|
||
};
|
||
const isMelee = (w) => positionClass(data.chess[w.chessId], w.moduleId) === 'melee';
|
||
const order = wanted.slice().sort((a, b) => (isMelee(b) - isMelee(a)) || (isMelee(a) ? (data.chess[b.chessId].stats.blockCnt || 0) - (data.chess[a.chessId].stats.blockCnt || 0) : 0));
|
||
let uid = uid0;
|
||
for (const w of order) {
|
||
const rec = data.chess[w.chessId];
|
||
const free = units.length < max ? tiles(positionClass(rec, w.moduleId)) : [];
|
||
if (!free.length) { rest.push(w); continue; }
|
||
const [r, c] = free[0];
|
||
used.add(tileKey(r, c));
|
||
const dir = dirs && units.length % 7 === 6 ? ['UP', 'LEFT', 'DOWN'][Math.floor(units.length / 7) % 3] : 'RIGHT';
|
||
const u = { uid: uid++, kind: 'chess', chessId: w.chessId, row: r, col: c, dir, items: w.items ?? [] };
|
||
if (w.skillIndex != null) u.skillIndex = w.skillIndex;
|
||
if (w.moduleId != null) u.moduleId = w.moduleId;
|
||
if (w.carryState) u.carryState = w.carryState;
|
||
units.push(u);
|
||
}
|
||
// placeable summons of the placed operators
|
||
const tokens = [];
|
||
for (const u of units) {
|
||
for (const { tokenId, count } of gd.placeableTokens(u.chessId, { skillIndex: u.skillIndex ?? null })) {
|
||
const trec = data.tokens[tokenId];
|
||
let allowed = null;
|
||
if (trec && trec.ownerRange) {
|
||
const rec = data.chess[u.chessId];
|
||
const lo = resolveRecordLoadout(rec, { skillIndex: u.skillIndex, moduleId: u.moduleId });
|
||
allowed = ownerRangeKeys(attackRangeGrid(loadoutRecord(rec, lo)), u.row, u.col, u.dir);
|
||
}
|
||
for (let k = 0; k < Math.min(count, 2); k++) {
|
||
const free = tiles(positionClass(trec)).filter(([r, c]) => !allowed || allowed.has(tileKey(r, c)));
|
||
if (!free.length) break;
|
||
const [r, c] = free[0];
|
||
used.add(tileKey(r, c));
|
||
tokens.push({ uid: uid++, kind: 'token', tokenId, ownerUid: u.uid, row: r, col: c, dir: u.dir });
|
||
}
|
||
}
|
||
}
|
||
return { units: units.concat(tokens), rest, nextUid: uid };
|
||
}
|
||
|
||
/** Bond snapshot of a board (bondsMeta.computeBonds over a stand-in PlayerState) with `layers` on every bond. */
|
||
function bondsOf(gd, units, layers) {
|
||
const board = new Map();
|
||
for (const u of units) if (u.kind === 'chess') board.set(tileKey(u.row, u.col), { kind: 'chess', id: u.chessId, items: (u.items || []).map((id) => ({ id })) });
|
||
const L = {};
|
||
for (const id of gd.bondIds) L[id] = typeof layers === 'function' ? layers(id) : layers;
|
||
return bondSnapshot(computeBonds(gd, { board, hand: [], layers: L, bondCountBonus: {} }));
|
||
}
|
||
|
||
/** A real round wave of `modeId` (faction picks from a seeded match setup) for the normal field. */
|
||
function normalWave(gd, round, seed) {
|
||
const setup = setupMatchWaves(gd, createRng(deriveSeed(seed, 'setup')));
|
||
return buildNormalWave(gd, createRng(deriveSeed(seed, 'waves')), setup, round);
|
||
}
|
||
|
||
const playerInput = (pid, seat, units, { side = 'L', colOffset = 0, bonds = {}, bandId = null, effects = [], deviceOverrides = {}, extra = {} } = {}) => ({
|
||
playerId: pid, seat, side, colOffset, units, bonds, bandId, playerEffects: effects, deviceOverrides, ...extra,
|
||
});
|
||
const cardRef = (effectId, n = 1) => ({ id: `choice:${effectId}#${n}`, key: `choice:${effectId}`, source: 'choice', params: { prepOk: true }, data: { effectId }, counter: null });
|
||
|
||
/** Extra spawns: enemy kinds outside the round template, every other one as a kill bounty (waves.js bountySpawns). */
|
||
function extraSpawns(gd, routes, keys, round, pid, t0 = 8, step = 5) {
|
||
const scale = gd.enemyScale(round);
|
||
return keys.map((key, i) => {
|
||
const fly = isFlyKey(gd, key);
|
||
let routeIndex = routeByMotion(routes, fly);
|
||
if (!(routeIndex >= 0)) routeIndex = 0;
|
||
const s = { time: t0 + i * step, enemyKey: key, routeIndex, count: 1, interval: 0, mods: { hpMul: scale.hpMul, atkMul: scale.atkMul, speedMul: scale.speedMul, slot: fly ? 'NF' : 'N' }, ownerPlayerId: pid };
|
||
if (i % 2 === 0) { s.tag = 'bounty'; s.bounty = { coins: 2, ownerPlayerId: pid }; s.mods.bountyCoins = 2; }
|
||
return s;
|
||
});
|
||
}
|
||
|
||
const ROSTER_MODES = Object.freeze(['mode_multi_normal', 'mode_multi_hard', 'mode_single_normal', 'mode_multi_abyss', 'mode_multi_funny', 'mode_single_hard']);
|
||
/** Round by strength: normal tier t ↔ elite tier t − 1 (band = tier + elite). */
|
||
const BAND_ROUND = Object.freeze({ 1: 2, 2: 4, 3: 6, 4: 8, 5: 10, 6: 12, 7: 13 });
|
||
const LAYER_STEPS = Object.freeze([0, 2, 10, 40, 120]);
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// family: roster
|
||
|
||
export function rosterScenarios() {
|
||
// every loadout variant of every visible record, by variant pass, then strength band (tier + elite), then id; a
|
||
// battle takes up to 12 of them with distinct chess ids and bands within one of the first waiting instance's
|
||
const queue = [];
|
||
for (const rec of VISIBLE) for (const v of variantsOf(rec)) queue.push({ ...v, band: rec.tier + (rec.isGolden ? 1 : 0) });
|
||
queue.sort((a, b) => a.v - b.v || a.band - b.band || byId(a.chessId, b.chessId));
|
||
const items = cursor(EQUIPS);
|
||
const bands = cursor(BANDS);
|
||
const cards = cursor(BATTLE_CARDS);
|
||
const scenarios = [];
|
||
let n = 0;
|
||
while (queue.length) {
|
||
const i = n++;
|
||
const stageId = STAGES[i % STAGES.length];
|
||
const modeId = ROSTER_MODES[i % ROSTER_MODES.length];
|
||
const gd = gdFor(modeId);
|
||
const first = queue[0];
|
||
const picked = [];
|
||
const ids = new Set();
|
||
for (const w of queue) {
|
||
if (picked.length >= 12) break;
|
||
if (ids.has(w.chessId) || Math.abs(w.band - first.band) > 1) continue;
|
||
picked.push(w);
|
||
ids.add(w.chessId);
|
||
}
|
||
const chunk = picked.map((w, j) => ({ ...w, items: j % 3 === 2 ? [items.next(), items.next()] : [items.next()] }));
|
||
const { units } = layout(gd, stageId, chunk, { max: 12 });
|
||
if (!units.length) throw new Error(`roster: nothing fits on ${stageId}`);
|
||
const placed = new Set(units.filter((u) => u.kind === 'chess').map((u) => u.chessId));
|
||
for (let q = queue.length - 1; q >= 0; q--) if (picked.includes(queue[q]) && placed.has(queue[q].chessId)) queue.splice(q, 1);
|
||
const band = Math.max(...picked.filter((w) => placed.has(w.chessId)).map((w) => w.band));
|
||
const round = BAND_ROUND[band];
|
||
const seed = deriveSeed(20261005, `roster:${i}`);
|
||
const wave = normalWave(gd, round, seed);
|
||
const pid = 'p1';
|
||
// every other battle on a stage with 模拟战场演变 map cards plays the next of them (deviceOverrides)
|
||
const visit = Math.floor(i / STAGES.length);
|
||
const maps = mapCardsFor(stageId);
|
||
const deviceOverrides = maps.length && visit % 2 === 1 ? maps[((visit - 1) / 2) % maps.length].overrides : {};
|
||
const effects = [cardRef(cards.next(), 1), cardRef(cards.next(), 2)];
|
||
scenarios.push({
|
||
id: `roster-${String(i + 1).padStart(3, '0')}`, family: 'roster', kind: 'normal', modeId, round, stageId, seed, pass: first.v,
|
||
rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides,
|
||
flags: { layerGainsEnabled: true, ...gd.dp },
|
||
players: [playerInput(pid, 0, units, { bonds: bondsOf(gd, units, LAYER_STEPS[i % LAYER_STEPS.length]), bandId: bands.next(), effects, deviceOverrides })],
|
||
// the round's wave three times (a quarter of the time limit apart): the operators fight long enough to cast their
|
||
// skills (one copy is cleared in ~40–80 s by 12 operators)
|
||
spawns: [0, 1, 2].flatMap((k) => wave.spawns.map((s) => ({ ...s, time: s.time + k * Math.round(wave.timeLimit / 4), ownerPlayerId: pid }))),
|
||
about: `pass ${first.v} band ${band}: ${placeName(units).join(' ')}`,
|
||
});
|
||
}
|
||
// every non-leader enemy kind joins one pass-0 roster battle (default loadouts) as an extra spawn — and, a second
|
||
// time, one of the other battles (the operators of the later passes need the enemies as much)
|
||
const pass0 = scenarios.filter((sc) => sc.pass === 0);
|
||
const later = scenarios.filter((sc) => sc.pass > 0);
|
||
EXTRA_ENEMIES.forEach((key, j) => {
|
||
for (const sc of [pass0[j % pass0.length], later[(j * 7) % later.length]]) if (sc) (sc._extra || (sc._extra = [])).push(key);
|
||
});
|
||
for (const sc of scenarios) {
|
||
if (!sc._extra) continue;
|
||
sc.spawns = sc.spawns.concat(extraSpawns(gdFor(sc.modeId), sc.routes, sc._extra, sc.round, 'p1'));
|
||
delete sc._extra;
|
||
}
|
||
return scenarios;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// family: bonds
|
||
|
||
const BOND_MODES = Object.freeze(['mode_multi_normal', 'mode_multi_hard', 'mode_single_normal', 'mode_single_hard']);
|
||
|
||
export function bondScenarios() {
|
||
const scenarios = [];
|
||
const bondIds = Object.keys(data.bonds).sort(byId);
|
||
const items = cursor(EQUIPS.filter((id) => /_b$/.test(id)));
|
||
const fillLow = cursor(VISIBLE.filter((c) => !c.isGolden && c.tier >= 3 && c.tier <= 4).map((c) => c.chessId));
|
||
const fillHigh = cursor(VISIBLE.filter((c) => c.isGolden && c.tier >= 4 && c.tier <= 5).map((c) => c.chessId));
|
||
let n = 0;
|
||
for (const bondId of bondIds) {
|
||
const bond = data.bonds[bondId];
|
||
const th = Array.isArray(bond.thresholds) && bond.thresholds.length ? bond.thresholds : [bond.activeCount || 1];
|
||
const golden = bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden';
|
||
const downward = bond.thresholdTemplate === 'count_threshold_downward';
|
||
// members by base id (visible records first); 绝技 counts every elite on the board
|
||
const bases = [];
|
||
const pool = golden ? VISIBLE.filter((c) => c.isGolden) : CHESS.filter((c) => (c.bonds || []).includes(bondId));
|
||
for (const c of pool.sort((a, b) => (b.visible - a.visible) || byId(a.chessId, b.chessId))) if (!bases.includes(c.baseId)) bases.push(c.baseId);
|
||
for (const level of ['low', 'high']) {
|
||
const i = n++;
|
||
const modeId = BOND_MODES.find((m, k) => k >= i % BOND_MODES.length && !gdFor(m).modeInactiveBonds.has(bondId)) ?? BOND_MODES.find((m) => !gdFor(m).modeInactiveBonds.has(bondId));
|
||
const gd = gdFor(modeId);
|
||
const stageId = STAGES[(i * 3) % STAGES.length];
|
||
const round = level === 'low' ? 5 : 10;
|
||
const seed = deriveSeed(20261005, `bond:${bondId}:${level}`);
|
||
const want = level === 'low' || downward ? th[0] : Math.min(th[th.length - 1], 10);
|
||
const picks = [];
|
||
for (const base of bases.slice(0, want)) {
|
||
const ids = CHESS.filter((c) => c.baseId === base).map((c) => c.chessId).sort(byId);
|
||
picks.push((level === 'high' || golden) && ids.length > 1 ? ids[ids.length - 1] : ids[0]);
|
||
}
|
||
// 助力: the upper tier counts operators that differ in name OR elite state — add the other copy of two members
|
||
if (bondId === 'deputShip' && level === 'high') {
|
||
for (const base of bases.slice(0, 2)) for (const c of CHESS.filter((x) => x.baseId === base)) if (!picks.includes(c.chessId)) picks.push(c.chessId);
|
||
}
|
||
// 调和: two 炎 members next to it, so its +1 lifts a core bond to its threshold
|
||
if (bondId === 'maniShip') picks.push(...VISIBLE.filter((c) => !c.isGolden && (c.bonds || []).includes('yanShip')).slice(0, 2).map((c) => c.chessId));
|
||
// fill the board to 8 operators with non-members (normal records only when elites are what the bond counts)
|
||
const fill = level === 'low' || golden ? fillLow : fillHigh;
|
||
for (let guard = 0; picks.length < 8 && guard < 200; guard++) {
|
||
const id = fill.next();
|
||
const rec = data.chess[id];
|
||
if (picks.some((p) => data.chess[p].baseId === rec.baseId) || (rec.bonds || []).includes(bondId) || (golden && rec.isGolden)) continue;
|
||
picks.push(id);
|
||
}
|
||
const wanted = picks.map((id, j) => ({ chessId: id, items: level === 'high' && j < 4 ? [items.next()] : [] }));
|
||
const { units } = layout(gd, stageId, wanted, { max: 12, dirs: false });
|
||
const wave = normalWave(gd, round, seed);
|
||
const bonds = bondsOf(gd, units, (id) => (id === bondId ? (level === 'low' ? 1 : 999) : 0));
|
||
const pid = 'p1';
|
||
scenarios.push({
|
||
id: `bond-${bondId}-${level}`, family: 'bonds', kind: 'normal', modeId, round, stageId, seed,
|
||
rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides,
|
||
flags: { layerGainsEnabled: true, ...gd.dp },
|
||
players: [playerInput(pid, 0, units, { bonds, bandId: null })],
|
||
spawns: wave.spawns.map((s) => ({ ...s, ownerPlayerId: pid })),
|
||
about: `${bond.name} ${level}: tier ${bonds[bondId] ? bonds[bondId].tier : 0} layers ${bonds[bondId] ? bonds[bondId].layers : 0}; ${placeName(units).join(' ')}`,
|
||
});
|
||
}
|
||
}
|
||
return scenarios;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// family: fields (Final Assault / Hidden Core leaders, 联防)
|
||
|
||
const ELITES = VISIBLE.filter((c) => c.isGolden && c.tier >= 4).sort((a, b) => b.tier - a.tier || byId(a.chessId, b.chessId));
|
||
|
||
export function fieldScenarios() {
|
||
const scenarios = [];
|
||
const teams = cursor(ELITES.map((c) => c.chessId));
|
||
const items = cursor(EQUIPS.filter((id) => /_b$/.test(id)));
|
||
const bands = cursor(BANDS.slice().reverse());
|
||
const team = (gd, stageId, field, uid0, n = 10, colOffset = 0) => {
|
||
const wanted = [];
|
||
for (let k = 0; k < n; k++) wanted.push({ chessId: teams.next(), items: k < 6 ? [items.next(), items.next()] : [] });
|
||
const seenIds = new Set();
|
||
const uniq = wanted.filter((w) => (seenIds.has(w.chessId) ? false : (seenIds.add(w.chessId), true)));
|
||
return layout(gd, stageId, uniq, { field, colOffset, max: n, uid0 });
|
||
};
|
||
let n = 0;
|
||
for (const bossId of BOSSES) {
|
||
const hidden = !!data.bosses[bossId].hidden;
|
||
for (const solo of [false, true]) {
|
||
const i = n++;
|
||
const modeId = solo ? (i % 4 < 2 ? 'mode_single_hard' : 'mode_single_abyss') : (i % 4 < 2 ? 'mode_multi_hard' : 'mode_multi_abyss');
|
||
const gd = gdFor(modeId);
|
||
const round = hidden ? gd.hiddenRound : gd.bossRound;
|
||
const stageId = STAGES[(i * 5) % STAGES.length];
|
||
const seed = deriveSeed(20261005, `boss:${bossId}:${solo ? 's' : 'p'}`);
|
||
const setup = setupMatchWaves(gd, createRng(deriveSeed(seed, 'setup')));
|
||
const wave = buildBossWave(gd, createRng(deriveSeed(seed, 'waves')), setup, round, { bossId, solo });
|
||
const pids = solo ? ['p1'] : ['p1', 'p2'];
|
||
let uid = 1;
|
||
const players = pids.map((pid, j) => {
|
||
const t = team(gd, stageId, j === 0 ? 'bossL' : 'bossR', uid);
|
||
uid = t.nextUid;
|
||
const input = playerInput(pid, j, t.units, { side: j === 0 ? 'L' : 'R', colOffset: j === 0 ? 0 : 8, bonds: bondsOf(gd, t.units, 20 * (j + 1)), bandId: bands.next() });
|
||
input.lpForBoss = 60;
|
||
return input;
|
||
});
|
||
const pool = gd.bossPoolHp(bossId, pids.length);
|
||
scenarios.push({
|
||
id: `${hidden ? 'hidden' : 'boss'}-${bossId}-${solo ? 'solo' : 'pair'}`, family: 'fields', kind: hidden ? 'hidden' : 'boss', modeId, round, stageId, seed,
|
||
rect: { ...GEO.BOSS_RECT }, timeLimit: Infinity, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides,
|
||
flags: { layerGainsEnabled: false, ...gd.dp }, bossId, boss: { poolHp: pool, poolMax: pool }, fieldId: 'b1', cap: 200,
|
||
players, spawns: wave.spawns.map((s) => ({ ...s })),
|
||
about: `${data.bosses[bossId].name} ${solo ? 'solo' : 'pair'} pool ${pool}: ${players.map((p) => placeName(p.units).join(' ')).join(' | ')}`,
|
||
});
|
||
}
|
||
}
|
||
// 联防: 1 and 2 helpers; the leakers' enemies (walkers, flyers, a summoned-only kind, a bounty) re-enter
|
||
for (const helpers of [1, 2]) {
|
||
const i = n++;
|
||
const modeId = helpers === 1 ? 'mode_multi_normal' : 'mode_multi_hard';
|
||
const gd = gdFor(modeId);
|
||
const round = helpers === 1 ? 7 : 11;
|
||
const stageId = STAGES[(i * 5) % STAGES.length];
|
||
const seed = deriveSeed(20261005, `unite:${helpers}`);
|
||
const src = normalWave(gd, round, seed);
|
||
const leakers = helpers === 1 ? ['p2', 'p3'] : ['p3', 'p4'];
|
||
const keys = [...new Set(src.spawns.map((s) => s.enemyKey))];
|
||
const tokenOnly = Object.keys(data.enemies).filter((k) => data.enemies[k].tokenOnly).sort(byId);
|
||
const leaked = [];
|
||
keys.forEach((k, j) => { leaked.push({ enemyKey: k, mods: { ...(src.spawns.find((s) => s.enemyKey === k).mods || {}) }, sourcePlayerId: leakers[j % 2] }); });
|
||
for (let j = 0; j < 6; j++) leaked.push({ enemyKey: keys[j % keys.length], mods: { ...(src.spawns[0].mods || {}) }, sourcePlayerId: leakers[j % 2] });
|
||
leaked.push({ enemyKey: tokenOnly[helpers - 1], mods: null, sourcePlayerId: leakers[0], isToken: true });
|
||
leaked.push({ enemyKey: keys[0], mods: { ...(src.spawns[0].mods || {}), bountyCoins: 3 }, sourcePlayerId: leakers[1], bounty: { coins: 3, ownerPlayerId: leakers[1] }, tag: 'bounty' });
|
||
const wave = buildUniteWave(gd, leaked, helpers, src.timeLimit);
|
||
let uid = 1;
|
||
const players = [];
|
||
for (let h = 0; h < helpers; h++) {
|
||
const t = team(gd, stageId, 'normal', uid, 8, helpers > 1 && h === 0 ? 8 : 0);
|
||
uid = t.nextUid;
|
||
// carried end state of the helper's own battle: HP ratio + SP, one knocked out, summons with SP only
|
||
t.units.forEach((u, k) => {
|
||
if (u.kind === 'token') { u.carryState = { sp: 3 }; return; }
|
||
u.carryState = k === 2 ? { down: true } : { hpPct: [1, 0.55, 1, 0.3, 0.8][k % 5], sp: (k * 7) % 20 };
|
||
});
|
||
players.push(playerInput(`p${h + 1}`, h, t.units, { colOffset: helpers > 1 && h === 0 ? 8 : 0, bonds: bondsOf(gd, t.units, 5 + 30 * h), bandId: bands.next() }));
|
||
}
|
||
scenarios.push({
|
||
id: `unite-${helpers}`, family: 'fields', kind: 'unite', modeId, round, stageId, seed,
|
||
rect: { ...GEO.UNITE_RECT }, timeLimit: src.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: src.overrides,
|
||
flags: { layerGainsEnabled: false, ...gd.dp }, fieldId: 'u', players, spawns: wave.spawns,
|
||
about: `联防 ${helpers} helper(s), ${leaked.length} leaked: ${players.map((p) => placeName(p.units).join(' ')).join(' | ')}`,
|
||
});
|
||
}
|
||
return scenarios;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// family: matches
|
||
|
||
export function matchScenarios() {
|
||
const out = [];
|
||
for (const difficulty of ['FUNNY', 'NORMAL', 'HARD', 'ABYSS']) for (const seed of [1, 2]) out.push({ id: `solo-${difficulty}-${seed}`, mode: 'solo', difficulty, bots: 1, seed });
|
||
out.push({ id: 'coop2-NORMAL-3', mode: 'coop', difficulty: 'NORMAL', bots: 2, seed: 3 });
|
||
out.push({ id: 'coop2-HARD-4', mode: 'coop', difficulty: 'HARD', bots: 2, seed: 4 });
|
||
out.push({ id: 'coop3-ABYSS-5', mode: 'coop', difficulty: 'ABYSS', bots: 3, seed: 5 });
|
||
out.push({ id: 'coop4-FUNNY-6', mode: 'coop', difficulty: 'FUNNY', bots: 4, seed: 6 });
|
||
out.push({ id: 'coop4-ABYSS-7', mode: 'coop', difficulty: 'ABYSS', bots: 4, seed: 7 });
|
||
out.push({ id: 'coop2-NORMAL-8-serverrun', mode: 'coop', difficulty: 'NORMAL', bots: 2, seed: 8, clientCombat: false });
|
||
// LP and every bond's layers raised at the first prep (as the boosted runs of test/match/fullmatch.test.js): the
|
||
// match reaches the Final Assault and — Σ activated layers over the threshold — the Hidden Core
|
||
out.push({ id: 'solo-HARD-9-boosted', mode: 'solo', difficulty: 'HARD', bots: 1, seed: 9, boost: { lp: 400, layers: 300 } });
|
||
out.push({ id: 'coop2-ABYSS-10-boosted', mode: 'coop', difficulty: 'ABYSS', bots: 2, seed: 10, boost: { lp: 400, layers: 300 } });
|
||
return out.map((m) => ({ ...m, family: 'matches', kind: 'match', about: `${m.mode} ${m.difficulty}, ${m.bots} bot seat(s), seed ${m.seed}${m.clientCombat === false ? ', server-run combat' : ''}${m.boost ? `, LP ${m.boost.lp} and ${m.boost.layers} layers per bond from the first prep` : ''}` }));
|
||
}
|
||
|
||
const pieceStr = (p, r, c) => `${p.id.replace(/^chess_char_/, '')}@${r},${c}${p.dir && p.dir !== 'RIGHT' ? p.dir[0] : ''}${p.items && p.items.length ? `[${p.items.map((i) => i.id.replace(/^chess_item_/, '')).join('+')}]` : ''}`;
|
||
function boardStr(ps) {
|
||
const out = [];
|
||
for (const [k, p] of ps.board) { const [r, c] = k.split(',').map(Number); out.push(pieceStr(p, r, c)); }
|
||
return out.sort(byId).join(' ');
|
||
}
|
||
const bondsStr = (bonds) => Object.entries(bonds || {}).filter(([, b]) => b && (b.active || b.layers > 0)).map(([id, b]) => `${id} ${b.tier}/${b.layers}`).sort(byId).join(', ');
|
||
const MATCH_ROW_COLS = Object.freeze(['alive', 'lp', 'prepFunds', 'funds', 'pending', 'level', 'deployed', 'board', 'layers', 'bonds', 'killed', 'total', 'leaks', 'gold']);
|
||
|
||
export function runMatch(cfg) {
|
||
const sched = new VirtualScheduler();
|
||
const seats = [];
|
||
for (let i = 0; i < cfg.bots; i++) seats.push({ seat: i, playerId: `ai_${i}`, name: `AI-${i + 1}`, isBot: true, connected: true });
|
||
let summary = null;
|
||
const logged = [];
|
||
const log = { info() {}, debug() {}, warn() {}, error: (...a) => logged.push(a.map(String).join(' ').slice(0, 120)) };
|
||
const m = new Match({
|
||
roomCode: 'GOLD', mode: cfg.mode, difficulty: cfg.difficulty, seats, seed: cfg.seed, data, log, scheduler: sched,
|
||
send: () => true, broadcast: () => {}, onEnd: (s) => { summary = s; },
|
||
clientCombat: cfg.clientCombat !== false, verify: 'off',
|
||
});
|
||
const rounds = [];
|
||
let last = '';
|
||
m.start();
|
||
const setup = { stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId, factions: [...(m.factions || [])] };
|
||
let boosted = !cfg.boost;
|
||
const prepFunds = {};
|
||
sched.runUntil(() => {
|
||
const key = `${m.phase}:${m.round}`;
|
||
if (key !== last) {
|
||
last = key;
|
||
if (!boosted && m.phase === 'PREP') {
|
||
boosted = true;
|
||
for (const ps of m.order) {
|
||
ps.lp = cfg.boost.lp;
|
||
for (const id of m.gd.bondIds) ps.layers[id] = cfg.boost.layers;
|
||
ps.recompute();
|
||
}
|
||
}
|
||
// funds when the prep opens (income in, nothing bought yet: the bots play their prep in later callbacks)
|
||
if (m.phase === 'PREP') for (const ps of m.order) prepFunds[ps.playerId] = ps.funds;
|
||
if (m.phase === 'SETTLE') {
|
||
const row = { round: m.round };
|
||
for (const ps of m.order) {
|
||
const r = m.lastResults.get(ps.playerId);
|
||
row[ps.playerId] = [
|
||
ps.alive ? 1 : 0, ps.lp, prepFunds[ps.playerId] ?? null, ps.funds, ps.pendingFunds, ps.shop.level, ps.deployCount, hex8(fnv(boardStr(ps))), ps.activatedLayers(),
|
||
bondsStr(ps.bonds), r ? r.killed ?? null : null, r ? r.total ?? null : null,
|
||
r ? (r.leaked || []).filter((l) => l && l.counted !== false).length : null, ps.stats.gold,
|
||
];
|
||
}
|
||
rounds.push(row);
|
||
}
|
||
}
|
||
return summary != null;
|
||
}, { maxSteps: 5e6 });
|
||
const rng = {};
|
||
for (const [name, key] of [['setup', 'rngSetup'], ['shop', 'rngShop'], ['waves', 'rngWaves'], ['draft', 'rngDraft'], ['bots', 'rngBots'], ['meta', 'rngMeta']]) {
|
||
rng[name] = m[key] && typeof m[key].state === 'function' ? rngDraws(deriveSeed(m.seed, name), m[key].state()) : null;
|
||
}
|
||
const s = summary || {};
|
||
const players = {};
|
||
for (const p of s.players || []) {
|
||
players[p.playerId] = {
|
||
alive: p.alive, victory: p.victory, roundsPassed: p.roundsPassed, eliminatedRound: p.eliminatedRound ?? null, lp: p.lp,
|
||
bandId: p.bandId, title: p.title, trophies: p.trophies, reward: p.reward, stats: sorted({ ...p.stats }),
|
||
lineup: (p.lineup || []).map((x) => `${x.id.replace(/^chess_char_/, '')}@${x.row},${x.col}${x.items && x.items.length ? `[${x.items.map((i) => i.replace(/^chess_item_/, '')).join('+')}]` : ''}`),
|
||
bonds: (p.bonds || []).map((bo) => `${bo.bondId ?? bo.id} ${bo.tier}/${bo.layers}`).sort(byId),
|
||
};
|
||
}
|
||
const digest = {
|
||
mode: cfg.mode, difficulty: cfg.difficulty, seats: cfg.bots, seed: cfg.seed, combat: cfg.clientCombat === false ? 'server' : 'client', boost: cfg.boost ?? null,
|
||
setup,
|
||
end: {
|
||
victory: s.victory ?? null, roundsPassed: s.roundsPassed ?? null, hiddenReached: s.hiddenReached ?? null, hiddenCleared: s.hiddenCleared ?? null,
|
||
reason: s.reason ?? null, teamLp: s.teamLp ?? null, bossPoolLeft: m.bossPool ? int(m.bossPool.hp) : null, virtualMs: s.durationMs ?? null,
|
||
},
|
||
rng,
|
||
errors: { engine: m.errorCount, logged: logged.length, sim: m.simErrors ?? null, dispatcher: m.dispatcher ? m.dispatcher.errors : null },
|
||
rounds,
|
||
players,
|
||
};
|
||
m.dispose();
|
||
return digest;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// families, fast subset, comparison
|
||
|
||
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios };
|
||
const ABOUT = {
|
||
roster: 'every visible chess record × every selectable skill / module, every stage, every non-leader enemy kind, items, bands, 机变 cards, map cards, placeable summons',
|
||
bonds: 'every bond at its activation threshold (layers 1) and at its top tier (layers 999)',
|
||
fields: 'Final Assault / Hidden Core leaders (pair + solo templates, shared pool, 200 s cap) and 联防 fields (1 / 2 helpers)',
|
||
matches: 'bot-only matches run to the end in virtual time (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core)',
|
||
};
|
||
/**
|
||
* The default test subset (GOLDEN_FULL=1 runs everything): the pass-0 roster battles (every chess record with its
|
||
* default loadout, every stage, every non-leader enemy kind), every bond at its top tier, both 联防 fields, four leader
|
||
* fields (one of them a Hidden Core) and five matches (solo 标准 / 绝境, co-op 2 / 4, the boosted Hidden Core run).
|
||
*/
|
||
const FAST_MATCHES = new Set(['solo-FUNNY-1', 'solo-HARD-1', 'coop2-NORMAL-3', 'coop4-ABYSS-7', 'solo-HARD-9-boosted']);
|
||
const FAST_FIELDS = new Set(['boss-boss_1-pair', 'boss-boss_4-solo', 'boss-boss_7-pair', 'hidden-boss_9-pair', 'unite-1', 'unite-2']);
|
||
export function isFast(sc) {
|
||
if (sc.family === 'matches') return FAST_MATCHES.has(sc.id);
|
||
if (sc.family === 'fields') return FAST_FIELDS.has(sc.id);
|
||
if (sc.family === 'bonds') return sc.id.endsWith('-high');
|
||
return sc.pass === 0;
|
||
}
|
||
|
||
const scenarioCache = new Map();
|
||
/** Scenario list of a family (generated once per process). */
|
||
export function scenariosOf(family) {
|
||
if (!scenarioCache.has(family)) {
|
||
const gen = GENERATORS[family];
|
||
if (!gen) throw new Error(`unknown golden family ${family}`);
|
||
const list = gen();
|
||
for (const sc of list) sc.fast = isFast(sc);
|
||
scenarioCache.set(family, list);
|
||
}
|
||
return scenarioCache.get(family);
|
||
}
|
||
|
||
/** Digest of one scenario. */
|
||
export function runScenario(sc) {
|
||
return sc.kind === 'match' ? runMatch(sc) : runBattle(sc);
|
||
}
|
||
|
||
export function goldenPath(family) { return join(GOLDEN_DIR, `${family}.json`); }
|
||
|
||
export function loadGolden(family) {
|
||
try { return JSON.parse(readFileSync(goldenPath(family), 'utf8')); } catch { return null; }
|
||
}
|
||
|
||
/** The digests of a stored golden document (its per-scenario `about` / `fast` notes removed): id → digest. */
|
||
export function storedDigests(doc) {
|
||
const out = {};
|
||
for (const [id, d] of Object.entries((doc && doc.scenarios) || {})) { const { about, fast, ...rest } = d; out[id] = rest; } // eslint-disable-line no-unused-vars
|
||
return out;
|
||
}
|
||
|
||
/** Column names of the digests' rows (documented once per golden file): units, enemies, hooks, match rounds. */
|
||
export const COLUMNS = Object.freeze({ units: UNIT_COLS, enemies: ENEMY_COLS, hooks: HOOKS, rounds: MATCH_ROW_COLS });
|
||
const colsFor = (path) => (/\.hooks$/.test(path) ? HOOKS : /\.enemies\.[^.[]+$/.test(path) ? ENEMY_COLS : /\.units\[[^\]]+\]$/.test(path) ? UNIT_COLS : /\.rounds\[\d+\]\.[^.[]+$/.test(path) ? MATCH_ROW_COLS : null);
|
||
const rowName = (r) => `${r[0]}#${r[1]}:${r[3]}`;
|
||
|
||
/**
|
||
* Field-level differences between two digests: [{ path, old, new }] — unit rows by their name (player#ref:def),
|
||
* row cells, enemy entries, hooks and match round rows by their column names.
|
||
* @returns {Array<{ path: string, old: any, new: any }>}
|
||
*/
|
||
export function diffDigest(a, b, path = '', out = [], limit = 40) {
|
||
if (out.length >= limit) return out;
|
||
if (JSON.stringify(a) === JSON.stringify(b)) return out;
|
||
if (a && b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)) {
|
||
if (Array.isArray(a)) {
|
||
const cols = colsFor(path);
|
||
if (cols && a.length === b.length && a.every((x) => x === null || typeof x !== 'object')) {
|
||
for (let i = 0; i < a.length && out.length < limit; i++) if (a[i] !== b[i]) out.push({ path: `${path}.${cols[i] ?? i}`, old: a[i], new: b[i] });
|
||
return out;
|
||
}
|
||
if (/\.units$/.test(path) && a.every(Array.isArray) && b.every(Array.isArray)) {
|
||
const A = new Map(a.map((r) => [rowName(r), r]));
|
||
const B = new Map(b.map((r) => [rowName(r), r]));
|
||
for (const [k, r] of A) {
|
||
if (!B.has(k)) out.push({ path: `${path}[${k}]`, old: r, new: '(missing)' });
|
||
else diffDigest(r, B.get(k), `${path}[${k}]`, out, limit);
|
||
if (out.length >= limit) return out;
|
||
}
|
||
for (const [k, r] of B) if (!A.has(k) && out.length < limit) out.push({ path: `${path}[${k}]`, old: '(missing)', new: r });
|
||
return out;
|
||
}
|
||
const n = Math.max(a.length, b.length);
|
||
for (let i = 0; i < n && out.length < limit; i++) diffDigest(a[i], b[i], `${path}[${i}]`, out, limit);
|
||
return out;
|
||
}
|
||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
|
||
for (const k of keys) {
|
||
if (!(k in a)) out.push({ path: `${path}.${k}`, old: '(missing)', new: b[k] });
|
||
else if (!(k in b)) out.push({ path: `${path}.${k}`, old: a[k], new: '(missing)' });
|
||
else diffDigest(a[k], b[k], `${path}.${k}`, out, limit);
|
||
if (out.length >= limit) return out;
|
||
}
|
||
return out;
|
||
}
|
||
out.push({ path: path || '.', old: a, new: b });
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Compare computed digests (id → digest) with stored ones (storedDigests of the golden document).
|
||
* @returns {Array<{ scenario: string, diffs: Array<{ path: string, old: any, new: any }> }>}
|
||
*/
|
||
export function compareFamily(stored, computed) {
|
||
const out = [];
|
||
for (const [id, dg] of Object.entries(computed)) {
|
||
const old = stored ? stored[id] : undefined;
|
||
if (old === undefined) { out.push({ scenario: id, diffs: [{ path: '.', old: '(not in the golden file)', new: '(computed)' }] }); continue; }
|
||
const diffs = diffDigest(old, dg);
|
||
if (diffs.length) out.push({ scenario: id, diffs });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function formatDiffs(family, list, { maxScenarios = 12, maxDiffs = 8 } = {}) {
|
||
const fmt = (v) => { const s = typeof v === 'string' ? v : JSON.stringify(v); return s.length > 160 ? `${s.slice(0, 157)}…` : s; };
|
||
const lines = [`golden ${family}: ${list.length} scenario(s) differ`];
|
||
for (const { scenario, diffs } of list.slice(0, maxScenarios)) {
|
||
lines.push(` ${scenario}:`);
|
||
for (const d of diffs.slice(0, maxDiffs)) lines.push(` ${d.path}: ${fmt(d.old)} → ${fmt(d.new)}`);
|
||
if (diffs.length > maxDiffs) lines.push(` … ${diffs.length - maxDiffs} more`);
|
||
}
|
||
if (list.length > maxScenarios) lines.push(` … ${list.length - maxScenarios} more scenario(s)`);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Pretty JSON for review in a diff: a value whose one-line form fits (≤ 160 characters; a map of numbers ≤ 400) stays
|
||
* on one line — unit rows, enemy entries, small objects — anything larger gets one entry per line.
|
||
*/
|
||
export function stringifyGolden(v, indent = '') {
|
||
const one = JSON.stringify(v);
|
||
if (v === null || typeof v !== 'object') return one;
|
||
const scalars = Object.values(v).every((x) => x === null || typeof x !== 'object');
|
||
if (indent.length >= 6 && (one.length <= 160 || (scalars && one.length <= 400))) return one;
|
||
const inner = `${indent} `;
|
||
if (Array.isArray(v)) {
|
||
if (!v.length) return '[]';
|
||
if (scalars) return one;
|
||
return `[\n${v.map((x) => inner + stringifyGolden(x, inner)).join(',\n')}\n${indent}]`;
|
||
}
|
||
const keys = Object.keys(v);
|
||
if (!keys.length) return '{}';
|
||
return `{\n${keys.map((k) => `${inner}${JSON.stringify(k)}: ${stringifyGolden(v[k], inner)}`).join(',\n')}\n${indent}}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// running (optionally in worker threads)
|
||
|
||
/**
|
||
* Compute digests. `select(sc)` filters scenarios; `jobs` > 1 spreads them over worker threads (results are keyed by
|
||
* scenario id, so the split never changes a digest).
|
||
* @returns {Promise<Record<string, Record<string, object>>>} family → id → digest
|
||
*/
|
||
export async function computeDigests(families, { select = () => true, jobs = 1, reverse = false } = {}) {
|
||
const tasks = [];
|
||
for (const f of families) for (const sc of scenariosOf(f)) if (select(sc)) tasks.push([f, sc.id]);
|
||
if (reverse) tasks.reverse();
|
||
const out = {};
|
||
for (const f of families) out[f] = {};
|
||
if (jobs <= 1 || tasks.length < 2) {
|
||
for (const [f, id] of tasks) out[f][id] = runScenario(scenariosOf(f).find((s) => s.id === id));
|
||
return order(out, families);
|
||
}
|
||
// heaviest first (matches), then the rest in order; each worker takes the next task when it is free
|
||
const queue = tasks.slice().sort((a, b) => (a[0] === 'matches' ? 0 : 1) - (b[0] === 'matches' ? 0 : 1));
|
||
await new Promise((resolve, reject) => {
|
||
let live = 0;
|
||
let failed = null;
|
||
const n = Math.min(jobs, queue.length);
|
||
for (let w = 0; w < n; w++) {
|
||
const worker = new Worker(fileURLToPath(import.meta.url), { workerData: { golden: true } });
|
||
live++;
|
||
const feed = () => { const t = queue.shift(); if (t) worker.postMessage(t); else worker.postMessage(null); };
|
||
worker.on('message', (msg) => {
|
||
if (msg.error) { failed = failed || new Error(`${msg.id}: ${msg.error}`); worker.postMessage(null); return; }
|
||
out[msg.family][msg.id] = msg.digest;
|
||
feed();
|
||
});
|
||
worker.on('error', (e) => { failed = failed || e; });
|
||
worker.on('exit', () => { if (--live === 0) (failed ? reject(failed) : resolve()); });
|
||
feed();
|
||
}
|
||
});
|
||
return order(out, families);
|
||
}
|
||
|
||
/** Digests in the scenario order of each family (stable files whatever finished first). */
|
||
function order(out, families) {
|
||
const res = {};
|
||
for (const f of families) {
|
||
res[f] = {};
|
||
for (const sc of scenariosOf(f)) if (out[f][sc.id] !== undefined) res[f][sc.id] = out[f][sc.id];
|
||
}
|
||
return res;
|
||
}
|
||
|
||
export function goldenDocument(family, digests) {
|
||
const list = scenariosOf(family);
|
||
return {
|
||
family,
|
||
about: ABOUT[family],
|
||
note: 'Generated by `npm run golden:update` (tools/golden.mjs). A refactor must never change this file; see test/golden/README.md.',
|
||
columns: family === 'matches' ? { rounds: MATCH_ROW_COLS } : { units: UNIT_COLS, enemies: ENEMY_COLS, hooks: HOOKS },
|
||
scenarios: Object.fromEntries(list.filter((sc) => digests[sc.id] !== undefined).map((sc) => [sc.id, { about: sc.about ?? null, fast: !!sc.fast, ...digests[sc.id] }])),
|
||
};
|
||
}
|
||
|
||
/** Coverage of a battle family's digests (skills cast, records, stages, enemy kinds) for --coverage. */
|
||
export function coverage(digests) {
|
||
const recs = new Set();
|
||
const lo = new Set();
|
||
const cast = new Set();
|
||
const loCast = new Set();
|
||
const stages = new Set();
|
||
const enemies = new Set();
|
||
let units = 0;
|
||
for (const dg of Object.values(digests)) {
|
||
if (!dg.units) continue;
|
||
stages.add(dg.stageId);
|
||
for (const k of Object.keys(dg.enemies || {})) enemies.add(k);
|
||
for (const u of dg.units) {
|
||
if (u[2] !== 'op') continue;
|
||
units++;
|
||
recs.add(u[3]);
|
||
const key = `${u[3]}|${u[4]}|${u[5]}`;
|
||
lo.add(key);
|
||
if (u[14] > 0) { cast.add(`${u[3]}|${u[4]}`); loCast.add(key); }
|
||
}
|
||
}
|
||
const skillsWanted = new Set();
|
||
for (const rec of VISIBLE) for (const s of rec.skills || []) if (s.skillType !== 'PASSIVE') skillsWanted.add(`${rec.chessId}|${s.index}`);
|
||
const skillsCast = [...skillsWanted].filter((k) => cast.has(k));
|
||
return {
|
||
operatorUnits: units, chessRecords: recs.size, loadouts: lo.size, loadoutsCast: loCast.size,
|
||
activeSkills: skillsWanted.size, skillsCast: skillsCast.length, skillsNeverCast: [...skillsWanted].filter((k) => !cast.has(k)),
|
||
stages: stages.size, enemyKinds: enemies.size,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// worker / CLI
|
||
|
||
if (!isMainThread && workerData && workerData.golden) {
|
||
parentPort.on('message', (t) => {
|
||
if (!t) { parentPort.close(); return; }
|
||
const [family, id] = t;
|
||
try {
|
||
const sc = scenariosOf(family).find((s) => s.id === id);
|
||
parentPort.postMessage({ family, id, digest: runScenario(sc) });
|
||
} catch (e) {
|
||
parentPort.postMessage({ family, id, error: String(e && e.stack || e) });
|
||
}
|
||
});
|
||
}
|
||
|
||
const isMain = isMainThread && process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||
if (isMain) {
|
||
const argv = process.argv.slice(2);
|
||
const opt = {};
|
||
for (let i = 0; i < argv.length; i++) {
|
||
if (!argv[i].startsWith('--')) continue;
|
||
const k = argv[i].slice(2);
|
||
opt[k] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
||
}
|
||
const families = opt.family ? String(opt.family).split(',') : FAMILY_NAMES.slice();
|
||
const only = opt.only ? new Set(String(opt.only).split(',')) : null;
|
||
const select = (sc) => (!only || only.has(sc.id)) && (!opt.fast || sc.fast);
|
||
const jobs = Math.max(1, Number(opt.jobs) || Math.min(4, Math.max(1, availableParallelism() - 1)));
|
||
if (opt.list) {
|
||
for (const f of families) for (const sc of scenariosOf(f)) if (select(sc)) console.log(`${f}\t${sc.id}\t${sc.fast ? 'fast' : ''}\t${sc.about ?? ''}`.slice(0, 220));
|
||
process.exit(0);
|
||
}
|
||
const t0 = performance.now();
|
||
const digests = await computeDigests(families, { select, jobs: opt.twice ? 1 : jobs });
|
||
const ms = Math.round(performance.now() - t0);
|
||
let bad = 0;
|
||
if (opt.twice) {
|
||
const t1 = performance.now();
|
||
const again = await computeDigests(families, { select, jobs: 1, reverse: true });
|
||
for (const f of families) {
|
||
const d = compareFamily(digests[f], again[f]);
|
||
if (d.length) { bad++; console.log(formatDiffs(`${f} (second in-process run, reverse order)`, d)); }
|
||
}
|
||
console.log(`determinism (in-process, twice, second pass reversed): ${bad ? 'DIFFERENT' : 'identical'} · ${ms} + ${Math.round(performance.now() - t1)} ms`);
|
||
}
|
||
if (opt.coverage) for (const f of families) if (f !== 'matches') console.log(f, JSON.stringify(coverage(digests[f]), null, 1));
|
||
if (opt.update) {
|
||
mkdirSync(GOLDEN_DIR, { recursive: true });
|
||
for (const f of families) {
|
||
const merged = only || opt.fast ? { ...storedDigests(loadGolden(f)), ...digests[f] } : digests[f];
|
||
writeFileSync(goldenPath(f), `${stringifyGolden(goldenDocument(f, merged))}\n`);
|
||
console.log(`wrote ${goldenPath(f).slice(ROOT.length + 1)} (${Object.keys(digests[f]).length} scenarios computed)`);
|
||
}
|
||
console.log(`computed in ${ms} ms (jobs ${opt.twice ? 1 : jobs})`);
|
||
} else if (!opt.twice) {
|
||
for (const f of families) {
|
||
const d = compareFamily(storedDigests(loadGolden(f)), digests[f]);
|
||
if (d.length) { bad++; console.log(formatDiffs(f, d)); } else console.log(`golden ${f}: ${Object.keys(digests[f]).length} scenarios match`);
|
||
}
|
||
console.log(`${bad ? 'MISMATCH' : 'ok'} · ${ms} ms (jobs ${jobs})`);
|
||
}
|
||
process.exit(bad ? 1 : 0);
|
||
}
|