feedback5 WD: the sim fields a 补位 stand-in — getChess(id, { standIn: true }) over data/backups.json, PlayerBattleInput standIn: true, kits by the stand-in's charId (never the replaced operator's), kits/ops/standin-<codename>.js registry, harness standIn

- simdata: DataSource keeps `backups` (rawBackups follows the fallback chain); getStandIn = shared/standIn.js
  standInRecord normalised (the chess's ids / bonds / 特质 / tier, the stand-in's body, def.standInFor); a PRESET / DIY
  chess or a source without backups fields itself; the per-battle view maps id-only lookups.
- spec.js keeps `standIn` only when exactly true (never on a token); Battle passes it to getChess; syncUnitLoadout
  carries it; UnitInfo `standInFor`.
- content/index.js kitOf: a stand-in's kit is KITS[def.charId] only; no default skill (a stand-in kit writes `skills`);
  without a kit the generic kit plus genericTalents. kits/index.js STANDIN_KIT_FILES / STANDIN_KITS, merged after the
  tier groups (old keys keep their order; golden unchanged).
- server/data.js and tools/setup.mjs expect backups.json.
- test/content/standin.test.js: the plumbing and the eight 预备干员 on all 28 chess records they replace.
This commit is contained in:
sganggs committed 2026-10-05 17:44:58 +08:00
1 parent 286a34064f
commit ed2b679f83
14 files changed
+656 -58

No files matched your search

+8 -5
View File
@@ -604,8 +604,10 @@ A **form** holds the operator fields of a chess record with **nothing selected**
`isDefault`; `displayTokens` / `tokens` (summons — none of the 17 has one); at `equipLevel > 0` `modules[]` (§2.2 shape `isDefault`; `displayTokens` / `tokens` (summons — none of the 17 has one); at `equipLevel > 0` `modules[]` (§2.2 shape
without `isDefault`). `buildUnitForm` uses buildChess's helpers and rules, and the build fails when `buildUnitForm` + without `isDefault`). `buildUnitForm` uses buildChess's helpers and rules, and the build fails when `buildUnitForm` +
`composeUnitRecord` do not give back every PRESET chess field for field (each is its own backup), so a later change to `composeUnitRecord` do not give back every PRESET chess field for field (each is its own backup), so a later change to
buildChess that the stand-ins would miss stops the build. No trigger deviation (§2.2, DESIGN §21.29) applies to a form — buildChess that the stand-ins would miss stops the build. The chess trigger deviations (§2.2, DESIGN §21.29) name chess
the deviations name chess, so the 重装 stand-ins (预备干员-重装, Mechanist) keep the official `TAKE_DAMAGE` row. and never apply to a form; the 重装 stand-ins have their own, by charId (`tools/build-data.mjs STANDIN_TRIGGER_DEVIATIONS`,
the owner's decision of 2026-10-05 in the approved 补位 plan): every skill of 预备干员-重装 and Mechanist is `DEFAULT` —
cast with an enemy in range — with `rawRule` the official `TAKE_DAMAGE`, on every form.
**Composition** (`shared/standIn.js`, pure ESM for the server, the sim and the client): `standInRecord(chess, backups)` is **Composition** (`shared/standIn.js`, pure ESM for the server, the sim and the client): `standInRecord(chess, backups)` is
the NORMAL chess as its stand-in — `IDENTITY_FIELDS` (ids, tier, `isHidden` / `visible`, `chessType`, `backup`, `bonds`, the NORMAL chess as its stand-in — `IDENTITY_FIELDS` (ids, tier, `isHidden` / `visible`, `chessType`, `backup`, `bonds`,
@@ -613,9 +615,10 @@ the NORMAL chess as its stand-in — `IDENTITY_FIELDS` (ids, tier, `isHidden` /
with `backup.skillIndex` / `backup.uniEquipId` as the defaults, plus `standInFor` (the replaced charId: the official 补位 with `backup.skillIndex` / `backup.uniEquipId` as the defaults, plus `standInFor` (the replaced charId: the official 补位
mark on the avatar). The result is shaped exactly like a chess record (`skill` = the `isDefault` entry of `skills[]`; mark on the avatar). The result is shaped exactly like a chess record (`skill` = the `isDefault` entry of `skills[]`;
elite `statsBase` / `traitBase` / `talentsBase` / `modules[]`), so `normalizeChess`, `resolveLoadout` (no loadout ⇒ the elite `statsBase` / `traitBase` / `talentsBase` / `modules[]`), so `normalizeChess`, `resolveLoadout` (no loadout ⇒ the
backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DIY chess. **Kits are keyed by base chess backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DIY chess. **In battle** a PlayerBattleInput
id** (`server/sim/content/index.js setupUnitKit`): a stand-in keeps the chess's ids, so its kit must be found by its entry with `standIn: true` is fielded as `getChess(chessId, { standIn: true })` (docs/SIM.md §12: this record, normalised),
`charId`. `diyRecord(slot, charId, backups, { skillIndex, moduleId })` fills a DIY slot with a prototype (the slot's and its kit is found by its `charId` (`server/sim/content/index.js kitOf`; kits/README.md "Stand-in kits") — never by
the chess id it keeps, which names the replaced operator's kit. `diyRecord(slot, charId, backups, { skillIndex, moduleId })` fills a DIY slot with a prototype (the slot's
tier and price, no 特质, the pick's derived bonds); which skill a prototype carries in a slot is not in the data. tier and price, no 特质, the pick's derived bonds); which skill a prototype carries in a slot is not in the data.
`diy`: `diy`:
+24 -4
View File
@@ -863,6 +863,11 @@ Kit = {
Keys: the data `baseId` (`chess_char_1_01_a`, also used for the elite `_b`), the exact chess id, or the suffix-less id of the Keys: the data `baseId` (`chess_char_1_01_a`, also used for the elite `_b`), the exact chess id, or the suffix-less id of the
DESIGN §5.6 example (`chess_char_1_01`) — all three are looked up. DESIGN §5.6 example (`chess_char_1_01`) — all three are looked up.
Missing kit ⇒ `content/generic.js` builds one from the blackboard (§7.4), so every chess always fights. Missing kit ⇒ `content/generic.js` builds one from the blackboard (§7.4), so every chess always fights.
补位 stand-ins (DATA.md §18; a PlayerBattleInput entry with `standIn: true` ⇒ the def of `getChess(chessId, { standIn:
true })`, §12): the def keeps the chess's ids, so `content/index.js kitOf` looks the kit up by `def.charId` only — the
stand-in's own, `kits/ops/standin-<codename>.js` registered under that charId (kits/README.md "Stand-in kits") — never
by the chess id, which names the replaced operator's kit. A stand-in has no default skill: its kit authors each skill in
`skills` (`skill` is ignored); without a kit it gets the generic kit plus `genericTalents(def)` (§7.4).
Operator loadouts (DESIGN §16): the unit's def is `getChess(chessId, { skillIndex, moduleId })` of its PlayerBattleInput Operator loadouts (DESIGN §16): the unit's def is `getChess(chessId, { skillIndex, moduleId })` of its PlayerBattleInput
entry (no loadout fields = the default). The skill spec is `kit.skills[selectedSkillId]` when authored, else `kit.skill` entry (no loadout fields = the default). The skill spec is `kit.skills[selectedSkillId]` when authored, else `kit.skill`
only when the selected skill is the default one, else the GENERIC spec of the selected skill (its generic install is only when the selected skill is the default one, else the GENERIC spec of the selected skill (its generic install is
@@ -947,7 +952,12 @@ whose skill "恢复…友方/友军…生命" heals the most injured ally in ran
displacement by the official 力度 − 重量 rules (拖拽/hookmaster: `pullToFront`; else `push`, directional for 往攻击方向 / 朝部署方向 / displacement by the official 力度 − 重量 rules (拖拽/hookmaster: `pullToFront`; else `push`, directional for 往攻击方向 / 朝部署方向 /
向前 / 身前方向 and 推击手, radial otherwise — §6; only a fallback: hand-written kits follow the client templates 向前 / 身前方向 and 推击手, radial otherwise — §6; only a fallback: hand-written kits follow the client templates
`knockback[dir]` / `knockback[relative]`, e.g. 琳琅诗怀雅 S3's "向前推开" is radial). **Passive** skills only apply stat mods (timed when the text says "N秒内": `knockback[dir]` / `knockback[relative]`, e.g. 琳琅诗怀雅 S3's "向前推开" is radial). **Passive** skills only apply stat mods (timed when the text says "N秒内":
宴 +65 % ATK for 14 s) and the self/counter effects — their scales describe procs that need a kit. Instant skills with 宴 +65 % ATK for 14 s; for the skill's own `duration` from every deployment when an ON_DEPLOY passive says "部署后…" with no
`duration` key — 一击即退, 10 s) and the self/counter effects — their scales describe procs that need a kit.
"立即获得N点部署费用" + `cost` ⇒ +cost DP at the start (冲锋号令); such an AUTO skill with nothing else to do fires at full
SP (`trigger: 'SP_FULL'`, as 德克萨斯's kit casts it). `genericTalents(def)` — for 补位 stand-ins without a kit only — applies
every unconditional stat talent ("攻击力+8%", "防御力+10%", "攻击速度+9"; `statTalentMods`: only stat clauses whose numbers
are the blackboard's) as a persistent `talent:generic:<i>` buff; any other talent is left to a kit. Instant skills with
mods/targeting but no attack override apply them to the next attack (the skill range is switched in for that attack). mods/targeting but no attack override apply them to the next attack (the skill range is switched in for that attack).
### 7.5 Worked examples (real operators, numbers from blackboards) ### 7.5 Worked examples (real operators, numbers from blackboards)
@@ -1182,7 +1192,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing; `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
`extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`), `extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) - `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data). (`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
@@ -1218,7 +1228,10 @@ test/content/facing.test.js)
or `players`; `enemies: or `players`; `enemies:
[{ key, time, route (index | RouteSpec), pos, count, interval, mods, tag, bounty, sourcePlayerId }]` or `waveTemplate` [{ key, time, route (index | RouteSpec), pos, count, interval, mods, tag, bounty, sourcePlayerId }]` or `waveTemplate`
(id or object ⇒ routes, spawns, time limit); `routes` (flat defaults: 0 walk low gate, 1 walk high gate, 2/3 fly); (id or object ⇒ routes, spawns, time limit); `routes` (flat defaults: 0 walk low gate, 1 walk high gate, 2/3 fly);
`timeLimit`; `content` (`'full'|'generic'|'none'`); `kits` (inject `{ baseId: kitFn }`); `defs: { chess, enemies, tokens }` `timeLimit`; `content` (`'full'|'generic'|'none'`); `kits` (inject `{ baseId: kitFn }`; a stand-in's by its charId); a unit's
`standIn: true` fields a NORMAL chess as its 补位 stand-in (the production path, the chess's backup skill / module; `_a` =
normal, `_b` = elite), `standIn: { skillIndex?, moduleId? }` another skill / module of it (the composed record `standInRec(chessId,
sel)` replaces that chess's record for the battle; kits/README.md "How to test a stand-in kit"); `defs: { chess, enemies, tokens }`
(extra/override records; build them with `chessRec({...})` / `enemyRec({...})`); `bonds`, `bandId`, `playerEffects`, (extra/override records; build them with `chessRec({...})` / `enemyRec({...})`); `bonds`, `bandId`, `playerEffects`,
`flags`, `sharedBoss`, `setup(battle)`, `hooks` (names to capture; `captureNoisy` to keep tick/hit/damaged/heal/spGain/ `flags`, `sharedBoss`, `setup(battle)`, `hooks` (names to capture; `captureNoisy` to keep tick/hit/damaged/heal/spGain/
attack contexts), `autoFinish` (default: true when enemies are scheduled). attack contexts), `autoFinish` (default: true when enemies are scheduled).
@@ -1227,7 +1240,7 @@ Harness API: `battle`/`b`, `step(n)`, `run(seconds)`, `runUntil(pred | seconds,
`eventsOf(kind)` (client tuples), `hooks` / `hooksOf(name)` (captured ctx copies with `t`), `result()`, `snapshot()`, `eventsOf(kind)` (client tuples), `hooks` / `hooksOf(name)` (captured ctx copies with `t`), `result()`, `snapshot()`,
`invariants()`. `checkInvariants(b)` (no NaN, hp ∈ [0, maxHp], positions inside the rect, SP/charge bounds, finite skill `invariants()`. `checkInvariants(b)` (no NaN, hp ∈ [0, maxHp], positions inside the rect, SP/charge bounds, finite skill
timers, occupancy map in sync with deployed allies, block links, DP, finite projectiles and per-player result counters, timers, occupancy map in sync with deployed allies, block links, DP, finite projectiles and per-player result counters,
no open hook emit between steps), `hashOf(v)`, `flatStage()`, `flatRoutes(kind)` are exported too. no open hook emit between steps), `hashOf(v)`, `flatStage()`, `flatRoutes(kind)`, `standInRec(chessId, sel)` are exported too.
Soaks: `SIM_FUZZ_N=3000 SIM_FUZZ_CHECK_EVERY=3 node --test test/sim/fuzz.test.js` (random real battles) and Soaks: `SIM_FUZZ_N=3000 SIM_FUZZ_CHECK_EVERY=3 node --test test/sim/fuzz.test.js` (random real battles) and
`SIM_CHAOS_N=500 SIM_CHAOS_SEED=<n> node --test --test-name-pattern="chaos fuzz" test/sim/robustness.test.js` (content that `SIM_CHAOS_N=500 SIM_CHAOS_SEED=<n> node --test --test-name-pattern="chaos fuzz" test/sim/robustness.test.js` (content that
@@ -1270,6 +1283,13 @@ ownerLoadout)` = the summon for that owner loadout (`bySkill` / `byModule` merge
per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owner)` use the loadout the inputs give that per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owner)` use the loadout the inputs give that
chess id — the first player's when two players of one field differ, `view.loadoutConflicts`); Battle itself always passes chess id — the first player's when two players of one field differ, `view.loadoutConflicts`); Battle itself always passes
the unit's own loadout (`_createAllyFromInput`, `tokenDef` / `spawnToken`, the dollkeeper substitute), so it is exact. the unit's own loadout (`_createAllyFromInput`, `tokenDef` / `spawnToken`, the dollkeeper substitute), so it is exact.
补位 (DATA.md §18): `getChess(id, { standIn: true })` = `getStandIn(id)` — shared/standIn.js `standInRecord` of the chess over
the source's `backups` (data/backups.json; `rawBackups()` follows the fallback chain), normalised: `id` / `baseId` / `golden` /
`tier` / `bonds` / `raw.garrisonIds` of the chess, every combat field of the stand-in, `charId` the stand-in's, `standInFor`
the replaced operator's, `loadout` the backup selection (`standIn: true`); its skill / module ignore the loadout's
("对于补位干员其技能不可更改", shared/standIn.js). A PRESET / DIY chess or a source without backups (a browser that did not
fetch backups.json) gives the chess's own def. A PlayerBattleInput entry carries it as `standIn: true` (spec.js keeps
nothing else); the per-battle view maps id-only lookups of that chess id to the stand-in.
Assumptions taken here (documented choices): element burst numbers for necrosis/apoptosis; unspawned enemies at timeout Assumptions taken here (documented choices): element burst numbers for necrosis/apoptosis; unspawned enemies at timeout
are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement is instantaneous (the official 失衡 slide over ≈ 0.4–1.2 s is not are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement is instantaneous (the official 失衡 slide over ≈ 0.4–1.2 s is not
modelled — only its distance, `push` / `pull`); pushes PRTS does not classify use the 弹道 distance column (`PUSH_TILES`); modelled — only its distance, `push` / `pull`); pushes PRTS does not classify use the 弹道 distance column (`PUSH_TILES`);
+2 -2
View File
@@ -2,7 +2,7 @@
// //
// Every `*.json` file in the data directory becomes a top-level key named after its basename // Every `*.json` file in the data directory becomes a top-level key named after its basename
// (config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, // (config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages,
// bosses, tokens, assets, …). The result is deep-frozen so no module can mutate shared data by accident. // bosses, tokens, assets, backups, …). The result is deep-frozen so no module can mutate shared data by accident.
// Missing or unparsable files are tolerated with a warning (data is generated in parallel with the // Missing or unparsable files are tolerated with a warning (data is generated in parallel with the
// server); consumers must cope with an absent key. // server); consumers must cope with an absent key.
// //
@@ -26,7 +26,7 @@ export const DATA_DIR = path.join(ROOT, 'data');
/** Files the game expects (a warning lists the missing ones). */ /** Files the game expects (a warning lists the missing ones). */
export const DATA_FILES = Object.freeze([ export const DATA_FILES = Object.freeze([
'config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices', 'config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices',
'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', 'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', 'backups',
]); ]);
/** /**
+5 -2
View File
@@ -102,8 +102,11 @@ export class BattlePlayers {
return u; return u;
} }
// the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's // the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's
// choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups) // choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups); `standIn:
const def = this.data.getChess(inp.chessId, { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null }); // true` fields the chess as its 补位 stand-in (simdata getStandIn: the stand-in's body, the chess's identity)
const lo = { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null };
if (inp.standIn === true) lo.standIn = true;
const def = this.data.getChess(inp.chessId, lo);
if (!def) { this.log(`unknown chess ${inp.chessId}`); return null; } if (!def) { this.log(`unknown chess ${inp.chessId}`); return null; }
const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir }); const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir });
u.items = [...(inp.items ?? [])]; u.items = [...(inp.items ?? [])];
+54 -18
View File
@@ -7,6 +7,10 @@
// a faulty content module is logged and skipped, never crashing the battle. // a faulty content module is logged and skipped, never crashing the battle.
// setupUnitKit(battle, unit, mode) — resolves a unit's Kit: kits[baseChessId]?.(bb, chess, def) ?? generic. // setupUnitKit(battle, unit, mode) — resolves a unit's Kit: kits[baseChessId]?.(bb, chess, def) ?? generic.
// Battle opts.kits ({ [baseChessId]: kitFn }) take precedence (tests / sandboxes). // Battle opts.kits ({ [baseChessId]: kitFn }) take precedence (tests / sandboxes).
// 补位 stand-ins (isStandInDef: the def of simdata getStandIn — the chess's ids, another character's body): the kit is
// kits[def.charId] (the stand-in's own, kits/ops/standin-<codename>.js), never the chess id's — that is the replaced
// operator's kit; without one, the generic kit plus the talents it can apply exactly (generic.js genericTalents).
// A stand-in has no default skill: its kit authors every skill it supports in `skills` (selectSkillSpec).
// Operator loadouts (DESIGN §16): the def is resolved for the unit's selected skill / module (simdata getChess), so // Operator loadouts (DESIGN §16): the def is resolved for the unit's selected skill / module (simdata getChess), so
// the kit receives `chess.skill` = the SELECTED skill record and `bb` = its blackboard (talents / trait / module of // the kit receives `chess.skill` = the SELECTED skill record and `bb` = its blackboard (talents / trait / module of
// the selected module). Kit contract (backward compatible): `{ skill?, skills?: { [skillId]: SkillSpec }, talents, // the selected module). Kit contract (backward compatible): `{ skill?, skills?: { [skillId]: SkillSpec }, talents,
@@ -19,7 +23,7 @@
// are resolved by Battle (getToken(id, owner.defId, owner.def.loadout)), not here. // are resolved by Battle (getToken(id, owner.defId, owner.def.loadout)), not here.
// registerAllMeta(registry) — calls each domain module's registerMeta(registry) (prep side, server boot). // registerAllMeta(registry) — calls each domain module's registerMeta(registry) (prep side, server boot).
import { genericKit } from './generic.js'; import { genericKit, genericTalents } from './generic.js';
import { withUnitLoadouts } from '../simdata.js'; import { withUnitLoadouts } from '../simdata.js';
// Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing // Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing
@@ -38,12 +42,43 @@ const DOMAIN_NAMES = ['tokens', 'devices', 'enemies', 'bosses', 'bonds', 'garris
const DOMAINS = await Promise.all(DOMAIN_NAMES.map((n) => safeImport(`./${n}.js`))); const DOMAINS = await Promise.all(DOMAIN_NAMES.map((n) => safeImport(`./${n}.js`)));
const tokens = DOMAINS[0]; const tokens = DOMAINS[0];
/** Merged kit registry: baseChessId → (bb, chess, def) => Kit (kits/index.js; each kit file is loaded guarded too) */ /**
* Merged kit registry: baseChessId → (bb, chess, def) => Kit, then the 补位 stand-in kits: stand-in charId → builder
* (kits/index.js; each kit file is loaded guarded too)
*/
export const KITS = KIT_REGISTRY.KITS ?? Object.freeze({}); export const KITS = KIT_REGISTRY.KITS ?? Object.freeze({});
/** Domain modules in install order: tokens, devices, enemies, bosses, bonds, garrisons, items, bands, choices. */ /** Domain modules in install order: tokens, devices, enemies, bosses, bonds, garrisons, items, bands, choices. */
export const MODULES = Object.freeze(DOMAIN_NAMES.map((n, i) => [n, DOMAINS[i]])); export const MODULES = Object.freeze(DOMAIN_NAMES.map((n, i) => [n, DOMAINS[i]]));
/**
* Whether `def` is a 补位 stand-in: a chess fielded with another character's body (simdata getStandIn — `standInFor` =
* the replaced operator's charId, `charId` = the stand-in's, which differ).
*/
export function isStandInDef(def) {
return !!(def && def.standInFor && def.charId && def.charId !== def.standInFor);
}
/**
* The kit builder of an operator def in `reg` ({ [key]: (bb, chess, def) => Kit }), or undefined. A stand-in's kit is
* keyed by its charId (`char_609_acguad`) and by nothing else: the chess id it keeps names the replaced operator's kit.
* Any other def: baseId (`…_a`, data/SIM.md), the exact id, or the suffix-less id of DESIGN §5.6's example.
*/
export function kitOf(def, reg) {
if (!def || !reg) return undefined;
if (isStandInDef(def)) return Object.prototype.hasOwnProperty.call(reg, def.charId) ? reg[def.charId] : undefined;
const bare = String(def.baseId ?? def.id ?? '').replace(/_[ab]$/, '');
return reg[def.baseId] ?? reg[def.id] ?? reg[bare];
}
/** The generic kit of an operator def; a stand-in's also carries the talents generic.js can apply exactly. */
function fallbackKit(bb, raw, def) {
const k = genericKit(bb, raw, def);
if (!isStandInDef(def)) return k;
const talents = genericTalents(def);
return talents.length ? { ...k, talents: [...(k.talents ?? []), ...talents] } : k;
}
/** Resolve the Kit of an ally unit. Never throws (falls back to the generic kit). */ /** Resolve the Kit of an ally unit. Never throws (falls back to the generic kit). */
export function setupUnitKit(battle, unit, mode = 'full') { export function setupUnitKit(battle, unit, mode = 'full') {
try { syncUnitLoadout(battle, unit); } catch (e) { battle._handlerError?.('loadout', unit, e); } try { syncUnitLoadout(battle, unit); } catch (e) { battle._handlerError?.('loadout', unit, e); }
@@ -61,20 +96,17 @@ export function setupUnitKit(battle, unit, mode = 'full') {
if (unit.kind !== 'op') return {}; if (unit.kind !== 'op') return {};
const injected = battle.opts && battle.opts.kits; const injected = battle.opts && battle.opts.kits;
if (mode === 'full' || injected) { if (mode === 'full' || injected) {
// DESIGN §5.6's example keys kits by the suffix-less id (`chess_char_1_01`), data/SIM.md by baseId (`…_a`): accept both const f = kitOf(def, injected) ?? (mode === 'full' ? kitOf(def, KITS) : undefined);
const bare = String(def.baseId ?? def.id ?? '').replace(/_[ab]$/, '');
const pick = (reg) => reg?.[def.baseId] ?? reg?.[def.id] ?? reg?.[bare];
const f = pick(injected) ?? (mode === 'full' ? pick(KITS) : undefined);
if (typeof f === 'function') { if (typeof f === 'function') {
try { try {
const k = f(bb, raw, def); const k = f(bb, raw, def);
if (k) return selectSkillSpec(k, bb, raw, def); if (k) return selectSkillSpec(k, bb, raw, def);
} catch (e) { } catch (e) {
battle._handlerError(`kit:${def.baseId}`, unit, e); battle._handlerError(`kit:${isStandInDef(def) ? def.charId : def.baseId}`, unit, e);
} }
} }
} }
return genericKit(bb, raw, def); return fallbackKit(bb, raw, def);
} }
/** A unit's own PlayerBattleInput entry (operators: kind ≠ 'token'; tokens: kind 'token'), or null. */ /** A unit's own PlayerBattleInput entry (operators: kind ≠ 'token'; tokens: kind 'token'), or null. */
@@ -88,13 +120,15 @@ function inputEntry(unit) {
/** /**
* The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means * The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means
* the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a * the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a
* multi-player field). Null when the unit has no input entry. * multi-player field). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say).
* Null when the unit has no input entry.
*/ */
function inputLoadout(unit) { function inputLoadout(unit) {
const x = inputEntry(unit); const x = inputEntry(unit);
if (!x) return null; if (!x) return null;
if (x.skillIndex == null && x.moduleId == null) return {}; const standIn = x.standIn === true ? { standIn: true } : null;
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null }; if (x.skillIndex == null && x.moduleId == null) return standIn ?? {};
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...standIn };
} }
/** Put a def on a not-yet-deployed ally (the fields Battle._makeAlly takes from the def). */ /** Put a def on a not-yet-deployed ally (the fields Battle._makeAlly takes from the def). */
@@ -143,13 +177,16 @@ export function syncUnitLoadout(battle, unit) {
/** Id of the selected skill of an operator def (null without a skill). */ /** Id of the selected skill of an operator def (null without a skill). */
const selectedSkillId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? null; const selectedSkillId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? null;
/** True when the def's selected skill is the chess's default skill (no loadout info ⇒ default). */ /**
const skillIsDefault = (def) => !def?.loadout || def.loadout.skillIsDefault !== false; * True when the def's selected skill is the chess's default skill (no loadout info ⇒ default). Never for a stand-in:
* the skill it fields is the one its chess names (S2 on one chess, S3 on another), so its kit authors each in `skills`.
*/
const skillIsDefault = (def) => !isStandInDef(def) && (!def?.loadout || def.loadout.skillIsDefault !== false);
/** /**
* The kit with the skill spec of the SELECTED skill (see header): `kit.skills[id]` → `kit.skill` (default skill only) * The kit with the skill spec of the SELECTED skill (see header): `kit.skills[id]` → `kit.skill` (default skill only;
* → the generic spec. Returns the kit itself when nothing changes; `kit.skillSource` tells which one was used * never for a stand-in) → the generic spec. Returns the kit itself when nothing changes; `kit.skillSource` tells which
* ('skills' | 'kit' | 'generic'). * one was used ('skills' | 'kit' | 'generic').
* @param {object} kit hand-authored kit * @param {object} kit hand-authored kit
*/ */
export function selectSkillSpec(kit, bb, raw, def) { export function selectSkillSpec(kit, bb, raw, def) {
@@ -172,8 +209,7 @@ export function selectSkillSpec(kit, bb, raw, def) {
* @returns {'skills'|'kit'|'generic'|'none'} where the spec comes from ('none' = no kit function at all) * @returns {'skills'|'kit'|'generic'|'none'} where the spec comes from ('none' = no kit function at all)
*/ */
export function skillSpecSource(def, kits = KITS) { export function skillSpecSource(def, kits = KITS) {
const bare = String(def?.baseId ?? def?.id ?? '').replace(/_[ab]$/, ''); const f = kitOf(def, kits);
const f = kits?.[def?.baseId] ?? kits?.[def?.id] ?? kits?.[bare];
if (typeof f !== 'function') return 'none'; if (typeof f !== 'function') return 'none';
let k = null; let k = null;
try { k = f(def.skill?.bb ?? {}, def.raw ?? def, def); } catch { return 'generic'; } try { k = f(def.skill?.bb ?? {}, def.raw ?? def, def); } catch { return 'generic'; }
+52 -3
View File
@@ -9,14 +9,17 @@ operators, the self-select 6★ operators, contributions from GitHub issue #136
| path | what | | 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 | | `index.js` | the registry: `KIT_FILES` lists every kit file, grouped by tier, and `STANDIN_KIT_FILES` the 补位 stand-in kits; `KITS` (base chess id → kit builder, then stand-in charId → kit builder), `TIER_KITS` (one registry per tier group) and `STANDIN_KITS` are built from them |
| `ops/<chessId>-<codename>.js` | one kit per file, with the helpers and constants only that kit uses | | `ops/<chessId>-<codename>.js` | one kit per file, with the helpers and constants only that kit uses |
| `ops/standin-<codename>.js` | one 补位 stand-in kit per file (below: "Stand-in kits") |
| `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/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) | | `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 | | `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 `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). id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). A 补位 stand-in's
kit is `KITS[def.charId]` and nothing else (`content/index.js kitOf`): it keeps the chess's ids, which name the replaced
operator's kit.
## Naming ## Naming
@@ -96,6 +99,36 @@ export default {
4. `node --test test/content/kits_layout.test.js` checks the name, the registry entry and the imports; 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. `node tools/kit-coverage.mjs --missing` lists selectable skills that still fall back to the generic spec.
## Stand-in kits (补位)
A NORMAL chess whose operator the player does not own fights as its official stand-in (原型干员, data/backups.json,
docs/DATA.md §18): the chess's ids, bonds, 特质, tier and price, the stand-in's body — stats, range, trait, talents, the
skill `backup.skillIndex` and the module `backup.uniEquipId` the chess names. The sim gets that def from
`battle.data.getChess(chessId, { standIn: true })` (a PlayerBattleInput entry with `standIn: true`); `def.charId` is the
stand-in's, `def.standInFor` the replaced operator's charId.
- **File and key**: `ops/standin-<codename>.js`, the code name being the stand-in's charId without `char_<n>_`
(`standin-acguad.js` = Sharp, `char_609_acguad`); the default export has exactly one key, that charId:
`export default { char_609_acguad: (bb, chess, def) => Kit }`. Append the file name to `STANDIN_KIT_FILES` in
`index.js`. Never register a stand-in under a chess id: that is the replaced operator's kit, which the stand-in must
not run.
- **Arguments**: `bb` = the blackboard of the skill the chess names, at the chess's level (normal Lv4, elite Lv7);
`chess` = the composed record (shared/standIn.js `standInRecord`: talents, `trait.bb`, `module` of the stand-in);
`def.tier` / `def.golden` tell which chess it stands in for.
- **No default skill**: one stand-in takes S2 on one chess and S3 on another (Sharp: S2 for 银灰, S3 for 隐德来希), so the
kit authors every skill it supports in `skills: { [skillId]: SkillSpec }`; `skill` is ignored for a stand-in (a skill
missing from `skills` gets the generic spec). `talents`, `trait` and `install` apply as for any kit.
- **Without a file** the stand-in fights with the generic kit plus the talents it can apply exactly
(`generic.js genericTalents`: unconditional stat lines like "攻击力+8%"). The eight 预备干员 need no file: their skills
(攻击力 / 防御力 / 治疗强化, 战术咏唱, 冲锋号令, 一击即退) are covered by the generic spec
(`test/content/standin.test.js`).
- **Auto-cast**: the trigger comes from the data like any chess's (checklist item 5). The 重装 exception for the
stand-ins is `tools/build-data.mjs STANDIN_TRIGGER_DEVIATIONS` (预备干员-重装, Mechanist: every skill `DEFAULT`,
`rawRule` `TAKE_DAMAGE`).
- **Summons**: no stand-in has one (DATA.md §18).
- `node --test test/content/kits_layout.test.js` checks the file name, the key (a charId of data/backups.json `units`)
and the registry entry.
## Testing a kit ## Testing a kit
- The harness is `test/helpers/battleHarness.js` (`makeBattle`, `runUntil`, `hooksOf`, `eventsOf`, `checkInvariants`; - The harness is `test/helpers/battleHarness.js` (`makeBattle`, `runUntil`, `hooksOf`, `eventsOf`, `checkInvariants`;
@@ -112,6 +145,22 @@ export default {
- Run `node --test test/content/kits_layout.test.js test/golden.test.js <your test file>`, then - Run `node --test test/content/kits_layout.test.js test/golden.test.js <your test file>`, then
`GOLDEN_FULL=1 node --test test/golden.test.js` before the pull request. `GOLDEN_FULL=1 node --test test/golden.test.js` before the pull request.
### How to test a stand-in kit
- Field the chess as its stand-in: `makeBattle({ units: [{ chessId: 'chess_char_5_13_b', row: 10, col: 4, standIn: true
}] })` — the production path (data/backups.json through `getChess(id, { standIn: true })`): `_a` = the normal form
(Lv4), `_b` = the elite (Lv7, its module), with the chess's backup skill. Pick one chess per skill the stand-in
fields (data/chess.json `backup`; `backups.json units[charId].standsIn` lists them).
- Another skill or module of the stand-in (a skill no chess names, a 自选 prototype): `standIn: { skillIndex: 0 }` or
`standIn: { skillIndex: 2, moduleId: 'none' }` — the harness composes that record (`standInRec`) and puts it under the
chess id for the whole battle, so do not field the real operator of that chess in the same battle.
- Inject a kit for a quick experiment with `kits: { char_609_acguad: (bb, chess, def) => Kit }` (keyed by the charId).
- Assert that the unit is the stand-in (`u.def.charId`, `u.def.standInFor`, `u.skill.id`) and that its kit is yours, not
the generic one (`!u.kit.generic`); then the checklist below as for any operator. Examples:
`test/content/standin.test.js` (the plumbing and the eight 预备干员).
- A stand-in kit is new gameplay for stand-ins only: the golden corpus fields no stand-in, so its digests stay as they
are.
## The fidelity rule ## The fidelity rule
Every official skill, talent and module is implemented exactly as the game has it — no approximation. Every official skill, talent and module is implemented exactly as the game has it — no approximation.
@@ -154,7 +203,7 @@ Each item is a mistake this project already made once. Tick every one for every
the rule, such as 烛煌 S3 in `feedback1e-skillrange.test.js`, are not examples of it); an AUTO skill that acts on 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 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, 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, 余 S2; the 预备干员-重装 / Mechanist stand-ins: `STANDIN_TRIGGER_DEVIATIONS`, every skill), DESIGN §21.29 / §22.10,
`test/sim/feedback1-tank-triggers.test.js`. `test/sim/feedback1-tank-triggers.test.js`.
- [ ] **6. Targeting** — can it hit air units (`canHitFly`), ground-only attacks, block count, target priority; can - [ ] **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 ground enemies hit it (起飞 / 对地规避, stealth, camouflage flags). Examples: `test/sim/professions.test.js` "fortress
+19 -2
View File
@@ -8,6 +8,12 @@
// Each file is loaded with a guarded dynamic import, so this runs unchanged in the browser (/sim/, no Node-only // Each file is loaded with a guarded dynamic import, so this runs unchanged in the browser (/sim/, no Node-only
// module, no directory listing): a file that fails to load (syntax error, throwing top-level code, missing file) is // module, no directory listing): a file that fails to load (syntax error, throwing top-level code, missing file) is
// logged and skipped — its chess falls back to the generic kit — instead of breaking the server or the page. // logged and skipped — its chess falls back to the generic kit — instead of breaking the server or the page.
//
// 补位 stand-in kits (DATA.md §18): STANDIN_KIT_FILES lists `ops/standin-<codename>.js`, one per stand-in character
// (codename = its charId without `char_<n>_`: `standin-acguad.js` = Sharp, char_609_acguad); each registers exactly one
// key, that charId. A stand-in keeps the ids of the chess it replaces, so content/index.js kitOf finds its kit by
// `def.charId` only, never by the chess id (the replaced operator's kit). STANDIN_KITS is merged into KITS after every
// tier group: the chess keys keep their order, and `char_…` keys never meet `chess_char_…` ones.
export const KIT_FILES = Object.freeze([ export const KIT_FILES = Object.freeze([
// tier 1 // tier 1
@@ -53,6 +59,13 @@ export const KIT_FILES = Object.freeze([
'chess_char_6_20-agoat2.js'], 'chess_char_6_20-agoat2.js'],
].map((group) => Object.freeze(group))); ].map((group) => Object.freeze(group)));
/**
* The 补位 stand-in kit files (`ops/standin-<codename>.js`, registry key = the stand-in's charId; a new file is appended).
* A stand-in without a file fights with the generic kit (content/generic.js) plus its unconditional stat talents
* (genericTalents) — the eight 预备干员 need no file.
*/
export const STANDIN_KIT_FILES = Object.freeze([]);
async function loadKitFile(file) { async function loadKitFile(file) {
try { try {
return await import(`./ops/${file}`); return await import(`./ops/${file}`);
@@ -63,10 +76,14 @@ async function loadKitFile(file) {
} }
const MODULES = await Promise.all(KIT_FILES.map((group) => Promise.all(group.map(loadKitFile)))); const MODULES = await Promise.all(KIT_FILES.map((group) => Promise.all(group.map(loadKitFile))));
const STANDIN_MODULES = await Promise.all(STANDIN_KIT_FILES.map(loadKitFile));
const registryOf = (m) => (m && m.default && typeof m.default === 'object' ? m.default : {}); const registryOf = (m) => (m && m.default && typeof m.default === 'object' ? m.default : {});
/** The registry of each tier group (index 0 = tier 1): baseChessId → kit builder, in KIT_FILES order. */ /** The registry of each tier group (index 0 = tier 1): baseChessId → kit builder, in KIT_FILES order. */
export const TIER_KITS = Object.freeze(MODULES.map((group) => Object.assign({}, ...group.map(registryOf)))); export const TIER_KITS = Object.freeze(MODULES.map((group) => Object.assign({}, ...group.map(registryOf))));
/** The merged kit registry: baseChessId → (bb, chess, def) => Kit, tier 1 … tier 6. */ /** The 补位 stand-in kits: stand-in charId → kit builder, in STANDIN_KIT_FILES order. */
export const KITS = Object.freeze(Object.assign({}, ...TIER_KITS)); export const STANDIN_KITS = Object.freeze(Object.assign({}, ...STANDIN_MODULES.map(registryOf)));
/** The merged kit registry: baseChessId → (bb, chess, def) => Kit, tier 1 … tier 6, then the stand-ins' charIds. */
export const KITS = Object.freeze(Object.assign({}, ...TIER_KITS, STANDIN_KITS));
+46 -4
View File
@@ -16,6 +16,11 @@
// match / UI (implemented in shared/loadoutRecord.js, re-exported here): resolveLoadout, loadoutRecord (the resolved // match / UI (implemented in shared/loadoutRecord.js, re-exported here): resolveLoadout, loadoutRecord (the resolved
// chess record), composeStats / composeTalents. (Which choices a player may make is shared/protocol.js // chess record), composeStats / composeTalents. (Which choices a player may make is shared/protocol.js
// loadoutOptions / checkLoadout.) // loadoutOptions / checkLoadout.)
// 补位 stand-ins (DATA.md §18): `getChess(id, { standIn: true })` = the def of a NORMAL chess fielded as its official
// stand-in (shared/standIn.js standInRecord over data/backups.json: the chess's ids, bonds, 特质, tier; the stand-in's
// body, skill `backup.skillIndex` and module `backup.uniEquipId` — any other skill / module choice is ignored), with
// `def.standInFor` = the replaced operator's charId and `def.charId` = the stand-in's (content/index.js looks its kit
// up by that charId). A PRESET / DIY chess, or a source without backups.json, gives the chess's own def instead.
// //
// This module is pure ESM shared with browsers (served at /sim/): no Node API is imported here. Under Node the default // This module is pure ESM shared with browsers (served at /sim/): no Node API is imported here. Under Node the default
// loader (./nodeData.js) is imported dynamically at module evaluation; browsers never load it. // loader (./nodeData.js) is imported dynamically at module evaluation; browsers never load it.
@@ -25,6 +30,7 @@
// getSimData() the injected data (browser) or the generated data (Node), or null. // getSimData() the injected data (browser) or the generated data (Node), or null.
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js'; import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
import { standInRecord } from '../../shared/standIn.js';
import { normHitArea } from './body.js'; import { normHitArea } from './body.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
@@ -193,6 +199,8 @@ export function normalizeChess(rec) {
tokens: rec.tokens ?? [], tokens: rec.tokens ?? [],
spine: rec.assets?.spine ?? rec.spine ?? rec.charId ?? null, spine: rec.assets?.spine ?? rec.spine ?? rec.charId ?? null,
avatar: rec.assets?.avatar ?? rec.avatar ?? rec.charId ?? null, avatar: rec.assets?.avatar ?? rec.avatar ?? rec.charId ?? null,
// a 补位 stand-in (shared/standIn.js): the replaced operator's charId — `charId` is the stand-in's (absent otherwise)
...(rec.standInFor ? { standInFor: rec.standInFor } : null),
raw: rec, raw: rec,
}; };
} }
@@ -509,7 +517,8 @@ export function freezeDef(d) {
} }
/** /**
* A DataSource resolves ids to normalised defs. `raw` is `{ chess, enemies, tokens, stages, waves }`. * A DataSource resolves ids to normalised defs. `raw` is `{ chess, enemies, tokens, stages, waves, backups? }`
* (`backups` = data/backups.json `{ units, diy }`, the 补位 stand-ins: getChess(id, { standIn: true })).
*/ */
export class DataSource { export class DataSource {
constructor(raw = {}, fallback = null) { constructor(raw = {}, fallback = null) {
@@ -519,6 +528,7 @@ export class DataSource {
tokens: asMap(unwrap(raw.tokens, 'tokens'), 'tokenId') ?? {}, tokens: asMap(unwrap(raw.tokens, 'tokens'), 'tokenId') ?? {},
stages: asMap(unwrap(raw.stages, 'stages'), 'id') ?? {}, stages: asMap(unwrap(raw.stages, 'stages'), 'id') ?? {},
waves: asMap(unwrap(raw.waves, 'waves'), 'id') ?? {}, waves: asMap(unwrap(raw.waves, 'waves'), 'id') ?? {},
backups: raw.backups && typeof raw.backups === 'object' && raw.backups.units ? raw.backups : null,
}; };
this.fallback = fallback; this.fallback = fallback;
this._chess = new Map(); this._chess = new Map();
@@ -534,13 +544,22 @@ export class DataSource {
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? null; } rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? null; }
rawStage(id) { return this.raw.stages[id] ?? this.fallback?.rawStage(id) ?? null; } rawStage(id) { return this.raw.stages[id] ?? this.fallback?.rawStage(id) ?? null; }
rawWave(id) { return this.raw.waves[id] ?? this.fallback?.rawWave(id) ?? null; } rawWave(id) { return this.raw.waves[id] ?? this.fallback?.rawWave(id) ?? null; }
/** data/backups.json (`{ units, diy }`) of this source or its fallback, or null. */
rawBackups() { return this.raw.backups ?? (typeof this.fallback?.rawBackups === 'function' ? this.fallback.rawBackups() : null); }
/** /**
* Chess def for the default loadout, or for `loadout` `{ skillIndex, moduleId }` (DESIGN §16; resolved with * Chess def for the default loadout, or for `loadout` `{ skillIndex, moduleId }` (DESIGN §16; resolved with
* resolveLoadout — illegal choices fall back to the default). `def.loadout` = the resolved loadout (null for records * resolveLoadout — illegal choices fall back to the default). `def.loadout` = the resolved loadout (null for records
* without skill choices); `def.id` is always the chess id. Defs are cached per (id, resolved loadout). * without skill choices); `def.id` is always the chess id. Defs are cached per (id, resolved loadout).
* `loadout.standIn === true`: the 补位 def of the chess (getStandIn) — its skill and module are the chess's backup
* selection whatever `skillIndex` / `moduleId` say; a chess without a stand-in (PRESET, DIY, no backups data) gives
* its own def for that loadout.
*/ */
getChess(id, loadout = null) { getChess(id, loadout = null) {
if (loadout && loadout.standIn === true) {
const sd = this.getStandIn(id);
if (sd) return sd;
}
if (loadout == null && this._chess.has(id)) return this._chess.get(id); if (loadout == null && this._chess.has(id)) return this._chess.get(id);
const r = this.rawChess(id); const r = this.rawChess(id);
const lo = r ? resolveLoadout(r, loadout) : null; const lo = r ? resolveLoadout(r, loadout) : null;
@@ -555,6 +574,27 @@ export class DataSource {
this._chess.set(key, d); this._chess.set(key, d);
return d; return d;
} }
/**
* The 补位 def of chess `id` (DATA.md §18): shared/standIn.js standInRecord of its record over rawBackups(), normalised
* — `def.id` / `baseId` / `golden` / `tier` / `bonds` / `raw.garrisonIds` of the chess, every combat field of the
* stand-in at the chess's status, `def.charId` the stand-in's, `def.standInFor` the replaced operator's charId,
* `def.loadout` the backup selection (`isDefault`, `standIn: true`). Null for a chess without a stand-in (PRESET,
* DIY) or when the data lacks it. Cached per id.
*/
getStandIn(id) {
const key = `${id}|standIn`;
if (this._chess.has(key)) return this._chess.get(key);
const r = this.rawChess(id);
const rec = r ? standInRecord(r, this.rawBackups()) : null;
let d = null;
if (rec) {
d = normalizeChess({ ...rec, chessId: id });
d.loadout = { ...resolveLoadout(rec, null), standIn: true };
freezeDef(d);
}
this._chess.set(key, d);
return d;
}
getEnemy(key) { getEnemy(key) {
if (this._enemy.has(key)) return this._enemy.get(key); if (this._enemy.has(key)) return this._enemy.get(key);
const r = this.rawEnemy(key); const r = this.rawEnemy(key);
@@ -691,7 +731,8 @@ export function toDataSource(data) {
* lookups (`view.loadoutConflicts` lists them) — callers that pass the unit's own loadout (`getChess(id, { skillIndex, * lookups (`view.loadoutConflicts` lists them) — callers that pass the unit's own loadout (`getChess(id, { skillIndex,
* moduleId })`, `getToken(id, owner, ownerDef.loadout)`) are always exact. Returns `ds` itself when no unit carries a * moduleId })`, `getToken(id, owner, ownerDef.loadout)`) are always exact. Returns `ds` itself when no unit carries a
* loadout, and an existing view unchanged (`isLoadoutView`). Used by spec.js createBattleFromSpec and by * loadout, and an existing view unchanged (`isLoadoutView`). Used by spec.js createBattleFromSpec and by
* content/index.js installContent (every Battle, however constructed). * content/index.js installContent (every Battle, however constructed). An entry with `standIn: true` (补位) maps its
* chess id to the stand-in def (`getChess(id, { standIn: true })`) the same way.
* @param {object} ds DataSource * @param {object} ds DataSource
* @param {object[]} players PlayerBattleInput[] (spec players) * @param {object[]} players PlayerBattleInput[] (spec players)
*/ */
@@ -703,11 +744,12 @@ export function withUnitLoadouts(ds, players) {
for (const p of Array.isArray(players) ? players : []) { for (const p of Array.isArray(players) ? players : []) {
for (const u of (p && Array.isArray(p.units) ? p.units : [])) { for (const u of (p && Array.isArray(p.units) ? p.units : [])) {
if (!u || u.kind === 'token' || typeof u.chessId !== 'string') continue; if (!u || u.kind === 'token' || typeof u.chessId !== 'string') continue;
if (u.skillIndex != null || u.moduleId != null) any = true; if (u.skillIndex != null || u.moduleId != null || u.standIn === true) any = true;
const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null }; const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null };
if (u.standIn === true) lo.standIn = true;
const prev = map.get(u.chessId); const prev = map.get(u.chessId);
if (!prev) map.set(u.chessId, lo); if (!prev) map.set(u.chessId, lo);
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId) conflicts.add(u.chessId); else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId || !!prev.standIn !== !!lo.standIn) conflicts.add(u.chessId);
} }
} }
if (!any) return ds; if (!any) return ds;
+5 -1
View File
@@ -2,7 +2,8 @@
// //
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
// form?, skillIndex?, moduleId?, items? } (form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm: // form?, skillIndex?, moduleId?, items?, standInFor? } (standInFor = the replaced operator's charId of a 补位 stand-in;
// form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm:
// 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built // 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built
// after the change, a field opened mid-battle, draws it: render/units.js FORMS) // after the change, a field opened mid-battle, draws it: render/units.js FORMS)
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). // dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
@@ -51,6 +52,9 @@ export function unitInfo(u) {
// an ally operator's equipped item ids (display: a 变形同构体 wearer counts for the bond it grants — the bond popup's // an ally operator's equipped item ids (display: a 变形同构体 wearer counts for the bond it grants — the bond popup's
// member list and the detail card's bond chips of a teammate's unit) // member list and the detail card's bond chips of a teammate's unit)
items: u.side === 'ally' && u.kind === 'op' && Array.isArray(u.items) && u.items.length ? [...u.items] : undefined, items: u.side === 'ally' && u.kind === 'op' && Array.isArray(u.items) && u.items.length ? [...u.items] : undefined,
// 补位: the replaced operator's charId when the chess fights as its stand-in (spine / avatar are the stand-in's; the
// detail card composes the stand-in record, shared/standIn.js standInRecord)
standInFor: u.side === 'ally' && u.kind === 'op' && typeof d.standInFor === 'string' ? d.standInFor : undefined,
}; };
} }
+8 -2
View File
@@ -20,6 +20,8 @@
// the default). createBattleFromSpec hands the Battle a per-battle data view (withUnitLoadouts) that resolves each // the default). createBattleFromSpec hands the Battle a per-battle data view (withUnitLoadouts) that resolves each
// operator def — and its summons — for the loadout of its chess (simdata getChess(id, loadout) / getToken(id, owner, // operator def — and its summons — for the loadout of its chess (simdata getChess(id, loadout) / getToken(id, owner,
// ownerLoadout)); an explicit loadout argument always wins over the view's per-chess lookup. // ownerLoadout)); an explicit loadout argument always wins over the view's per-chess lookup.
// 补位 (DATA.md §18): an operator entry with `standIn: true` (kept only when exactly `true`) is fielded as its chess's
// stand-in (simdata getChess(id, { standIn: true }); its skill / module are the chess's backup selection).
import { Battle } from './Battle.js'; import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js'; import { toDataSource, withUnitLoadouts } from './simdata.js';
@@ -76,11 +78,15 @@ export function buildBattleSpec(o = {}) {
const LOADOUT_ID = /^[A-Za-z0-9_\-]{1,64}$/; const LOADOUT_ID = /^[A-Za-z0-9_\-]{1,64}$/;
/** Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality). Mutates `u`. */ /**
* Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality) and `standIn` only
* when exactly `true` (补位). Mutates `u`.
*/
export function sanitizeUnitLoadout(u) { export function sanitizeUnitLoadout(u) {
if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex; if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex;
if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId; if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId;
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; } if ('standIn' in u && u.standIn !== true) delete u.standIn;
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; }
return u; return u;
} }
+26 -9
View File
@@ -1,27 +1,44 @@
// The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js // 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 `<chessId>-<codename>.js` registers exactly the kit of `<chessId>_a`, whose // KIT_FILES or STANDIN_KIT_FILES and nothing else is; a file `<chessId>-<codename>.js` registers exactly the kit of
// data record has that charId code name; the registry is the tier groups in order, and content/index.js serves that // `<chessId>_a`, whose data record has that charId code name; a 补位 file `standin-<codename>.js` registers exactly the
// object; no kit file imports a Node-only or server-only module or reads the clock / Math.random (the sim is // kit of the stand-in character whose charId has that code name (data/backups.json `units`); the registry is the tier
// deterministic and also runs in the browser, /sim/). // groups in order, then the stand-ins, 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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { KIT_FILES, TIER_KITS, KITS } from '../../server/sim/content/kits/index.js'; import { KIT_FILES, TIER_KITS, KITS, STANDIN_KIT_FILES, STANDIN_KITS } from '../../server/sim/content/kits/index.js';
import { KITS as CONTENT_KITS } from '../../server/sim/content/index.js'; import { KITS as CONTENT_KITS } from '../../server/sim/content/index.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const KIT_DIR = join(ROOT, 'server/sim/content/kits'); const KIT_DIR = join(ROOT, 'server/sim/content/kits');
const CHESS = JSON.parse(readFileSync(join(ROOT, 'data/chess.json'), 'utf8')); const CHESS = JSON.parse(readFileSync(join(ROOT, 'data/chess.json'), 'utf8'));
const BACKUPS = JSON.parse(readFileSync(join(ROOT, 'data/backups.json'), 'utf8'));
const jsIn = (dir) => readdirSync(join(KIT_DIR, dir)).filter((f) => f.endsWith('.js')).sort(); 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', () => { test('kits/index.js lists every kit file of kits/ops/ exactly once, and only those', () => {
const listed = KIT_FILES.flat(); const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES];
assert.equal(new Set(listed).size, listed.length, 'a file listed twice'); assert.equal(new Set(listed).size, listed.length, 'a file listed twice');
assert.deepEqual([...listed].sort(), jsIn('ops')); assert.deepEqual([...listed].sort(), jsIn('ops'));
}); });
test('a stand-in kit file standin-<codename>.js registers the kit of that stand-in charId (data/backups.json units)', async () => {
const units = Object.keys(BACKUPS.units);
for (const file of STANDIN_KIT_FILES) {
const m = /^standin-([a-z0-9]+)\.js$/.exec(file);
assert.ok(m, `${file}: name`);
const charId = units.find((id) => id.replace(/^char_\d+_/, '') === m[1]);
assert.ok(charId, `${file}: no stand-in with the code name ${m[1]}`);
const mod = await import(`../../server/sim/content/kits/ops/${file}`);
assert.deepEqual(Object.keys(mod.default), [charId], `${file}: default export`);
assert.equal(typeof mod.default[charId], 'function', `${file}: kit builder`);
assert.equal(STANDIN_KITS[charId], mod.default[charId], `${file}: registered`);
assert.equal(KITS[charId], mod.default[charId], `${file}: in the merged registry`);
}
});
test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (codename: its charId without char_<n>_)', async () => { test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (codename: its charId without char_<n>_)', async () => {
for (const file of KIT_FILES.flat()) { for (const file of KIT_FILES.flat()) {
const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file); const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file);
@@ -37,9 +54,9 @@ test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (coden
} }
}); });
test('the registry is the tier groups in KIT_FILES order, one key per file; content/index.js serves the same object', () => { test('the registry is the tier groups in KIT_FILES order, then the stand-ins, one key per file; content/index.js serves the same object', () => {
assert.deepEqual(Object.keys(KITS), TIER_KITS.flatMap((t) => Object.keys(t))); assert.deepEqual(Object.keys(KITS), [...TIER_KITS.flatMap((t) => Object.keys(t)), ...Object.keys(STANDIN_KITS)]);
assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length, 'one key per file, none twice'); assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length + STANDIN_KIT_FILES.length, 'one key per file, none twice');
assert.equal(CONTENT_KITS, KITS); assert.equal(CONTENT_KITS, KITS);
}); });
+357
View File
@@ -0,0 +1,357 @@
// test/content/standin.test.js — 补位 stand-ins in the battle simulation (DATA.md §18; kits/README.md "Stand-in kits").
//
// The data layer: simdata getChess(id, { standIn: true }) / getStandIn — the chess's identity (ids, bonds, 特质, tier)
// with the stand-in's body, skill and module (shared/standIn.js standInRecord over data/backups.json) —, the per-battle
// view, the BattleSpec field `standIn`; the kit lookup by the stand-in's charId, never the replaced operator's kit
// (content/index.js kitOf); the harness option `units[].standIn`; and the eight 预备干员 (char_600–607) on the generic
// path, on every chess they replace (normal + elite): the stand-in body, the skill firing with its blackboard numbers,
// the unconditional stat talent (generic.js genericTalents), 一击即退 lasting its 10 s from every deployment (PRTS
// 预备干员-特种: 被动, 持续 10), 冲锋号令's DP at full SP (as 德克萨斯's kit casts the same skill) and 预备干员-重装 casting
// with an enemy in range (the owner's 重装 exception for the stand-ins, 2026-10-05; tools/build-data.mjs
// STANDIN_TRIGGER_DEVIATIONS).
// Run: node --test test/content/standin.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, standInRec, checkInvariants } from '../helpers/battleHarness.js';
import { DataSource, getDefaultSource, withUnitLoadouts } from '../../server/sim/simdata.js';
import { buildBattleSpec, createBattleFromSpec, sanitizeUnitLoadout } from '../../server/sim/spec.js';
import { KITS, kitOf, isStandInDef, skillSpecSource } from '../../server/sim/content/index.js';
import { statTalentMods } from '../../server/sim/content/generic.js';
import { unitInfo } from '../../server/sim/snapshot.js';
import { standInRecord, unitForm } from '../../shared/standIn.js';
import { bodyInKeys } from '../../server/sim/body.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const ds = getDefaultSource();
const RESERVES = ['char_600_cpione', 'char_601_cguard', 'char_602_cdfend', 'char_603_csnipe', 'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo', 'char_607_cspec'];
/** Every chess record (normal + elite) a 预备干员 stands in for. */
const RESERVE_RECORDS = Object.values(CHESS).filter((c) => c.chessType === 'NORMAL' && RESERVES.includes(c.backup.charId))
.sort((a, b) => a.chessId.localeCompare(b.chessId));
const DUMMY = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 1 }) };
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-6, `${msg}: ${a} vs ${b}`);
// ---------------------------------------------------------------------------------------------------------------
// data layer
test('data: every NORMAL chess record resolves to its stand-in def — the chess identity, the stand-in body and backup selection — cached and frozen; PRESET / DIY keep their own def', () => {
let n = 0;
for (const c of Object.values(CHESS)) {
const d = ds.getChess(c.chessId, { standIn: true });
if (c.chessType !== 'NORMAL') {
assert.equal(d, ds.getChess(c.chessId), `${c.chessId}: a ${c.chessType} chess fights as itself`);
assert.equal(d?.standInFor, undefined);
continue;
}
const r = standInRecord(c, BACKUPS);
const where = `${c.chessId} → ${c.backup.charId}`;
assert.deepEqual([d.id, d.baseId, d.golden, d.tier, d.charId, d.standInFor, d.name], [c.chessId, c.baseId, !!c.isGolden, c.tier, c.backup.charId, c.charId, r.name], where);
assert.deepEqual([d.bonds, d.raw.garrisonIds], [c.bonds, c.garrisonIds], `${where}: bonds / 特质 of the chess`);
for (const k of ['maxHp', 'atk', 'def', 'res', 'blockCnt', 'cost', 'respawnTime']) assert.equal(d.stats[k], r.stats[k], `${where}: ${k} of the stand-in`);
assert.deepEqual([d.profession, d.subProf, d.rangeGrid], [r.profession, r.subProfessionId, r.rangeGrid], `${where}: class and range of the stand-in`);
assert.deepEqual([d.skill.id, d.skill.index, d.skill.bb], [r.skill.skillId, c.backup.skillIndex, r.skill.bb], `${where}: the chess's backup skill`);
assert.equal(d.loadout.standIn, true);
assert.equal(d.loadout.moduleId ?? null, c.status.equipLevel > 0 ? (c.backup.uniEquipId ?? 'none') : null, `${where}: the backup module`);
assert.equal(!!d.raw.module?.active, !!(c.backup.uniEquipId && c.status.equipLevel > 0), `${where}: module active`);
assert.ok(Object.isFrozen(d) && Object.isFrozen(d.skill.bb), `${where}: frozen`);
assert.equal(ds.getChess(c.chessId, { standIn: true, skillIndex: 0, moduleId: 'none' }), d, `${where}: a stand-in's skill / module cannot be changed`);
const own = ds.getChess(c.chessId);
assert.ok(own !== d && own.charId === c.charId && own.standInFor === undefined, `${where}: the chess's own def is untouched`);
assert.ok(isStandInDef(d) && !isStandInDef(own));
n++;
}
assert.equal(n, 110);
});
test('data: a source without backups.json fields the chess itself (a browser that did not fetch it); with it, the stand-in', () => {
const rec = CHESS.chess_char_4_04_a;
const bare = new DataSource({ chess: { [rec.chessId]: rec } }, null);
assert.equal(bare.rawBackups(), null);
const d = bare.getChess(rec.chessId, { standIn: true });
assert.deepEqual([d.charId, d.standInFor], [rec.charId, undefined]);
const full = new DataSource({ chess: { [rec.chessId]: rec }, backups: BACKUPS }, null);
assert.deepEqual([full.getChess(rec.chessId, { standIn: true }).charId, full.getChess(rec.chessId, { standIn: true }).standInFor], ['char_607_cspec', rec.charId]);
// a fallback chain finds the default source's backups (test DataSources over getDefaultSource)
assert.equal(new DataSource({}, ds).rawBackups(), ds.rawBackups());
});
test('spec and view: buildBattleSpec keeps standIn only when exactly true (never on a token); createBattleFromSpec fields the stand-in; the view maps id-only lookups', () => {
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: true }), { chessId: 'x', standIn: true });
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: 'true' }), { chessId: 'x' });
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: 1 }), { chessId: 'x' });
assert.deepEqual(sanitizeUnitLoadout({ kind: 'token', tokenId: 't', standIn: true }), { kind: 'token', tokenId: 't' });
const spec = buildBattleSpec({
battleId: 'standin', fieldId: 'f', kind: 'normal', seed: 9, modeId: 'mode_multi_normal', round: 3, stageId: 'act2autochess_m01', timeLimit: 30,
players: [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null, playerEffects: [],
units: [
{ uid: 1, kind: 'chess', chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true },
{ uid: 2, kind: 'chess', chessId: 'chess_char_4_07_a', row: 11, col: 4, standIn: 'yes' },
],
}],
spawns: [], routes: [],
});
assert.deepEqual(spec.players[0].units.map((u) => u.standIn), [true, undefined]);
const b = createBattleFromSpec(spec, ds);
b.step();
const [u1, u2] = [1, 2].map((uid) => b.allyUnits.find((u) => u.uid === uid));
assert.deepEqual([u1.def.charId, u1.def.standInFor, u1.defId], ['char_607_cspec', CHESS.chess_char_4_04_a.charId, 'chess_char_4_04_a']);
assert.deepEqual([u2.def.charId, u2.def.standInFor], [CHESS.chess_char_4_07_a.charId, undefined]);
assert.equal(b.data.getChess('chess_char_4_04_a'), u1.def, 'the per-battle view: an id-only lookup is the stand-in');
assert.deepEqual(b.data.loadoutConflicts, []);
assert.equal(b.errors.length, 0);
// two players of one field: one fields the chess as its stand-in, the other as itself — the first wins id-only lookups
const view = withUnitLoadouts(ds, [
{ units: [{ chessId: 'chess_char_4_04_a', standIn: true }] },
{ units: [{ chessId: 'chess_char_4_04_a' }] },
]);
assert.deepEqual(view.loadoutConflicts, ['chess_char_4_04_a']);
assert.equal(view.getChess('chess_char_4_04_a').charId, 'char_607_cspec');
assert.equal(view.getChess('chess_char_4_04_a', {}).charId, CHESS.chess_char_4_04_a.charId, 'an explicit loadout is exact');
});
// ---------------------------------------------------------------------------------------------------------------
// kit lookup
test('kits: a stand-in finds its kit by its charId only — never the kit of the operator it replaces (kitOf, skillSpecSource)', () => {
const sd = ds.getChess('chess_char_4_04_a', { standIn: true });
const od = ds.getChess('chess_char_4_04_a');
const fChess = () => ({}), fStand = () => ({});
assert.equal(typeof KITS.chess_char_4_04_a, 'function', '伊内丝 has a kit of her own');
assert.equal(kitOf(sd, { chess_char_4_04_a: fChess, chess_char_4_04: fChess }), undefined);
assert.equal(kitOf(sd, { chess_char_4_04_a: fChess, char_607_cspec: fStand }), fStand);
assert.equal(kitOf(od, { chess_char_4_04_a: fChess, char_607_cspec: fStand }), fChess);
assert.equal(kitOf(ds.getChess('chess_char_4_04_b'), { chess_char_4_04_a: fChess }), fChess, 'the elite finds the base kit as before');
assert.equal(kitOf(sd, KITS), undefined, 'no stand-in kit is registered for 预备干员-特种');
assert.equal(skillSpecSource(sd), 'none');
assert.equal(skillSpecSource(od), 'kit');
assert.equal(skillSpecSource(sd, { char_607_cspec: () => ({ skills: { 'skcom_born_up[2]': { kind: 'passive' } } }) }), 'skills');
assert.equal(skillSpecSource(sd, { char_607_cspec: () => ({ skill: { kind: 'passive' } }) }), 'generic', 'a stand-in has no default skill');
});
test('kits in battle: injected kits keyed by the chess id reach the operator only, keyed by the charId the stand-in only; without one the stand-in runs the generic kit', () => {
const calls = [];
const spy = (tag) => (bb, chess, def) => { calls.push([tag, def.charId]); return { skill: null, talents: [] }; };
const h = makeBattle({
units: [{ chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true }, { chessId: 'chess_char_4_04_b', row: 11, col: 4 }],
kits: { chess_char_4_04_a: spy('chess'), char_607_cspec: spy('standIn') },
});
h.step();
assert.deepEqual(calls.sort(), [['chess', CHESS.chess_char_4_04_b.charId], ['standIn', 'char_607_cspec']]);
checkInvariants(h.b);
const g = makeBattle({ units: [{ chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true }, { chessId: 'chess_char_4_04_b', row: 11, col: 4 }] });
g.step();
const [s, o] = [g.unit(1), g.unit(2)];
assert.equal(s.kit.generic, true, 'the stand-in: generic kit');
assert.equal(s.kit.talents.length, 1, 'plus its one unconditional stat talent');
assert.ok(!o.kit.generic, 'the operator: her own kit');
assert.equal(g.b.errors.length, 0);
});
test('kits: a stand-in kit has no default skill — `skills[skillId]` is its spec, `skill` is never used (the generic spec instead)', () => {
const run = (kit) => {
const h = makeBattle({ units: [{ chessId: 'chess_char_4_07_a', row: 10, col: 4, standIn: true }], kits: { char_601_cguard: () => kit } });
h.step();
return h.unit(1).kit;
};
const k1 = run({ skill: { kind: 'duration', mods: { atkPct: 9 } }, talents: [] });
assert.equal(k1.skillSource, 'generic');
assert.deepEqual(k1.skill.mods, { atkPct: 0.35 }, '攻击力强化·β型 at Lv4: atk 0.35 from the blackboard');
const k2 = run({ skills: { 'skcom_atk_up[2]': { kind: 'duration', mods: { atkPct: 0.123 } } }, talents: [] });
assert.equal(k2.skillSource, 'skills');
assert.deepEqual(k2.skill.mods, { atkPct: 0.123 });
});
test('loadout sync keeps a stand-in whose entry also names a skill / module; UnitInfo carries standInFor for the client', () => {
const h = makeBattle({ units: [
{ chessId: 'chess_char_4_07_b', row: 10, col: 4, standIn: true, skillIndex: 0, moduleId: 'none' },
{ chessId: 'chess_char_4_04_a', row: 11, col: 4 },
] });
h.step();
const [s, o] = [h.unit(1), h.unit(2)];
assert.deepEqual([s.def.charId, s.skill.id], ['char_601_cguard', 'skcom_atk_up[2]']);
assert.equal(unitInfo(s).standInFor, CHESS.chess_char_4_07_b.charId);
assert.equal(unitInfo(s).spine, 'char_601_cguard', 'the stand-in model');
assert.equal(unitInfo(o).standInFor, undefined);
assert.equal(h.b.errors.length, 0);
});
test('harness: standIn: { skillIndex, moduleId } fields another skill / module of the stand-in (standInRec); a chess without a stand-in throws', () => {
const r = standInRec('chess_char_5_11_b', { skillIndex: 0 });
assert.deepEqual([r.charId, r.skill.skillId, r.module.id, r.module.active, r.standInFor], ['char_613_acmedc', 'skchr_acmedc_1', 'uniequip_002_acmedc', true, CHESS.chess_char_5_11_b.charId]);
assert.deepEqual(standInRec('chess_char_5_11_b'), standInRecord(CHESS.chess_char_5_11_b, BACKUPS), 'no selection: the chess backup');
const h = makeBattle({ units: [{ chessId: 'chess_char_5_11_b', row: 10, col: 4, standIn: { skillIndex: 0, moduleId: 'none' } }] });
h.step();
const u = h.unit(1);
assert.deepEqual([u.def.charId, u.skill.id, !!u.def.raw.module?.active, u.def.standInFor], ['char_613_acmedc', 'skchr_acmedc_1', false, CHESS.chess_char_5_11_b.charId]);
assert.equal(u.base.atk, unitForm(BACKUPS, 'char_613_acmedc', CHESS.chess_char_5_11_b.status).stats.atk, 'no module: the form ATK');
assert.throws(() => standInRec('chess_char_1_01_a'), /no 补位 stand-in/);
assert.throws(() => standInRec('chess_char_5_11_b', { skillIndex: 7 }), /has no skill/);
});
// ---------------------------------------------------------------------------------------------------------------
// the eight 预备干员 on the generic path
/** The skill buff mods the generic spec should give for a stat skill's blackboard (generic.js key map). */
function statMods(bb) {
const m = {};
if (bb.atk) m.atkPct = bb.atk;
if (bb.def) m.defPct = bb.def;
if (bb.attack_speed) m.aspd = bb.attack_speed;
return m;
}
test('the eight 预备干员: every chess record they replace fields the stand-in body; the skill fires with its blackboard numbers; the stat talent applies', () => {
const seen = new Set();
for (const c of RESERVE_RECORDS) {
const charId = c.backup.charId;
const form = unitForm(BACKUPS, charId, c.status);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
const where = `${c.chessId} ${c.name} → ${BACKUPS.units[charId].name} ${sk.name}`;
const medic = charId === 'char_605_cmedic';
const units = [{ chessId: c.chessId, row: 9, col: 5, dir: 'RIGHT', standIn: true, carryState: { sp: 999 } }];
if (medic) units.push({ chessId: 'chess_char_1_02_a', row: 10, col: 6 });
const h = makeBattle({
defs: { enemies: DUMMY }, units, timeLimit: 60, autoFinish: false, flags: { dpPerSec: 0 },
enemies: medic ? [] : [{ key: 'enemy_dummy', route: 0, time: 0 }], hooks: ['skillStart'],
});
const u = h.unit(1);
if (medic) { h.step(); const a = h.unit(2); h.b.loseHp(a, a.hp * 0.6); }
// the stand-in body and the generic kit, never the replaced operator's
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, u.kit.generic], [charId, c.charId, sk.skillId, true], where);
for (const k of ['maxHp', 'atk', 'def']) assert.equal(u.base[k], form.stats[k], `${where}: base ${k}`);
// the unconditional stat talent (攻击力+8% / 防御力+10% / 攻击速度+9), as a persistent talent buff
const talent = form.talents[0];
const tMods = statTalentMods({ description: talent.desc, bb: talent.bb });
assert.ok(tMods, `${where}: ${talent.desc} is a plain stat talent`);
assert.deepEqual(u.findBuff('talent:generic:0')?.mods, tMods, `${where}: talent ${talent.name}`);
const dp0 = h.b.players[0].dp;
if (sk.skillType === 'PASSIVE') {
h.step();
assert.deepEqual(u.findBuff(`generic:passive:${sk.skillId}`)?.mods, statMods(sk.bb), `${where}: deploy buff`);
} else {
assert.ok(h.runUntil(() => u.skill.activations > 0, 30), `${where}: the skill fires`);
h.step();
if (sk.prefabId === 'skcom_charge_cost') {
assert.equal(u.skill.rule, 'SP_FULL', `${where}: AUTO, nothing to wait for`);
assert.equal(h.b.players[0].dp, dp0 + sk.bb.cost, `${where}: +${sk.bb.cost} DP`);
} else {
const buff = u.findBuff(`skill:${u.id}`);
assert.deepEqual(buff?.mods, statMods(sk.bb), `${where}: skill buff from the blackboard`);
assert.equal(u.skill.kind, 'duration', where);
assert.equal(u.skill.duration, sk.duration, `${where}: ${sk.duration} s`);
// the stat the skill raises, exactly: base × (1 + talent + skill) or base + talent + skill
if (sk.bb.atk) close(u.s.atk, form.stats.atk * (1 + (tMods.atkPct ?? 0) + sk.bb.atk), `${where}: ATK`);
if (sk.bb.def) close(u.s.def, form.stats.def * (1 + (tMods.defPct ?? 0) + sk.bb.def), `${where}: DEF`);
if (sk.bb.attack_speed) assert.equal(u.s.aspd, form.stats.aspd + (tMods.aspd ?? 0) + sk.bb.attack_speed, `${where}: ASPD`);
}
}
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, `${where}: ${JSON.stringify(h.b.errors[0])}`);
seen.add(charId);
}
assert.equal(RESERVE_RECORDS.length, 28);
assert.deepEqual([...seen].sort(), RESERVES);
});
test('一击即退 (预备干员-特种 for 伊内丝): ATK / DEF up for exactly its 10 s from every deployment, normal and elite', () => {
for (const id of ['chess_char_4_04_a', 'chess_char_4_04_b']) {
const c = CHESS[id];
const form = unitForm(BACKUPS, 'char_607_cspec', c.status);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
assert.deepEqual([sk.skillId, sk.skillType, sk.duration], ['skcom_born_up[2]', 'PASSIVE', 10]);
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], timeLimit: 200, autoFinish: false });
const u = h.unit(1);
const on = () => { close(u.s.atk, form.stats.atk * (1.08 + sk.bb.atk), `${id} ATK on`); close(u.s.def, form.stats.def * (1 + sk.bb.def), `${id} DEF on`); };
const off = () => { close(u.s.atk, form.stats.atk * 1.08, `${id} ATK off`); close(u.s.def, form.stats.def, `${id} DEF off`); };
h.run(9.9);
on();
h.run(0.2);
off();
// knocked out and redeployed (再部署时间大幅度减少): the buff starts again with the new deployment
h.b.kill(u);
assert.ok(h.runUntil(() => u.alive && u.deployed, 60), `${id}: redeployed`);
const back = h.b.time;
h.step();
on();
h.runUntil(() => h.b.time >= back + 9.9, 20);
on();
h.run(0.2);
off();
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
}
});
test('冲锋号令·γ型 (预备干员-先锋 for 圣约送葬人): +12 DP as soon as the SP is full — no enemy needed — then again after each refill', () => {
for (const id of ['chess_char_5_01_a', 'chess_char_5_01_b']) {
const c = CHESS[id];
const sk = unitForm(BACKUPS, 'char_600_cpione', c.status).skills.find((s) => s.index === c.backup.skillIndex);
assert.deepEqual([sk.skillId, sk.skillType, sk.bb.cost], ['skcom_charge_cost[3]', 'AUTO', 12]);
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], timeLimit: 200, autoFinish: false, flags: { dpPerSec: 0 }, hooks: ['skillStart'] });
const u = h.unit(1);
const dp = () => h.b.players[0].dp;
h.step();
const dp0 = dp();
// initial SP initSp, then +1/s: the first cast when the SP reaches the cost (41 − 12 s normal, 38 − 14 s elite)
assert.ok(h.runUntil(() => u.skill.activations === 1, 60));
close(Math.round(h.b.time), sk.spCost - sk.initSp, `${id}: first cast at full SP`);
h.step();
assert.equal(dp(), dp0 + 12, `${id}: +12 DP`);
assert.ok(h.runUntil(() => u.skill.activations === 2, 60));
h.step();
assert.equal(dp(), dp0 + 24, `${id}: +12 DP again`);
assert.equal(h.hooksOf('skillStart').length, 2);
assert.equal(h.b.enemies.length, 0, 'no enemy at all');
checkInvariants(h.b);
}
});
/**
* A real battle (the 重装 trigger check of test/sim/feedback1-tank-triggers.test.js): the stand-in of `chessId` on
* (9,5) of 战场#01(下半) facing the gate with its SP full; one 萨卡兹枯朽前锋 (melee) walks the lane from 3 s. Returns the
* first cast (time, reason, an enemy on its range or blocked then) and the time the unit first took damage.
*/
function firstCast(chessId) {
const h = makeBattle({
stageId: 'act2autochess_m01', seed: 3, timeLimit: 120, autoFinish: false,
units: [{ chessId, row: 9, col: 5, dir: 'RIGHT', standIn: true, carryState: { sp: 999 } }],
enemies: [{ key: 'enemy_1422_lrsldr', route: 0, time: 3 }],
});
const u = h.unit(chessId);
const out = { rule: u.skill.rule, cast: null, reason: null, enemyInRange: false, blocking: false, hurt: null };
h.b.on('damaged', (ctx) => { if (ctx.target === u && out.hurt == null) out.hurt = h.b.time; }, { priority: 1000 });
h.b.on('skillStart', (ctx) => {
if (ctx.unit !== u || out.cast != null) return;
out.cast = h.b.time;
out.reason = ctx.reason;
const keys = new Set(u.baseRangeKeys || u.rangeKeys);
out.enemyInRange = h.b.enemies.some((e) => e.alive && e.deployed && bodyInKeys(e, keys));
out.blocking = u.blocking.length > 0;
}, { priority: 1000 });
h.runUntil(() => out.cast != null && out.hurt != null, 40);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
return { ...out, charId: u.def.charId, skill: u.skill.id, trigger: u.def.skill.trigger };
}
test('预备干员-重装 (for 泥岩 S2, 乌尔比安 S3): casts with an enemy in range before anything hits it — the owner\'s 重装 exception, rawRule TAKE_DAMAGE', () => {
for (const id of ['chess_char_4_18_a', 'chess_char_4_18_b', 'chess_char_5_05_a', 'chess_char_5_05_b']) {
const r = firstCast(id);
const where = `${id}: ${JSON.stringify(r)}`;
assert.equal(r.charId, 'char_602_cdfend', where);
assert.deepEqual(r.trigger, { rule: 'DEFAULT', grid: null }, where);
assert.equal(r.rule, 'DEFAULT', where);
assert.ok(r.cast != null && r.cast >= 3, `cast once the enemy is on the field — ${where}`);
assert.equal(r.reason, 'DEFAULT', where);
assert.ok(r.enemyInRange || r.blocking, `an enemy on its tile at the cast — ${where}`);
assert.ok(r.hurt == null || r.hurt > r.cast, `cast before the first hit — ${where}`);
}
// the data keeps the official row as rawRule, on every skill of both 重装 stand-ins (Mechanist's kit reads it as is)
for (const charId of ['char_602_cdfend', 'char_610_acfend']) {
for (const f of Object.values(BACKUPS.units[charId].forms)) for (const s of f.skills) assert.deepEqual([s.trigger.rule, s.trigger.rawRule], ['DEFAULT', 'TAKE_DAMAGE'], `${charId} ${s.skillId}`);
}
});
+49 -5
View File
@@ -11,15 +11,21 @@
// stage explicit stage object (overrides stageId) // stage explicit stage object (overrides stageId)
// kind 'normal' | 'unite' | 'boss' | 'hidden' (default 'normal') // kind 'normal' | 'unite' | 'boss' | 'hidden' (default 'normal')
// seed uint32 (default 1) // seed uint32 (default 1)
// units [{ chessId, row, col, items?, uid?, carryState?, abs? } | { kind:'token', tokenId, row, col }] // units [{ chessId, row, col, items?, uid?, carryState?, abs?, standIn? } | { kind:'token', tokenId, row, col }]
// board coordinates (rows 9–12, cols 2–10) for player 'p1' // board coordinates (rows 9–12, cols 2–10) for player 'p1'. `standIn: true` fields a NORMAL chess as its
// 补位 stand-in (data/backups.json through simdata getChess(id, { standIn: true }) — the production
// path: the chess's ids / bonds / 特质 / tier, the stand-in's body, its kit by charId); the `_a` id is the
// normal form, `_b` the elite, skill / module are the chess's backup selection. `standIn: { skillIndex?,
// moduleId? }` fields it with another of the stand-in's skills / modules (a kit test of a skill no chess
// names): the composed record (standInRec) replaces that chess id's record for the whole battle
// players full PlayerBattleInput[] (overrides `units`) // players full PlayerBattleInput[] (overrides `units`)
// enemies [{ key, time=0, route=0 | RouteSpec, pos?, count?, interval?, mods?, tag?, bounty?, sourcePlayerId? }] // enemies [{ key, time=0, route=0 | RouteSpec, pos?, count?, interval?, mods?, tag?, bounty?, sourcePlayerId? }]
// waveTemplate wave id (data/waves.json) or template object → routes + spawns + timeLimit // waveTemplate wave id (data/waves.json) or template object → routes + spawns + timeLimit
// routes RouteSpec[] (default: template routes, or flat-stage routes 0..3 = walk low, walk high, fly low, fly high) // routes RouteSpec[] (default: template routes, or flat-stage routes 0..3 = walk low, walk high, fly low, fly high)
// timeLimit seconds (default: template maxPlayTime, else 60; boss/hidden: Infinity) // timeLimit seconds (default: template maxPlayTime, else 60; boss/hidden: Infinity)
// content 'full' | 'generic' | 'none' (default 'full') // content 'full' | 'generic' | 'none' (default 'full')
// kits { [baseChessId]: (bb, chess, def) => Kit } injected kits (take precedence over kits/index.js) // kits { [baseChessId | standInCharId]: (bb, chess, def) => Kit } injected kits (take precedence over
// kits/index.js; a stand-in's kit is keyed by its charId only)
// extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests) // extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests)
// defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records // defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true). The harness sets // flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true). The harness sets
@@ -36,6 +42,7 @@
import { Battle } from '../../server/sim/Battle.js'; import { Battle } from '../../server/sim/Battle.js';
import { DataSource, getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js'; import { DataSource, getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
import { TICK } from '../../server/sim/constants.js'; import { TICK } from '../../server/sim/constants.js';
import { standInRecord, composeUnitRecord, unitForm } from '../../shared/standIn.js';
export const ALL_HOOKS = Object.freeze([ export const ALL_HOOKS = Object.freeze([
'battleStart', 'deploy', 'tick', 'beforeAttack', 'attack', 'hit', 'damaged', 'heal', 'kill', 'death', 'battleStart', 'deploy', 'tick', 'beforeAttack', 'attack', 'hit', 'damaged', 'heal', 'kill', 'death',
@@ -141,15 +148,52 @@ export function enemyRec(o = {}) {
}; };
} }
/**
* The 补位 record of a NORMAL chess (data/chess.json `_a` normal / `_b` elite) fielded as its stand-in — shared/standIn.js
* standInRecord: the chess's backup skill and module — or, with `skillIndex` / `moduleId` (null / 'none' = no module),
* another selection of the same stand-in at the chess's status. Throws when the chess has no stand-in (PRESET, DIY) or
* the stand-in lacks that skill / module.
* @param {string} chessId
* @param {{ skillIndex?: number, moduleId?: string|null }} [sel]
* @param {object} [data] DataSource (default: the sim's default source)
*/
export function standInRec(chessId, sel = {}, data = getDefaultSource()) {
const chess = data.rawChess(chessId);
const backups = typeof data.rawBackups === 'function' ? data.rawBackups() : null;
const b = chess?.backup;
if (!b || !backups || chess.chessType !== 'NORMAL') throw new Error(`${chessId}: no 补位 stand-in (PRESET / DIY chess, or no backups.json)`);
const custom = sel && (sel.skillIndex != null || sel.moduleId !== undefined);
const rec = !custom ? standInRecord(chess, backups) : composeUnitRecord(chess, backups.units?.[b.charId], unitForm(backups, b.charId, chess.status), {
skillIndex: sel.skillIndex ?? b.skillIndex,
moduleId: sel.moduleId === undefined ? b.uniEquipId ?? null : (sel.moduleId === 'none' ? null : sel.moduleId),
standInFor: chess.charId,
});
if (!rec || !rec.skill) throw new Error(`${chessId}: stand-in ${b.charId} has no skill ${sel?.skillIndex ?? b.skillIndex}`);
const mod = sel?.moduleId;
if (custom && mod && mod !== 'none' && !rec.module?.active) throw new Error(`${chessId}: stand-in ${b.charId} has no module ${mod} at ${chessId}`);
return rec;
}
function buildData(defs) { function buildData(defs) {
if (!defs) return getDefaultSource(); if (!defs) return getDefaultSource();
return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource()); return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource());
} }
/** `units[].standIn` objects → the composed records for `defs.chess` (null when there are none). */
function standInDefs(units) {
let out = null;
for (const u of units ?? []) {
if (!u || !u.standIn || typeof u.standIn !== 'object') continue;
(out ??= {})[u.chessId] = standInRec(u.chessId, u.standIn);
}
return out;
}
/** Create a battle + harness. See header for options. */ /** Create a battle + harness. See header for options. */
export function makeBattle(opts = {}) { export function makeBattle(opts = {}) {
const kind = opts.kind ?? 'normal'; const kind = opts.kind ?? 'normal';
const data = opts.data ?? buildData(opts.defs); const custom = opts.data ? null : standInDefs(opts.units);
const data = opts.data ?? buildData(custom ? { ...opts.defs, chess: { ...(opts.defs?.chess ?? {}), ...custom } } : opts.defs);
let stage = opts.stage ?? null; let stage = opts.stage ?? null;
if (!stage) stage = !opts.stageId || opts.stageId === 'flat' ? flatStage(opts.flat || {}) : data.getStage(opts.stageId); if (!stage) stage = !opts.stageId || opts.stageId === 'flat' ? flatStage(opts.flat || {}) : data.getStage(opts.stageId);
if (!stage) throw new Error(`unknown stage ${opts.stageId}`); if (!stage) throw new Error(`unknown stage ${opts.stageId}`);
@@ -181,7 +225,7 @@ export function makeBattle(opts = {}) {
if (extraRoutes.length) routes = routes.concat(extraRoutes); if (extraRoutes.length) routes = routes.concat(extraRoutes);
const players = opts.players ?? [{ const players = opts.players ?? [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, playerId: 'p1', seat: 0, side: 'L', colOffset: 0,
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...u })), units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...u, ...(u.standIn ? { standIn: true } : null) })),
bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [], bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [],
}]; }];
const hookNames = opts.hooks ?? ALL_HOOKS; const hookNames = opts.hooks ?? ALL_HOOKS;
+1 -1
View File
@@ -44,7 +44,7 @@ const NODE_URL = 'https://nodejs.org/zh-cn/download';
/** Data files the server expects (server/data.js DATA_FILES) + the emote catalogue used by the client. */ /** Data files the server expects (server/data.js DATA_FILES) + the emote catalogue used by the client. */
export const DATA_FILES = ['config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices', export const DATA_FILES = ['config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices',
'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', 'emotes']; 'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', 'backups', 'emotes'];
/** Runtime packages that must be installed (package.json dependencies). */ /** Runtime packages that must be installed (package.json dependencies). */
export const RUNTIME_PACKAGES = ['ws', 'pixi.js', 'pixi-spine', 'preact', 'htm']; export const RUNTIME_PACKAGES = ['ws', 'pixi.js', 'pixi-spine', 'preact', 'htm'];
/** Vendor files the client cannot run without (tools/vendor.mjs; three.js is optional there). */ /** Vendor files the client cannot run without (tools/vendor.mjs; three.js is optional there). */