From 920624e743e2311e243e92c7783432bbdc861948 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 16:26:29 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20R4:=20kits/README.md=20=E2=80=94=20?= =?UTF-8?q?how=20a=20kit=20file=20is=20built,=20what=20it=20may=20use=20(s?= =?UTF-8?q?hared=20helpers,=20the=20battle=20API=20of=20docs/SIM.md),=20na?= =?UTF-8?q?ming,=20registration,=20tests,=20the=20fidelity=20rule=20and=20?= =?UTF-8?q?a=20ten-item=20fidelity=20checklist=20per=20operator=20with=20e?= =?UTF-8?q?xample=20kits=20and=20tests=20(for=20the=20stand-ins,=20self-se?= =?UTF-8?q?lect=206=E2=98=85=20operators=20and=20GitHub=20#136);=20test/co?= =?UTF-8?q?ntent/kits=5Flayout.test.js=20checks=20that=20every=20ops/=20fi?= =?UTF-8?q?le=20is=20listed=20in=20kits/index.js,=20named=20after=20its=20?= =?UTF-8?q?chess=20id=20and=20charId,=20registers=20exactly=20its=20kit,?= =?UTF-8?q?=20and=20imports=20nothing=20Node-only=20or=20server-only=20and?= =?UTF-8?q?=20no=20clock=20/=20Math.random?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sim/content/kits/README.md | 185 ++++++++++++++++++++++++++++++ test/content/kits_layout.test.js | 58 ++++++++++ 2 files changed, 243 insertions(+) create mode 100644 server/sim/content/kits/README.md create mode 100644 test/content/kits_layout.test.js diff --git a/server/sim/content/kits/README.md b/server/sim/content/kits/README.md new file mode 100644 index 00000000..0ac4b7d1 --- /dev/null +++ b/server/sim/content/kits/README.md @@ -0,0 +1,185 @@ +# Operator kits + +A kit is the battle behaviour of one chess (棋子): its skills, talents, trait specifics and module effects, on top of +the profession defaults of `server/sim/professions.js`. Kits run inside the deterministic battle simulation that the +server and every browser share, so the same seed and inputs give the same battle everywhere. New kits — the stand-in +operators, the self-select 6★ operators, contributions from GitHub issue #136 — go here, one file each. + +## Layout + +| path | what | +|---|---| +| `index.js` | the registry: `KIT_FILES` lists every kit file, grouped by tier; `KITS` (base chess id → kit builder) and `TIER_KITS` (one registry per tier group) are built from it | +| `ops/-.js` | one kit per file, with the helpers and constants only that kit uses | +| `shared/tier1.js` | the general kit helpers (blackboard readers, unit predicates, hit hooks, area queries, buffs, zones, free tiles, skill records) and the notes of the tier-1 kits | +| `shared/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) | +| `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor | + +`content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less +id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). + +## Naming + +`ops/-.js`, where the code name is the operator's official `charId` without its +`char__` prefix (`data/chess.json` `charId`): `chess_char_3_04_a` (琳琅诗怀雅, `char_1033_swire2`) → +`ops/chess_char_3_04-swire2.js`. + +- The chess id is the registry key and the one unique name: one operator can be two chess (锡人 `chess_char_1_16` and + `chess_char_2_19`, 耶拉 3_20 / 4_03, 华法琳 4_26 / 5_04 …), so a code name alone collides. One file ⇔ one registry + entry, and a directory listing sorts like the data: by tier, then shop order. +- The code name finds the operator by name; the docs and the code already call kits by it (`lemuen`, `whitw2`, + `sbell2`, `pithst`). It is ASCII and stable across languages — no Unicode file names (the macOS NFD / Windows / URL + encoding differences of `/sim/` in the browser) — and the Chinese name is in the file's first line, so + `grep -l 玛恩纳 ops/` works too. +- `-` separates the two parts, which both contain `_`. + +## A kit file + +```js +// server/sim/content/kits/ops/chess_char_6_21-codename.js — 名字 (char_xxxx_codename) kit, tier 6. +// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md. + +import { num, talentBb, statBuff } from '../shared/tier1.js'; // shared helpers, engine modules (relative paths) + +/** 名字 S2 "…": the radius exists only in the PRTS 备注 (no blackboard key). */ +const SOME_RADIUS = 1.5; // helpers / constants of this kit only + +export default { + // 6_21 名字 — S3 …: what each skill / talent / module does, which blackboard key gives each number, [ASSUMED] where + // no source says. + chess_char_6_21_a: (bb, chess, def) => ({ skill: { … }, skills: { … }, talents: [ … ], trait: { … }, install() {} }), +}; +``` + +- **The default export** is `{ [baseChessId]: (bb, chess, def) => Kit }` with exactly one key, the `_a` id; the same kit + serves the elite `_b` record. `bb` = the SELECTED skill's blackboard at the chess's level (normal Lv4, elite Lv7), + `chess` = the record as the unit's loadout makes it (talents, `trait.bb`, `module`), `def` = the normalised def. The + Kit fields (`skill`, `skills: { [skillId]: SkillSpec }`, `talents`, `trait`, `install`) and how the loader picks the + selected skill's spec: docs/SIM.md §7.2; the SkillSpec schema: §7.3; worked examples with real operators: §7.5. +- Small real files to start from: `ops/chess_char_1_11-skgoat.js`, `ops/chess_char_1_02-yak.js`; one with every + selectable skill: `ops/chess_char_3_04-swire2.js`; a large one with its own helpers: `ops/chess_char_6_18-whitw2.js`. +- The tier-4 files wrap their entry in `withDefaults(…)` (`shared/tier4.js`: it rebuilds `def` for callers that pass + `(bb, chess)` only); `content/index.js` always passes all three, so a new kit needs no wrapper. + +## What a kit may use + +- **Helpers**: `../shared/tier1.js` first — `num` / `talentBb` / `talentGrid` / `moduleBb` / `traitBb` / `moduleOn`, + `skillRec` / `skillBbOf` (another selectable skill's record and blackboard), `instantKind`, `batMod`, `up` / `cheb`, + `onHitBy` / `onHitOn` / `onDamagedOn`, `enemiesInGrid` / `alliesInGridOf` / `enemyInRange`, `statBuff` / `toggleBuff` + / `installAura` / `spTimeBonus` / `installReveal`, `makeZone`, `freeTileAround`, `giveSp`, `once`. The other + `../shared/tierN.js` are usable too, but same-named helpers are not interchangeable: `num` of tier 1 / 4 also reads + numeric strings, those of tiers 3 / 5 / 6 do not; `toggleBuff`, `talentBb`, `moduleBb`, `instantKind` take different + arguments per tier. Read the helper before using it. +- **The battle API** documented in docs/SIM.md: units (§2), buffs / mods / statuses (§3), the damage & heal pipeline + (§4), the hook bus and its re-entrancy rule (§5), the engine helpers content must use (§6), the skill runtime (§7.1); + engine modules by relative path (`../../../constants.js`, `targeting.js`, `body.js` for huge enemies, `dir.js` for + facing, `damage.js` `hasHp` / `isHpLoss` / `mitigate`, `buffs.js`, `simdata.js` `normalizeChess`); content modules + (`../../tokens.js` summons, `../../items/battle.js`). +- **Never**: Node-only modules (`node:*`, `server/sim/nodeData.js`) or anything of `server/match`, `server/net`, + `public/` — the sim is served to browsers as-is (`/sim/`, no bundler); `Math.random`, `Date.now`, timers — use + `battle.rng()` (`rng.int / range / chance / pick / shuffle / weighted`), `battle.after` / `battle.every`; state kept + across battles in module variables — key it by battle (`WeakMap`) or keep it in `unit.mem`; mutating `bb`, `def`, + `chess` or range grids (deep-frozen, shared by every battle: copy them). Register hooks with `{ owner: unit }` and + check `unit.alive` in handlers. `test/content/kits_layout.test.js` enforces the import / clock rules. +- Order matters: hooks run by priority, then registration order, and every random draw comes from one stream — the + golden corpus (`test/golden/`) notices any change in either. + +## Registering a kit + +1. The chess needs its data record first: `data/chess.json` is generated from the official tables by + `tools/build-data.mjs` (docs/DATA.md) — skills, talents, trait, modules, ranges, tokens and the resolved auto-cast + trigger of every skill. +2. Add the kit file to `ops/`, named as above. +3. Append its file name to its tier's group in `index.js` `KIT_FILES` (never reorder the existing entries). Each file + is loaded with a guarded dynamic import: a file that fails to load is logged and its chess falls back to the + generic kit, so check the server log when a kit seems to do nothing. +4. `node --test test/content/kits_layout.test.js` checks the name, the registry entry and the imports; + `node tools/kit-coverage.mjs --missing` lists selectable skills that still fall back to the generic spec. + +## Testing a kit + +- The harness is `test/helpers/battleHarness.js` (`makeBattle`, `runUntil`, `hooksOf`, `eventsOf`, `checkInvariants`; + docs/SIM.md §10 has an example and every option). Seed everything, use synthetic `enemyRec` targets for exact + numbers, assert on ids, end with `checkInvariants(h.b)`. +- Patterns: `test/content/kits_t1t2.test.js` … `kits_t6.test.js` (the default skill and the talents of every chess), + `test/content/kits_alt_t1.test.js` … `kits_alt_t6.test.js` (every selectable non-default skill and every module; + `kits_alt_t4.test.js` also runs every chess × skill × module against a mixed wave), + `test/content/feedback1b_kits.test.js` (real-match checks). + A new kit gets its own test file (for example `test/content/kits_.test.js`). +- Golden results: a new or changed kit moves the digests of the scenarios that field its chess. Run + `npm run golden:update` and commit `test/golden/*.json` in the same change, saying which scenarios moved and why + (`test/golden/README.md`). A refactor never changes them. +- Run `node --test test/content/kits_layout.test.js test/golden.test.js `, then + `GOLDEN_FULL=1 node --test test/golden.test.js` before the pull request. + +## The fidelity rule + +Every official skill, talent and module is implemented exactly as the game has it — no approximation. + +- Sources: the official tables (`.cache/gamedata/excel/*.json`, which `tools/build-data.mjs` downloads: character_table, + skill_table, battle_equip_table / uniequip_table, activity_table → act2autochess) as built into `data/chess.json`; + PRTS (prts.wiki: the operator page, its 备注, 卫戍协议 pages) for mechanics the data does not carry — cite it. +- Numbers come from the blackboards whenever a key exists; a number that only exists in the text is parsed from it + (with a fallback) or a named constant with its source in the comment. +- When the text and the blackboard seem to disagree, find out what the game does and say so in the comment (DESIGN + §19.9: 至简's text says 购买价格为1 while `bb.price` is 2 — the blackboard is the discount off the tier price 3). +- If the engine cannot express a mechanic, extend the engine in a reviewed change of its own instead of faking it. +- Anything no source backs is marked `[ASSUMED]` in the comment and in the pull request; a decision of the owner is + cited as "owner's decision YYYY-MM-DD". + +## Fidelity checklist (one per operator) + +Each item is a mistake this project already made once. Tick every one for every operator in the pull request. + +- [ ] **1. Skills** — every effect of each skill at the level the chess uses (normal Lv4, elite Lv7: the skill_table + blackboard and the text, reconciled), durations, charges, ammo, SP type (time / attack / hit) and initial SP (the + data's `spType`, `initSp`; SkillSpec overrides need a reason). Examples: ammo — 隐现 `ops/chess_char_1_01-inside.js`, + `kits_t1t2.test.js` "1_01 隐现: ammo skill"; charges — 松果 S1, `kits_alt_t3.test.js` "3_10 松果 S1 RMA长钉 (charges)"; + hit SP — `feedback1d-solvent.test.js` (the drain feeds 受击回复 SP). +- [ ] **2. Talents** — every talent at the chess's level / potential (the talent blackboards), conditional ones too. + Example: `kits_t1t2.test.js` "1_01 隐现 elite: +self_ammo after `duration` s on field; a random other 【拉特兰】 ammo + operator +ally_ammo". +- [ ] **3. Modules** — for every module the chess offers (loadout, DESIGN §16): the trait override + (battle_equip_table `overrideTraitDataBundle`, the trait text), the talent changes (`addOrOverrideTalentDataBundle`) + and the stat bonuses (`attributeBlackboard`); `chess` is already the loadout-resolved record. Examples: + `kits_alt_t4.test.js` "信仰搅拌机 modules: SPT-Y 老朋友 = range +1 …" and "莫斯提马 SPC-X: the range becomes the module + grid …", and its "every chess × selectable skill × module" run. +- [ ] **4. Attack range** — the base range, the range while a skill that changes it is on, and back when it ends; a + skill's own 技能范围. Examples: 烛煌 S3, `test/sim/feedback1e-skillrange.test.js`; DESIGN §21.16 (the live range on the + card); 见行者 / 松果, `kits_t3.test.js` ("fires on an enemy inside the skill range only"). +- [ ] **5. Auto-cast trigger** — the data resolves it (`tools/build-data.mjs resolveTrigger`, `data/chess.json` + `skills[].trigger`); check it, and change the data rule rather than hand-coding a trigger in the kit. The owner's + rules (0.2.0): a skill whose attack range is larger than the normal one casts as soon as an enemy is inside the + SKILL range (a deliberate deviation from the official 技能策略, like the 重装 exception; existing kits that predate + the rule, such as 烛煌 S3 in `feedback1e-skillrange.test.js`, are not examples of it); an AUTO skill that acts on + allies or itself fires at full SP (引星棘刺 S1, `kits_alt_t5.test.js` "… fires as soon as its SP is full, no enemy + needed (GitHub #124)"); the 重装 exception list (`TRIGGER_DEVIATIONS`: 深巡 S2, 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2, + 余 S2; the 预备干员-重装 / Mechanist stand-ins join it), DESIGN §21.29 / §22.10, + `test/sim/feedback1-tank-triggers.test.js`. +- [ ] **6. Targeting** — can it hit air units (`canHitFly`), ground-only attacks, block count, target priority; can + ground enemies hit it (起飞 / 对地规避, stealth, camouflage flags). Examples: `test/sim/professions.test.js` "fortress + (号角 / 灰毫) is ground-only and never fires at FLY enemies" (DESIGN §22.13); `feedback1b_kits.test.js` "B4 玛恩纳 S3 … + hits the air units" vs "S1 / S2 … stay ground-only"; 蒂比 `ops/chess_char_2_13-tippi.js` (`LIFTOFF_FLAGS`, DESIGN + §21.22); `kits_t3.test.js` "忍冬 迷彩: … ranged enemies stop targeting her". +- [ ] **7. Damage typing** — physical / arts / true / element (元素损伤 vs 元素伤害: docs/SIM.md §7.2), splash vs + normal (普通伤害), dodgeable or not, `isSkill`, and what triggers on-hit effects (a trait that heals on any damage + dealt, e.g. 咒愈师). Examples: `enemies_bosses.test.js` "its attack splash (法术普通伤害) can be dodged; its death blast + (法术溅射伤害) cannot"; DESIGN §23.29 (叙拉古 rolls on every 普通伤害 hit); `test/sim/professions.test.js` "incantation + medics heal an ally for 50 % of damage dealt"; the re-entrancy rule of docs/SIM.md §5. +- [ ] **8. Summons / tokens** — placement rules (the owner's range, deployable tiles), redeploy, what they inherit from + the owner's skill / module (`data/tokens.json` variants, `battle.tokenDef`). Examples: + `test/match/feedback1-placement.test.js` "#9 data: 狼群 and 流形 are owner-range summons" (DESIGN §20.1, §21.3); + `test/content/playtest6_summons.test.js`, `tokens_summons.test.js`; `feedback1b_kits.test.js` "B3 维娜·维多利亚 S3". +- [ ] **9. Status effects** — every status applied, with its official duration and stacking (寒冷 on 寒冷 ⇒ 冻结 for the + longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`). Examples: + `test/sim/combat.test.js` "cold on cold ⇒ freeze for the longer cold …", "sleep: target is untargetable and + inactive; fear stops enemy attacks", "statuses: sleep = invulnerable except `hitSleep` attackers; levitate …"; + `enemies_bosses.test.js` "沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced". +- [ ] **10. Tests** — at least one per skill and per talent, plus the trigger rule, the range toggle, anti-air and + every module; the golden files updated in the same pull request (an intended change). + +## Old references + +DESIGN, the changelog and issues cite kits as `kits/tierN.js ` (before 0.2.0). That kit is now +`ops/-.js` — search `ops/` for the chess id, the code name or the Chinese name — and the helpers of +that file are in `shared/tierN.js`. diff --git a/test/content/kits_layout.test.js b/test/content/kits_layout.test.js new file mode 100644 index 00000000..738bfeda --- /dev/null +++ b/test/content/kits_layout.test.js @@ -0,0 +1,58 @@ +// The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js +// KIT_FILES and nothing else is; a file `-.js` registers exactly the kit of `_a`, whose +// data record has that charId code name; the registry is the tier groups in order, and content/index.js serves that +// object; no kit file imports a Node-only or server-only module or reads the clock / Math.random (the sim is +// deterministic and also runs in the browser, /sim/). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { KIT_FILES, TIER_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { KITS as CONTENT_KITS } from '../../server/sim/content/index.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const KIT_DIR = join(ROOT, 'server/sim/content/kits'); +const CHESS = JSON.parse(readFileSync(join(ROOT, 'data/chess.json'), 'utf8')); +const jsIn = (dir) => readdirSync(join(KIT_DIR, dir)).filter((f) => f.endsWith('.js')).sort(); + +test('kits/index.js lists every kit file of kits/ops/ exactly once, and only those', () => { + const listed = KIT_FILES.flat(); + assert.equal(new Set(listed).size, listed.length, 'a file listed twice'); + assert.deepEqual([...listed].sort(), jsIn('ops')); +}); + +test('a kit file -.js registers the kit of _a (codename: its charId without char__)', async () => { + for (const file of KIT_FILES.flat()) { + const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file); + assert.ok(m, `${file}: name`); + const key = `${m[1]}_a`; + const rec = CHESS[key]; + assert.ok(rec, `${file}: no data record ${key}`); + assert.equal(String(rec.charId).replace(/^char_\d+_/, ''), m[2], `${file}: code name of ${rec.charId}`); + const mod = await import(`../../server/sim/content/kits/ops/${file}`); + assert.deepEqual(Object.keys(mod.default), [key], `${file}: default export`); + assert.equal(typeof mod.default[key], 'function', `${file}: kit builder`); + assert.equal(KITS[key], mod.default[key], `${file}: registered`); + } +}); + +test('the registry is the tier groups in KIT_FILES order, one key per file; content/index.js serves the same object', () => { + assert.deepEqual(Object.keys(KITS), TIER_KITS.flatMap((t) => Object.keys(t))); + assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length, 'one key per file, none twice'); + assert.equal(CONTENT_KITS, KITS); +}); + +test('kit files stay browser-safe and deterministic: no Node-only or server-only import, no clock, no Math.random', () => { + for (const f of ['index.js', ...['shared', 'ops'].flatMap((d) => jsIn(d).map((x) => `${d}/${x}`))]) { + const src = readFileSync(join(KIT_DIR, f), 'utf8'); + assert.ok(!/from\s+['"]node:|import\(\s*['"]node:|\brequire\(|nodeData\.js/.test(src), `${f}: Node-only import`); + const specifiers = [...src.matchAll(/^(?:import|export)\s[^;]*?\sfrom\s+['"]([^'"]+)['"]/gm), ...src.matchAll(/\bimport\(\s*['"`]([^'"`]+)['"`]/g)]; + for (const [, p] of specifiers) { + assert.ok(p.startsWith('.'), `${f}: bare import ${p}`); + // never server / client code, never an index module (content/index.js, kits/index.js: an import cycle) + assert.ok(!/(^|\/)(match|net|public)\/|(^|\/)index\.js$/.test(p), `${f}: imports ${p}`); + } + assert.ok(!/Math\.random|Date\.now|performance\.now|setTimeout|setInterval/.test(src), `${f}: clock / Math.random (use battle.rng, battle.after / every)`); + } +});