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:
1 parent
286a34064f
commit
ed2b679f83
14 files changed
+656
-58
No files matched your search
+8
-5
@@ -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`:
|
||||
|
||||
+24
-4
@@ -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-<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
|
||||
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:<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).
|
||||
|
||||
### 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=<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
|
||||
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`);
|
||||
|
||||
+2
-2
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?? [])];
|
||||
|
||||
+54
-18
@@ -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-<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
|
||||
// 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'; }
|
||||
|
||||
@@ -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/<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/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-<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
|
||||
|
||||
- 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
|
||||
`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
|
||||
|
||||
@@ -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-<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([
|
||||
// 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-<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) {
|
||||
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));
|
||||
+46
-4
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<chessId>-<codename>.js` registers exactly the kit of `<chessId>_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 `<chessId>-<codename>.js` registers exactly the kit of
|
||||
// `<chessId>_a`, whose data record has that charId code name; a 补位 file `standin-<codename>.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-<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 () => {
|
||||
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 <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', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user