feedback5 WH: 自选 pieces in the sim — shared/diy.js (diyPool, checkDiyPick, diyRecord / diyRecordOf, validateDiyPicks: the legal picks per tier, prototypes skill-locked to their 补位 rows, owned picks any skill / module at the slot's stage, no 特质, derived bonds, one slot per owned operator, distinct picks per tier, unkitted operators refused); simdata getChess(slotId, { diy }) builds the 自选 def (diyFor, charId, loadout.diy, tokenOwner) and getToken resolves its summons from backups.json tokens; Battle, BattleSpec and the loadout view carry the PlayerBattleInput diy pick (a DIY slot without a legal pick fields nothing); kitOf takes KITS[charId] for a 自选 piece as for a stand-in; kits/index.js OPERATOR_KIT_FILES (op-<codename>.js, empty for now), GENERIC_KIT_CHARS, KITTED_CHARS; the harness fields units[].diy

No golden scenario moved (none fields a 自选 piece).
This commit is contained in:
sganggs committed 2026-10-05 19:51:40 +08:00
1 parent c278eafa52
commit 2b72d4deb0
13 files changed
+791 -42

No files matched your search

+6 -2
View File
@@ -103,11 +103,15 @@ export class BattlePlayers {
}
// 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); `standIn:
// true` fields the chess as its 补位 stand-in (simdata getStandIn: the stand-in's body, the chess's identity)
// true` fields the chess as its 补位 stand-in (simdata getStandIn: the stand-in's body, the chess's identity); `diy`
// fills a 自选 slot with its pick (simdata getDiy: the slot's identity, the operator's body, skill and module)
const lo = { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null };
if (inp.standIn === true) lo.standIn = true;
if (inp.diy && typeof inp.diy === 'object') lo.diy = inp.diy;
const def = this.data.getChess(inp.chessId, lo);
if (!def) { this.log(`unknown chess ${inp.chessId}`); return null; }
if (!def) { this.log(`unknown chess ${inp.chessId}${lo.diy ? ' (illegal 自选 pick)' : ''}`); return null; }
// a DIY slot has no body of its own (甄选干员): it fights only as a 自选 piece (its `diy` pick)
if (def.raw?.isDiy && !def.diyFor) { this.log(`自选 slot ${inp.chessId} without a pick`); return null; }
const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir });
u.items = [...(inp.items ?? [])];
u.carry = inp.carryState ?? null;
+1 -1
View File
@@ -31,7 +31,7 @@ export class BattleSummons {
const src = d && Array.isArray(d.sources) ? d.sources : null;
if (!src || src.includes('skill') || src.includes('talent')) return true;
const vs = this.data.rawToken?.(tokenId)?.variants;
const id = String(owner.defId ?? '');
const id = String(owner.def?.tokenOwner ?? owner.defId ?? ''); // a 自选 piece's variants are keyed by its owner form
return !(vs && typeof vs === 'object' && (vs[id] || vs[id.replace(/_b$/, '_a')]));
}
+33 -14
View File
@@ -11,6 +11,9 @@
// 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).
// 自选 pieces (isDiyDef: the def of simdata getDiy — a DIY slot's ids, the picked operator's body) take their kit the
// same way: kits[def.charId] (an owned 6★'s kits/ops/op-<codename>.js, a prototype's standin-<codename>.js), else the
// generic kit plus genericTalents; no default skill either (the pick chooses any of the three).
// 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,
@@ -60,21 +63,33 @@ export function isStandInDef(def) {
}
/**
* 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.
* Whether `def` is a 自选 piece: a DIY slot fielded with a picked operator's body (simdata getDiy — `diyFor` = the slot's
* base id, `charId` = the operator's).
*/
export function isDiyDef(def) {
return !!(def && def.diyFor && def.charId);
}
/** A def whose body is another character than its chess id names (补位 stand-in, 自选 piece): its kit is keyed by charId. */
const isOtherBody = (def) => isStandInDef(def) || isDiyDef(def);
/**
* The kit builder of an operator def in `reg` ({ [key]: (bb, chess, def) => Kit }), or undefined. A stand-in's and a
* 自选 piece's kit is keyed by its charId (`char_609_acguad`, `char_112_siege`) and by nothing else: the chess id it keeps
* names the replaced operator's kit (a DIY slot has none). 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;
if (isOtherBody(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. */
/** The generic kit of an operator def; a stand-in's / 自选 piece'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;
if (!isOtherBody(def)) return k;
const talents = genericTalents(def);
return talents.length ? { ...k, talents: [...(k.talents ?? []), ...talents] } : k;
}
@@ -102,7 +117,7 @@ export function setupUnitKit(battle, unit, mode = 'full') {
const k = f(bb, raw, def);
if (k) return selectSkillSpec(k, bb, raw, def);
} catch (e) {
battle._handlerError(`kit:${isStandInDef(def) ? def.charId : def.baseId}`, unit, e);
battle._handlerError(`kit:${isOtherBody(def) ? def.charId : def.baseId}`, unit, e);
}
}
}
@@ -120,15 +135,18 @@ 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). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say).
* 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), as
* does `diy` (自选: the pick of a DIY slot's piece). Null when the unit has no input entry.
*/
function inputLoadout(unit) {
const x = inputEntry(unit);
if (!x) return 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 };
const extra = {
...(x.standIn === true ? { standIn: true } : null),
...(x.diy && typeof x.diy === 'object' ? { diy: x.diy } : null),
};
if (x.skillIndex == null && x.moduleId == null) return extra;
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...extra };
}
/** Put a def on a not-yet-deployed ally (the fields Battle._makeAlly takes from the def). */
@@ -179,9 +197,10 @@ const selectedSkillId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? n
/**
* 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`.
* the skill it fields is the one its chess names (S2 on one chess, S3 on another), so its kit authors each in `skills`;
* nor for a 自选 piece (the pick chooses any of its skills).
*/
const skillIsDefault = (def) => !isStandInDef(def) && (!def?.loadout || def.loadout.skillIsDefault !== false);
const skillIsDefault = (def) => !isOtherBody(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;
+37 -2
View File
@@ -14,6 +14,14 @@
// 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.
//
// 自选 operator kits (DATA.md §18, README.md "How to add an operator (自选)"): OPERATOR_KIT_FILES lists
// `ops/op-<codename>.js`, one per owned-6★ pick of data/backups.json `diy.ownedPool` (codename = its charId without
// `char_<n>_`: `op-siege.js` = 推进之王, char_112_siege), each registering exactly that charId and writing every skill under
// `skills`. A 自选 piece keeps its DIY slot's ids, so kitOf finds its kit by `def.charId` — a prototype pick runs its
// stand-in kit, a 4★ reserve the generic kit (GENERIC_KIT_CHARS). OPERATOR_KITS is merged after the stand-ins.
// KITTED_CHARS = every character a 自选 pick may field with a faithful kit; an operator outside it is not offered
// (shared/diy.js diyPool / validateDiyPicks `kitted`).
export const KIT_FILES = Object.freeze([
// tier 1
@@ -66,6 +74,19 @@ export const KIT_FILES = Object.freeze([
*/
export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sharp2.js', 'standin-acspec.js', 'standin-acmedc.js', 'standin-acfend.js', 'standin-acpion.js', 'standin-acnipe.js', 'standin-accast.js', 'standin-acsupo.js']);
/**
* The 自选 operator kit files (`ops/op-<codename>.js`, registry key = the operator's charId, an owned-6★ pick of
* data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS).
*/
export const OPERATOR_KIT_FILES = Object.freeze([]);
/**
* The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 —
* test/content/standin.test.js): fielded with no kit file, as 补位 stand-ins and as tier-5 自选 picks.
*/
export const GENERIC_KIT_CHARS = Object.freeze(['char_600_cpione', 'char_601_cguard', 'char_602_cdfend', 'char_603_csnipe',
'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo', 'char_607_cspec']);
async function loadKitFile(file) {
try {
return await import(`./ops/${file}`);
@@ -77,6 +98,7 @@ 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 OPERATOR_MODULES = await Promise.all(OPERATOR_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. */
@@ -85,5 +107,18 @@ export const TIER_KITS = Object.freeze(MODULES.map((group) => Object.assign({},
/** 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));
/** The 自选 operator kits: owned-6★ charId → kit builder, in OPERATOR_KIT_FILES order. */
export const OPERATOR_KITS = Object.freeze(Object.assign({}, ...OPERATOR_MODULES.map(registryOf)));
/**
* The merged kit registry: baseChessId → (bb, chess, def) => Kit, tier 1 … tier 6, then the stand-ins' charIds, then
* the 自选 operators' charIds.
*/
export const KITS = Object.freeze(Object.assign({}, ...TIER_KITS, STANDIN_KITS, OPERATOR_KITS));
/**
* Every character a 自选 pick may field with a faithful kit: the 预备干员 (GENERIC_KIT_CHARS), the stand-ins with a kit
* file and the operators of OPERATOR_KIT_FILES whose file loaded. The `kitted` option of shared/diy.js diyPool /
* validateDiyPicks.
*/
export const KITTED_CHARS = Object.freeze([...new Set([...GENERIC_KIT_CHARS, ...Object.keys(STANDIN_KITS), ...Object.keys(OPERATOR_KITS)])]);
+5 -2
View File
@@ -139,10 +139,13 @@ function withLoadout(v, lo) {
return out;
}
/** The owner's own variant of a token record (chess id, else its normal `_a` sibling) with its loadout; null if none. */
/**
* The owner's own variant of a token record (chess id, else its normal `_a` sibling; a 自选 piece's: its owner form,
* `def.tokenOwner`) with its loadout; null if none.
*/
function ownVariant(raw, owner) {
const vs = raw?.variants;
const oid = owner?.defId;
const oid = owner?.def?.tokenOwner ?? owner?.defId;
if (!vs || typeof vs !== 'object' || !oid) return null;
const v = vs[oid] ?? vs[String(oid).replace(/_b$/, '_a')] ?? null;
return v ? withLoadout(v, owner.def?.loadout) : null;
+75 -5
View File
@@ -21,6 +21,12 @@
// 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.
// 自选 picks (DATA.md §18): `getChess(slotId, { diy: { charId, skillIndex, uniEquipId } })` = the def of a DIY slot (its
// `_a` / `_b` id) filled with that operator (shared/diy.js diyRecordOf: the slot's tier / price / status, no 特质, the
// pick's derived bonds, the operator's body, skill and module — a prototype's locked ones), with `def.diyFor` = the
// slot's base id, `def.charId` = the operator (its kit, content/index.js kitOf), `def.loadout.diy` = the checked pick and
// `def.tokenOwner` = the key of its summons' variants (data/backups.json `tokens`, read by getToken); null for an illegal
// pick.
//
// 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.
@@ -31,6 +37,7 @@
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
import { standInRecord } from '../../shared/standIn.js';
import { diyRecordOf, diyTokenOwner } from '../../shared/diy.js';
import { normHitArea } from './body.js';
// ---------------------------------------------------------------------------------------------------------------
@@ -201,6 +208,8 @@ export function normalizeChess(rec) {
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),
// a 自选 piece (shared/diy.js): the slot's base id — `charId` is the operator's (absent otherwise)
...(rec.diyFor ? { diyFor: rec.diyFor } : null),
raw: rec,
};
}
@@ -541,7 +550,8 @@ export class DataSource {
rawEnemy(key) {
return this.raw.enemies[key] ?? this.raw.enemies['enemy_' + key] ?? this.raw.enemies[String(key).replace(/^enemy_/, '')] ?? this.fallback?.rawEnemy(key) ?? null;
}
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? null; }
/** A token record: data/tokens.json, else the 自选 picks' summons (data/backups.json `tokens`). */
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? this.rawBackups()?.tokens?.[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. */
@@ -553,9 +563,11 @@ export class DataSource {
* 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.
* its own def for that loadout. `loadout.diy` (`{ charId, skillIndex, uniEquipId }`): the 自选 def of a DIY slot
* (getDiy; null when the pick is not legal for that slot).
*/
getChess(id, loadout = null) {
if (loadout && loadout.diy && typeof loadout.diy === 'object') return this.getDiy(id, loadout.diy);
if (loadout && loadout.standIn === true) {
const sd = this.getStandIn(id);
if (sd) return sd;
@@ -595,6 +607,32 @@ export class DataSource {
this._chess.set(key, d);
return d;
}
/**
* The 自选 def of DIY slot `id` (its `_a` or `_b` record) filled with `pick` (DATA.md §18): shared/diy.js diyRecordOf
* over this source (chess records, backups.json), normalised — `def.id` / `baseId` / `golden` / `tier` / `bonds` (the
* pick's derived ones) of the slot, every combat field of the operator at the slot's status with the pick's skill and
* module, `def.charId` the operator's, `def.diyFor` the slot's base id, `def.loadout` = the selection (`isDefault`) with
* `diy` = the checked pick, `def.tokenOwner` = its summons' variant key (shared/diy.js diyTokenOwner). Null for an
* illegal pick (not a pick of the slot's tier, a prototype off its locked skill, an unknown skill or module — checkDiyPick)
* or when the data lacks it. Cached per (id, pick).
* @param {string} id
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick
*/
getDiy(id, pick) {
const key = `${id}|diy|${pick?.charId ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}`;
if (this._chess.has(key)) return this._chess.get(key);
const r = this.rawChess(id);
const rec = r && r.isDiy ? diyRecordOf(r, pick, this) : null;
let d = null;
if (rec) {
d = normalizeChess({ ...rec, chessId: id });
d.loadout = { ...resolveLoadout(rec, null), diy: { charId: rec.charId, skillIndex: rec.skill.index, uniEquipId: rec.module?.id ?? null } };
d.tokenOwner = diyTokenOwner(rec.charId, rec.status);
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);
@@ -611,6 +649,7 @@ export class DataSource {
* than 'display'; PlayerState / the kits' skill summons read them from here).
*/
getToken(id, ownerChessId = null, ownerLoadout = null) {
if (ownerChessId && ownerLoadout && ownerLoadout.diy && typeof ownerLoadout.diy === 'object') return this.getDiyToken(id, ownerChessId, ownerLoadout.diy);
const olo = ownerChessId && ownerLoadout ? resolveLoadout(this.rawChess(ownerChessId), ownerLoadout) : null;
const ck = id + '|' + (ownerChessId ?? '') + loadoutKey(olo);
if (this._token.has(ck)) return this._token.get(ck);
@@ -642,6 +681,33 @@ export class DataSource {
this._token.set(ck, d);
return d;
}
/**
* Token def of a summon of a 自选 piece: its record (data/backups.json `tokens`) with the variant of the owner form
* (`<charId>@<statusKey of the slot record>`, shared/diy.js diyTokenOwner) and the pick as the owner's loadout —
* `bySkill[skillIndex]` (the token skill / count / sources of that skill), `byModule[uniEquipId]` (module token
* attributes, trait, talents), as getToken does for a chess owner's loadout. A token without a variant of that form
* gets the record's defaults (`sources` null).
* @param {string} id token id
* @param {string} slotId the owner's DIY slot record id (`_a` / `_b`)
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick the owner def's `loadout.diy`
*/
getDiyToken(id, slotId, pick) {
const slot = this.rawChess(slotId);
const owner = slot && typeof pick?.charId === 'string' ? diyTokenOwner(pick.charId, slot.status) : null;
const ck = `${id}|diy|${owner ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}`;
if (this._token.has(ck)) return this._token.get(ck);
const r = this.rawToken(id);
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
if (variant) {
const si = pick.skillIndex;
if (Number.isInteger(si) && variant.bySkill && variant.bySkill[si]) variant = { ...variant, ...variant.bySkill[si] };
const mid = pick.uniEquipId;
if (typeof mid === 'string' && variant.byModule && variant.byModule[mid]) variant = { ...variant, ...variant.byModule[mid] };
}
const d = r ? freezeDef(normalizeToken(id, r, owner, variant)) : null;
this._token.set(ck, d);
return d;
}
getStage(id) {
if (this._stage.has(id)) return this._stage.get(id);
const d = normalizeStage(id, this.rawStage(id));
@@ -732,7 +798,8 @@ export function toDataSource(data) {
* 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). An entry with `standIn: true` (补位) maps its
* chess id to the stand-in def (`getChess(id, { standIn: true })`) the same way.
* chess id to the stand-in def (`getChess(id, { standIn: true })`) the same way, and one with `diy` (自选: `{ charId,
* skillIndex, uniEquipId }`) to its 自选 def (`getChess(id, { diy })`).
* @param {object} ds DataSource
* @param {object[]} players PlayerBattleInput[] (spec players)
*/
@@ -744,12 +811,15 @@ 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 || u.standIn === true) any = true;
const diy = u.diy && typeof u.diy === 'object' ? u.diy : null;
if (u.skillIndex != null || u.moduleId != null || u.standIn === true || diy) any = true;
const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null };
if (u.standIn === true) lo.standIn = true;
if (diy) lo.diy = diy;
const prev = map.get(u.chessId);
if (!prev) map.set(u.chessId, lo);
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId || !!prev.standIn !== !!lo.standIn) conflicts.add(u.chessId);
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId || !!prev.standIn !== !!lo.standIn
|| JSON.stringify(prev.diy ?? null) !== JSON.stringify(lo.diy ?? null)) conflicts.add(u.chessId);
}
}
if (!any) return ds;
+4 -1
View File
@@ -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?, standInFor? } (standInFor = the replaced operator's charId of a 补位 stand-in;
// form?, skillIndex?, moduleId?, items?, standInFor?, diy? } (standInFor = the replaced operator's charId of a 补位
// stand-in; diy = a 自选 piece's pick { charId, skillIndex, uniEquipId } — defId is its slot, spine / avatar the operator's;
// 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)
@@ -55,6 +56,8 @@ export function unitInfo(u) {
// 补位: 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,
// 自选: the pick of a DIY slot's piece (the detail card composes its record, shared/diy.js diyRecord)
diy: u.side === 'ally' && u.kind === 'op' && d.diyFor && d.loadout?.diy ? { ...d.loadout.diy } : undefined,
};
}
+25 -3
View File
@@ -22,6 +22,9 @@
// 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).
// 自选 (DATA.md §18): an operator entry of a DIY slot (`chessId` its `_a` / `_b` id) carries `diy: { charId, skillIndex?,
// uniEquipId? }` (kept when well-formed; the data layer checks legality — shared/diy.js checkDiyPick) and is fielded as
// simdata getChess(id, { diy }).
import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js';
@@ -79,14 +82,33 @@ 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) and `standIn` only
* when exactly `true` (补位). Mutates `u`.
* A well-formed 自选 pick `{ charId, skillIndex?, uniEquipId? }` reduced to those fields (skill 0–9 or null, module a
* loadout id or null), or null.
*/
function cleanDiyPick(d) {
if (!d || typeof d !== 'object' || Array.isArray(d) || typeof d.charId !== 'string' || !LOADOUT_ID.test(d.charId)) return null;
const out = { charId: d.charId };
if (d.skillIndex != null) {
if (!(Number.isInteger(d.skillIndex) && d.skillIndex >= 0 && d.skillIndex <= 9)) return null;
out.skillIndex = d.skillIndex;
}
if (d.uniEquipId != null) {
if (!(typeof d.uniEquipId === 'string' && LOADOUT_ID.test(d.uniEquipId))) return null;
out.uniEquipId = d.uniEquipId;
}
return out;
}
/**
* Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality), `standIn` only when
* exactly `true` (补位) and `diy` only as a well-formed pick (自选). 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 ('standIn' in u && u.standIn !== true) delete u.standIn;
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; }
if ('diy' in u) { const d = cleanDiyPick(u.diy); if (d) u.diy = d; else delete u.diy; }
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; delete u.diy; }
return u;
}