Seen: the sim moved a displaced enemy at once and it walked / swung again on the next tick; players compared with the official game (PR #392: 「被推/拉完的敌人会先站住一小会儿再走」). Research (scratchpad report r3): PRTS 失衡位移机制 — a force > 0 puts an enemy in the UNBALANCE state machine with a 0.1 s 失衡硬直 floor, ending when its speed is ≤ 0.1 m/s, on 浮空 or a forced state change; 异常效果图鉴/失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」; 异常效果 — not an abnormal status (水遁忍者's check still runs); 游戏数据基础 推力-位移近似对应表 位移时间 (whole frames at 30 fps); 推与拉 拉力 「作用时间默认为 1 s;若本次受力 等级 < −1,作用时间改为 0.5 s」, 急停 keeps the state to the window's end; 特殊机制 静态刚体 (0.1 s 保底); 术语释义 浮空. Now: constants.js PUSH_UNBALANCE {−2: 0.2, −1: 0.4, 0: 0.8, 1: 0.9, 2: 32/30, 3: 35/30}, PULL_UNBALANCE 1 / PULL_UNBALANCE_WEAK 0.5, UNBALANCE_MIN 0.1. battle/displacement.js: push() holds for its level's row (the −2 correction included), kept when the 特效 column or a wall shortens the slide [ASSUMED for the wall], the 0.1 s floor when a wall stops it at the first step [ASSUMED]; pull() holds for its window whatever the travel (≤ −3: none; an enemy the puller blocks: still no pull [ASSUMED, unchanged]); a 静态刚体 (data staticBody, not 失衡免疫 noDisplace) hit by force level ≥ −2 gets 0.1 s and no movement (_staticForce); _unbalance sets enemy.unbalanceUntil (a longer running one kept) and ends the attack clip's stand. ai.js updateEnemy: no walking (恐惧 / 诱导 too — PR #392's fear exemption is not taken), no new normal attack, a swing short of its frame cut, 浮空 clears it; a 重生 clears it (archetypes.js reborn). Not a stun: no status, no immunity, SP and the attack cooldown run. No global skill lock: enemy abilities keep ticking ([ASSUMED] scope). The move stays instant (the unbalanced() talents keyed off the jump unchanged). The displace fx carries `dur` (game s); render/units.js slideTo divides it by ctx.animRate (2× default: a 0.8 s push slides 0.4 real s); DISPLACE_SLIDE·√D is only the fallback for an fx without it. Not taken from #392: UNBALANCE_TRAVEL 0.6387·√tiles / the client's 0.3194 (derived from the page's μ = 0.5 单位假设, one frame high on every push row, the wrong shape for pulls) and the fear exemption. Tests: test/sim/unbalance.test.js (8: the push rows and walking after them; no attack in the hold, a feared enemy held; the −2 correction's 0.2 s; 特效 / wall-shortened rows and the first-step floor; pull windows incl. the 急停 and ≤ −3; 静态刚体 0.1 s without movement, 失衡免疫 never; 浮空 clears; the fx `dur` — the file fails on the parent); test/render/unitview.test.js (+1: the slide at 2× for a push and a pull, the fallback — fails on the parent); feedback1b-displacement B5 (the drone is now held 0.1 s per force, never moved). test/sim + test/content + test/render: 3433 pass. Docs: SIM.md (push / pull, displace, assumptions), PLAYING.md 推开 / 拉拽, research 13 §0 / §5. Golden — every moved battle has pushes / pulls (probe of the callers) and its enemies are now held; outcome moves: roster-002 (野鬃: killed 29→33, leaks 15→11), roster-003 (野鬃: deaths 3→7), roster-007 (薄绿: killed 110→118, leaks 53→47), roster-008 (薄绿: 45→47 / 42→40), roster-010 (歌蕾蒂娅: killed 45→44, leaks 57→58), roster-011 (莫斯提马: 78→79 / 4→3), roster-031 (钩索师 pulls, tier4: killed 64→72, leaks 12→4, deaths 6→5), roster-041 (钩索师: deaths 5→4), roster-043 (山: deaths 15→14), roster-044 (山: killed 107→103, leaks 12→16, deaths 15→14); bond-deputShip-low (薄绿: 18→21 killed, leaks 3→0, deaths 1→0), bond-kazimierzShip-high (野鬃: deaths 3→2); boss-boss_3-pair (锏: p2 deaths 3→2), boss-boss_4-pair (莫斯提马 / 歌蕾蒂娅: killed 11→12, boss HP 7442429→7375628, p2 leaks 2→1, deaths 19→18), boss-boss_6-pair (boss HP 6932482→6865243, p2 deaths 19→17), hidden-boss_8-pair (boss HP 12585770→12585798), hidden-boss_9-pair (锏: boss HP 3630612→3636991); standin-05 / 08 / 10 (预备干员-特种 pulls: killed 5→8 / 42→44 / 28→29, leaks down by 3 / 2 / 1); diy-101 (傀影: killed 22→21, leaks 92→93), diy-114 / 115 / 116 (黑键: 106→99 & 83→90 / 154→156 & 78→70 / 3→2 & 120→121), diy-137 (预备干员-特种: 68→79 / 79→68); matches through those battles — coop2-HARD-4, coop2-NORMAL-14-standins, coop2-NORMAL-3, coop2-NORMAL-70-diy, coop3-ABYSS-5, coop4-ABYSS-7, solo-ABYSS-2 (LP 2→4 at round 10), solo-HARD-2 (ai_0 now survives round 13: eliminated 13 → alive), solo-HARD-9-boosted (LP 399→398), solo-NORMAL-1. Other moves (damage, heals, snapshots only): roster-009/012/017/019/032/039/040/042/046, 13 bonds, boss-boss_2-pair, hidden-boss_10-pair, standin-03, diy-053/084/085/102/103/108/109, coop2-ABYSS-10-boosted, solo-ABYSS-1.
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.mjsbuilds the corpus fromdata/*.json(fixed order, fixed seeds) and reduces every scenario to a digest. Its header documents the families and the digest fields.test/golden/<family>.jsonhold the digests (roster,bonds,fields,matches,standins,diy).test/golden.test.jsrecomputes 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 on the round's stage, both halves (carried HP / SP, a knocked-out operator, two leakers' enemies) |
matches |
18 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 9 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, standIns) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, diy) |
standins |
10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (standIn: true: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
diy |
battles of 自选 pieces (a DIY slot with its diy pick, shared/diy.js): every owned 6★ with an operator kit (kits/index.js OPERATOR_KITS) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios (the battle count grows with the kits) |
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, seven matches, the five stand-in
battles of the normal records, the first 自选 battle of the operator kits and of the prototypes — 62 of the 151). GOLDEN_FULL=1 checks everything.
Workflow
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.jsbefore committing it. If a digest moves, the refactor changed behaviour: find out why (the first differing value ofsnapssays 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:updateand 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, everysnaps) 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 enumeratesdata/chess.json).