feedback5 WA: golden-result safety net before the 0.2.0 refactor — tools/golden.mjs runs a seeded corpus (49 roster battles: every chess record × skill × module, every stage and enemy kind; 46 bond battles; 22 leader / 联防 fields; 16 bot-only matches) and test/golden.test.js compares its digests with test/golden/*.json field by field (fast subset by default, GOLDEN_FULL=1 for all; npm run golden / golden:update; test/golden/README.md)
This commit is contained in:
1 parent
305b524df2
commit
0fa0f958b1
10 files changed
+8808
No files matched your search
@@ -184,9 +184,11 @@ node --test # 单元 + 集成测试(约 3170 项;缺少素
|
||||
SP_E2E=1 node --test test/ui/mock.e2e.test.js # 浏览器端到端测试,需要本机 Chrome(CHROME_PATH 可指定路径)
|
||||
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载的素材
|
||||
RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染测试,部分需要本地提取的棋盘贴图
|
||||
GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定种子的整套战斗与人机对局摘要(默认只跑快速子集)
|
||||
```
|
||||
|
||||
- 游戏数据由 `npm run build-data`(`tools/build-data.mjs`)从官方数据表生成,不要手工修改 `data/*.json`。
|
||||
- 只重构、不改玩法的提交不能改变 `test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,检查差异后随改动一起提交(见 [test/golden/README.md](test/golden/README.md))。
|
||||
- GitHub Actions([.github/workflows/ci.yml](.github/workflows/ci.yml))在 Ubuntu 与 Windows、Node 22 / 24 上运行 `npm ci`、`node --test` 和服务器冒烟测试。
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -1240,6 +1240,11 @@ prints a per-unit damage/DPS/kills/heal table (+ ASCII frames: letters = operato
|
||||
`^` = flyers, `■` = crates). `--wave <template>`, `--seed`, `--content generic`, `--hpMul/--atkMul/--speedMul`,
|
||||
`--time`, `--bossHp`, `--json`, `--events`, or a scenario JSON file (see the file header).
|
||||
|
||||
Golden results (`tools/golden.mjs`, `test/golden/README.md`): a fixed corpus of seeded battles — every chess record ×
|
||||
skill × module, every bond at its threshold and at 999 layers, every leader field, 联防 — and bot-only matches,
|
||||
reduced to digests in `test/golden/*.json`; `test/golden.test.js` fails, naming the scenario and the field, when a
|
||||
digest moves. A refactor must leave them unchanged; `npm run golden:update` records an intended gameplay change.
|
||||
|
||||
## 12. Data notes (simdata.js)
|
||||
|
||||
Defs are normalised from data/*.json (DATA.md) with research JSON as a fallback. Chess: `stats {maxHp, atk, def, res, aspd,
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js",
|
||||
"test": "node --test",
|
||||
"golden": "node tools/golden.mjs",
|
||||
"golden:update": "node tools/golden.mjs --update",
|
||||
"vendor": "node tools/vendor.mjs",
|
||||
"build-data": "node tools/build-data.mjs",
|
||||
"assets": "node tools/vendor.mjs && node tools/fetch-assets.mjs",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Golden results (test/golden/README.md, tools/golden.mjs): recompute the deterministic scenario corpus and compare
|
||||
// every digest with test/golden/*.json — battles (every chess record × skill × module, every bond, the leader and 联防
|
||||
// fields) and bot-only matches. A behaviour-preserving refactor must leave every digest unchanged; a failure names the
|
||||
// scenario, the field and old → new. Default: the fast subset (scenarios marked "fast": true in the golden files);
|
||||
// GOLDEN_FULL=1 checks the whole corpus. GOLDEN_JOBS=N sets the worker threads (default: up to 4).
|
||||
// After an intended gameplay change: `npm run golden:update`, review the diff, commit the files with the change.
|
||||
import { test, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { availableParallelism } from 'node:os';
|
||||
import { FAMILY_NAMES, scenariosOf, computeDigests, loadGolden, storedDigests, compareFamily, formatDiffs } from '../tools/golden.mjs';
|
||||
|
||||
const FULL = process.env.GOLDEN_FULL === '1';
|
||||
const JOBS = Math.max(1, Number(process.env.GOLDEN_JOBS) || Math.min(4, Math.max(1, availableParallelism() - 1)));
|
||||
|
||||
let digests = null;
|
||||
// ≈ 7 s (fast) / 20 s (full) with 4 worker threads here; the timeout only stops a hang (a step that never returns)
|
||||
before(async () => {
|
||||
digests = await computeDigests(FAMILY_NAMES, { select: (sc) => FULL || sc.fast, jobs: JOBS });
|
||||
}, { timeout: 15 * 60_000 });
|
||||
|
||||
test('a changed digest value is reported with its scenario, field and old → new', () => {
|
||||
const stored = storedDigests(loadGolden('roster'));
|
||||
const [id, dg] = Object.entries(stored)[0];
|
||||
const changed = structuredClone(dg);
|
||||
const row = changed.units[0];
|
||||
row[10] += 1; // dmg
|
||||
changed.hooks[2] += 1; // tick
|
||||
changed.enemies[Object.keys(changed.enemies)[0]][1] += 1; // killed
|
||||
const diffs = compareFamily(stored, { [id]: changed });
|
||||
assert.equal(diffs.length, 1);
|
||||
const text = formatDiffs('roster', diffs);
|
||||
assert.match(text, new RegExp(`${id}:`));
|
||||
assert.match(text, new RegExp(`\\.units\\[${row[0]}#${row[1]}:${row[3]}\\]\\.dmg: ${row[10] - 1} → ${row[10]}`));
|
||||
assert.match(text, /\.hooks\.tick: \d+ → \d+/);
|
||||
assert.match(text, /\.enemies\.enemy_\w+\.killed: \d+ → \d+/);
|
||||
});
|
||||
|
||||
test('golden files list exactly the corpus scenarios (regenerate with npm run golden:update after a corpus change)', () => {
|
||||
for (const f of FAMILY_NAMES) {
|
||||
const doc = loadGolden(f);
|
||||
assert.ok(doc, `test/golden/${f}.json is missing: run npm run golden:update`);
|
||||
assert.deepEqual(Object.keys(doc.scenarios), scenariosOf(f).map((sc) => sc.id), `test/golden/${f}.json scenario list`);
|
||||
for (const sc of scenariosOf(f)) assert.equal(doc.scenarios[sc.id].fast, sc.fast, `${sc.id}: fast flag`);
|
||||
}
|
||||
});
|
||||
|
||||
for (const f of FAMILY_NAMES) {
|
||||
test(`golden ${f}: ${FULL ? 'every scenario' : 'the fast subset (GOLDEN_FULL=1 for all)'} reproduces its stored digest`, () => {
|
||||
const computed = digests[f];
|
||||
assert.ok(Object.keys(computed).length > 0, `no ${f} scenario computed`);
|
||||
const diffs = compareFamily(storedDigests(loadGolden(f)), computed);
|
||||
assert.equal(diffs.length, 0, `${formatDiffs(f, diffs)}\n(an intended gameplay change: npm run golden:update and review the diff — test/golden/README.md)`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Golden results
|
||||
|
||||
The simulation is deterministic: the same seed and inputs give the same battle and the same match, bit for bit. These
|
||||
files record the exact outcome of a fixed corpus of seeded scenarios, so a change that alters gameplay — even one meant
|
||||
to be a pure refactor that only reorders random draws, hook order or iteration order — fails loudly instead of slipping
|
||||
through.
|
||||
|
||||
- `tools/golden.mjs` builds the corpus from `data/*.json` (fixed order, fixed seeds) and reduces every scenario to a
|
||||
digest. Its header documents the families and the digest fields.
|
||||
- `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`).
|
||||
- `test/golden.test.js` recomputes them and compares. On a mismatch it prints the scenario, the field and old → new.
|
||||
|
||||
## The corpus
|
||||
|
||||
| family | what runs |
|
||||
|---|---|
|
||||
| `roster` | 49 battles: every visible chess record (normal and elite) with every selectable skill and module (DESIGN §16), 12 operators per battle on a real stage (all 11 in turn) against the round's real wave three times over, every non-leader enemy kind of `data/enemies.json` as extra spawns (half of them bounties), placeable summons on the board, every equipment item, band, battle-side 机变 card and stage map card in turn, bonds from the board |
|
||||
| `bonds` | 46 battles: every bond at its activation threshold (1 layer) and at its top tier (999 layers) |
|
||||
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers (carried HP / SP, a knocked-out operator, two leakers' enemies) |
|
||||
| `matches` | 16 bot-only matches to the end in virtual time: solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core |
|
||||
|
||||
Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields
|
||||
run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a
|
||||
test-harness default never moves a digest.
|
||||
|
||||
What a digest ignores on purpose (it does not affect gameplay): object key order, engine unit ids (snapshot ids are
|
||||
renumbered by first appearance, units are named by board uid or def id), board piece uids, the order of client events
|
||||
within a tick (they are counted, not hashed) and wall-clock time. Damage / healing sums are rounded to integers and HP
|
||||
to 2 decimals.
|
||||
|
||||
`npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout,
|
||||
every stage and every non-leader enemy kind, every bond at its top tier, six fields, five matches — 53 of the 133). `GOLDEN_FULL=1` checks everything.
|
||||
|
||||
## Workflow
|
||||
|
||||
```sh
|
||||
GOLDEN_FULL=1 node --test test/golden.test.js # the whole corpus (≈ 20 s with 4 worker threads)
|
||||
node --test test/golden.test.js # the fast subset
|
||||
npm run golden # the same comparison from the tool (all families, a short report)
|
||||
npm run golden:update # recompute and rewrite test/golden/*.json
|
||||
node tools/golden.mjs --twice # determinism: the corpus twice in one process, the second pass reversed
|
||||
```
|
||||
|
||||
- **A refactor commit must never change these files.** Run `GOLDEN_FULL=1 node --test test/golden.test.js` before
|
||||
committing it. If a digest moves, the refactor changed behaviour: find out why (the first differing value of `snaps`
|
||||
says when two runs parted, in 10-game-second steps) instead of updating the files.
|
||||
- **An intended gameplay change** (a fix, new content, a data rebuild): run `npm run golden:update` and commit the
|
||||
changed files together with the change, saying in the commit message which scenarios moved and why.
|
||||
- **Reviewing the diff:** every scenario is a block of its own and every unit / enemy / round is one line, so a diff
|
||||
shows which scenarios moved and which values changed. A change confined to the scenarios that contain the touched
|
||||
operator, enemy or rule is expected; a change everywhere (every `rngDraws`, every `snaps`) means something global
|
||||
moved — the RNG draw order, the tick order, a shared rule.
|
||||
- **Adding to the corpus** (a new family, more scenarios, a new digest field) changes the files by design: do it in a
|
||||
commit of its own, never together with a refactor.
|
||||
- A new engine hook, event or chess record does not change the digests of the existing scenarios by itself — the
|
||||
hook list is frozen in the tool and the corpus is regenerated only on `golden:update` — but new data records do join
|
||||
the corpus there (the roster family enumerates `data/chess.json`).
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1035
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user