From ed2b679f8389db2dea71cd03efeed74efda43b17 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:44:58 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20WD:=20the=20sim=20fields=20a=20?= =?UTF-8?q?=E8=A1=A5=E4=BD=8D=20stand-in=20=E2=80=94=20getChess(id,=20{=20?= =?UTF-8?q?standIn:=20true=20})=20over=20data/backups.json,=20PlayerBattle?= =?UTF-8?q?Input=20`standIn:=20true`,=20kits=20by=20the=20stand-in's=20cha?= =?UTF-8?q?rId=20(never=20the=20replaced=20operator's),=20kits/ops/standin?= =?UTF-8?q?-.js=20registry,=20harness=20`standIn`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/DATA.md | 13 +- docs/SIM.md | 28 ++- server/data.js | 4 +- server/sim/battle/players.js | 7 +- server/sim/content/index.js | 72 ++++-- server/sim/content/kits/README.md | 55 ++++- server/sim/content/kits/index.js | 21 +- server/sim/simdata.js | 50 ++++- server/sim/snapshot.js | 6 +- server/sim/spec.js | 10 +- test/content/kits_layout.test.js | 35 ++- test/content/standin.test.js | 357 ++++++++++++++++++++++++++++++ test/helpers/battleHarness.js | 54 ++++- tools/setup.mjs | 2 +- 14 files changed, 656 insertions(+), 58 deletions(-) create mode 100644 test/content/standin.test.js diff --git a/docs/DATA.md b/docs/DATA.md index 1f0d0abb..7e4d553a 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -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 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 -buildChess that the stand-ins would miss stops the build. No trigger deviation (§2.2, DESIGN §21.29) applies to a form — -the deviations name chess, so the 重装 stand-ins (预备干员-重装, Mechanist) keep the official `TAKE_DAMAGE` row. +buildChess that the stand-ins would miss stops the build. The chess trigger deviations (§2.2, DESIGN §21.29) name chess +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 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 补位 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 -backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DIY chess. **Kits are keyed by base chess -id** (`server/sim/content/index.js setupUnitKit`): a stand-in keeps the chess's ids, so its kit must be found by its -`charId`. `diyRecord(slot, charId, backups, { skillIndex, moduleId })` fills a DIY slot with a prototype (the slot's +backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DIY chess. **In battle** a PlayerBattleInput +entry with `standIn: true` is fielded as `getChess(chessId, { standIn: true })` (docs/SIM.md §12: this record, normalised), +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. `diy`: diff --git a/docs/SIM.md b/docs/SIM.md index 5b01b931..1160c89f 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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 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. +补位 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-.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 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 @@ -947,7 +952,12 @@ whose skill "恢复…友方/友军…生命" heals the most injured ally in ran 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 `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:` 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). ### 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; `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]`. -- `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) (`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); @@ -1218,7 +1228,10 @@ test/content/facing.test.js) or `players`; `enemies: [{ 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); -`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`, `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). @@ -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()`, `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, -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 `SIM_CHAOS_N=500 SIM_CHAOS_SEED= 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 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. +补位 (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 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`); diff --git a/server/data.js b/server/data.js index 11d32d60..7beccb56 100644 --- a/server/data.js +++ b/server/data.js @@ -2,7 +2,7 @@ // // 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, -// 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 // 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). */ export const DATA_FILES = Object.freeze([ 'config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices', - 'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', + 'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', 'backups', ]); /** diff --git a/server/sim/battle/players.js b/server/sim/battle/players.js index 1a29aa44..dec37825 100644 --- a/server/sim/battle/players.js +++ b/server/sim/battle/players.js @@ -102,8 +102,11 @@ export class BattlePlayers { return u; } // 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) - const def = this.data.getChess(inp.chessId, { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null }); + // choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups); `standIn: + // 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; } const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir }); u.items = [...(inp.items ?? [])]; diff --git a/server/sim/content/index.js b/server/sim/content/index.js index 69e59666..84570020 100644 --- a/server/sim/content/index.js +++ b/server/sim/content/index.js @@ -7,6 +7,10 @@ // 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. // 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-.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 // 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, @@ -19,7 +23,7 @@ // 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). -import { genericKit } from './generic.js'; +import { genericKit, genericTalents } from './generic.js'; import { withUnitLoadouts } from '../simdata.js'; // 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 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({}); /** 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]])); +/** + * 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). */ export function setupUnitKit(battle, unit, mode = 'full') { 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 {}; const injected = battle.opts && battle.opts.kits; 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 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); + const f = kitOf(def, injected) ?? (mode === 'full' ? kitOf(def, KITS) : undefined); if (typeof f === 'function') { try { const k = f(bb, raw, def); if (k) return selectSkillSpec(k, bb, raw, def); } 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. */ @@ -88,13 +120,15 @@ function inputEntry(unit) { /** * 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 - * 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) { const x = inputEntry(unit); if (!x) return null; - if (x.skillIndex == null && x.moduleId == null) return {}; - return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null }; + const standIn = x.standIn === true ? { standIn: true } : 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). */ @@ -143,13 +177,16 @@ export function syncUnitLoadout(battle, unit) { /** Id of the selected skill of an operator def (null without a skill). */ 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 generic spec. Returns the kit itself when nothing changes; `kit.skillSource` tells which one was used - * ('skills' | 'kit' | 'generic'). + * The kit with the skill spec of the SELECTED skill (see header): `kit.skills[id]` → `kit.skill` (default skill only; + * never for a stand-in) → the generic spec. Returns the kit itself when nothing changes; `kit.skillSource` tells which + * one was used ('skills' | 'kit' | 'generic'). * @param {object} kit hand-authored kit */ 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) */ export function skillSpecSource(def, kits = KITS) { - const bare = String(def?.baseId ?? def?.id ?? '').replace(/_[ab]$/, ''); - const f = kits?.[def?.baseId] ?? kits?.[def?.id] ?? kits?.[bare]; + const f = kitOf(def, kits); if (typeof f !== 'function') return 'none'; let k = null; try { k = f(def.skill?.bb ?? {}, def.raw ?? def, def); } catch { return 'generic'; } diff --git a/server/sim/content/kits/README.md b/server/sim/content/kits/README.md index 0ac4b7d1..3d7e4432 100644 --- a/server/sim/content/kits/README.md +++ b/server/sim/content/kits/README.md @@ -9,14 +9,17 @@ operators, the self-select 6★ operators, contributions from GitHub issue #136 | 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/-.js` | one kit per file, with the helpers and constants only that kit uses | +| `ops/standin-.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/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) | | `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor | `content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less -id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). +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 @@ -96,6 +99,36 @@ export default { 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. +## 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-.js`, the code name being the stand-in's charId without `char__` + (`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 - 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 `, then `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 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 allies or itself fires at full SP (引星棘刺 S1, `kits_alt_t5.test.js` "… fires as soon as its SP is full, no enemy needed (GitHub #124)"); the 重装 exception list (`TRIGGER_DEVIATIONS`: 深巡 S2, 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2, - 余 S2; the 预备干员-重装 / Mechanist stand-ins join it), DESIGN §21.29 / §22.10, + 余 S2; the 预备干员-重装 / Mechanist stand-ins: `STANDIN_TRIGGER_DEVIATIONS`, every skill), DESIGN §21.29 / §22.10, `test/sim/feedback1-tank-triggers.test.js`. - [ ] **6. Targeting** — can it hit air units (`canHitFly`), ground-only attacks, block count, target priority; can ground enemies hit it (起飞 / 对地规避, stealth, camouflage flags). Examples: `test/sim/professions.test.js` "fortress diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index d6eb9292..abd79935 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -8,6 +8,12 @@ // 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 // 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-.js`, one per stand-in character +// (codename = its charId without `char__`: `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([ // tier 1 @@ -53,6 +59,13 @@ export const KIT_FILES = Object.freeze([ 'chess_char_6_20-agoat2.js'], ].map((group) => Object.freeze(group))); +/** + * The 补位 stand-in kit files (`ops/standin-.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) { try { 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 STANDIN_MODULES = await Promise.all(STANDIN_KIT_FILES.map(loadKitFile)); 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. */ 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. */ -export const KITS = Object.freeze(Object.assign({}, ...TIER_KITS)); +/** The 补位 stand-in kits: stand-in charId → kit builder, in STANDIN_KIT_FILES order. */ +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)); diff --git a/server/sim/simdata.js b/server/sim/simdata.js index c7cfcd52..a8e467f6 100644 --- a/server/sim/simdata.js +++ b/server/sim/simdata.js @@ -16,6 +16,11 @@ // 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 // 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 // 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. import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js'; +import { standInRecord } from '../../shared/standIn.js'; import { normHitArea } from './body.js'; // --------------------------------------------------------------------------------------------------------------- @@ -193,6 +199,8 @@ export function normalizeChess(rec) { tokens: rec.tokens ?? [], spine: rec.assets?.spine ?? rec.spine ?? 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, }; } @@ -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 { constructor(raw = {}, fallback = null) { @@ -519,6 +528,7 @@ export class DataSource { tokens: asMap(unwrap(raw.tokens, 'tokens'), 'tokenId') ?? {}, stages: asMap(unwrap(raw.stages, 'stages'), '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._chess = new Map(); @@ -534,13 +544,22 @@ export class DataSource { rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(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; } + /** 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 * 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). + * `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) { + 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); const r = this.rawChess(id); const lo = r ? resolveLoadout(r, loadout) : null; @@ -555,6 +574,27 @@ export class DataSource { this._chess.set(key, 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) { if (this._enemy.has(key)) return this._enemy.get(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, * 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 - * 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[]} players PlayerBattleInput[] (spec players) */ @@ -703,11 +744,12 @@ export function withUnitLoadouts(ds, players) { for (const p of Array.isArray(players) ? players : []) { for (const u of (p && Array.isArray(p.units) ? p.units : [])) { 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 }; + if (u.standIn === true) lo.standIn = true; const prev = map.get(u.chessId); 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; diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index b53f6019..8d2a4052 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -2,7 +2,8 @@ // // 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?, -// 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 // 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). @@ -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 // 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, + // 补位: 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, }; } diff --git a/server/sim/spec.js b/server/sim/spec.js index a9702c0c..de41b66a 100644 --- a/server/sim/spec.js +++ b/server/sim/spec.js @@ -20,6 +20,8 @@ // 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, // 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 { toDataSource, withUnitLoadouts } from './simdata.js'; @@ -76,11 +78,15 @@ export function buildBattleSpec(o = {}) { 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) { 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 (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; } diff --git a/test/content/kits_layout.test.js b/test/content/kits_layout.test.js index 738bfeda..c9c27ac0 100644 --- a/test/content/kits_layout.test.js +++ b/test/content/kits_layout.test.js @@ -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 -// KIT_FILES and nothing else is; a file `-.js` registers exactly the kit of `_a`, whose -// data record has that charId code name; the registry is the tier groups in order, and content/index.js serves that -// object; no kit file imports a Node-only or server-only module or reads the clock / Math.random (the sim is -// deterministic and also runs in the browser, /sim/). +// KIT_FILES or STANDIN_KIT_FILES and nothing else is; a file `-.js` registers exactly the kit of +// `_a`, whose data record has that charId code name; a 补位 file `standin-.js` registers exactly the +// kit of the stand-in character whose charId has that code name (data/backups.json `units`); the registry is the tier +// 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 assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { KIT_FILES, TIER_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { 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'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); const KIT_DIR = join(ROOT, 'server/sim/content/kits'); const CHESS = JSON.parse(readFileSync(join(ROOT, 'data/chess.json'), 'utf8')); +const BACKUPS = JSON.parse(readFileSync(join(ROOT, 'data/backups.json'), 'utf8')); const jsIn = (dir) => readdirSync(join(KIT_DIR, dir)).filter((f) => f.endsWith('.js')).sort(); test('kits/index.js lists every kit file of kits/ops/ exactly once, and only those', () => { - const listed = KIT_FILES.flat(); + const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES]; assert.equal(new Set(listed).size, listed.length, 'a file listed twice'); assert.deepEqual([...listed].sort(), jsIn('ops')); }); +test('a stand-in kit file standin-.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 -.js registers the kit of _a (codename: its charId without char__)', async () => { for (const file of KIT_FILES.flat()) { const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file); @@ -37,9 +54,9 @@ test('a kit file -.js registers the kit of _a (coden } }); -test('the registry is the tier groups in KIT_FILES order, one key per file; content/index.js serves the same object', () => { - assert.deepEqual(Object.keys(KITS), TIER_KITS.flatMap((t) => Object.keys(t))); - assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length, 'one key per file, none twice'); +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)), ...Object.keys(STANDIN_KITS)]); + 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); }); diff --git a/test/content/standin.test.js b/test/content/standin.test.js new file mode 100644 index 00000000..6c149cd2 --- /dev/null +++ b/test/content/standin.test.js @@ -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}`); + } +}); diff --git a/test/helpers/battleHarness.js b/test/helpers/battleHarness.js index 93107238..3340b219 100644 --- a/test/helpers/battleHarness.js +++ b/test/helpers/battleHarness.js @@ -11,15 +11,21 @@ // stage explicit stage object (overrides stageId) // kind 'normal' | 'unite' | 'boss' | 'hidden' (default 'normal') // seed uint32 (default 1) -// units [{ chessId, row, col, items?, uid?, carryState?, abs? } | { kind:'token', tokenId, row, col }] -// board coordinates (rows 9–12, cols 2–10) for player 'p1' +// 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'. `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`) // 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 // 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) // 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) // 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 @@ -36,6 +42,7 @@ import { Battle } from '../../server/sim/Battle.js'; import { DataSource, getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js'; import { TICK } from '../../server/sim/constants.js'; +import { standInRecord, composeUnitRecord, unitForm } from '../../shared/standIn.js'; export const ALL_HOOKS = Object.freeze([ '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) { if (!defs) return 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. */ export function makeBattle(opts = {}) { 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; if (!stage) stage = !opts.stageId || opts.stageId === 'flat' ? flatStage(opts.flat || {}) : data.getStage(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); const players = opts.players ?? [{ 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 ?? [], }]; const hookNames = opts.hooks ?? ALL_HOOKS; diff --git a/tools/setup.mjs b/tools/setup.mjs index 45dce49e..3f9c163e 100755 --- a/tools/setup.mjs +++ b/tools/setup.mjs @@ -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. */ 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). */ 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). */