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:
1 parent
c278eafa52
commit
2b72d4deb0
13 files changed
+791
-42
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)])]);
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
// shared/diy.js — 自选编队: a 自选 (DIY) slot filled with an operator. Pure ESM shared by the simulation
|
||||
// (server/sim/simdata.js getChess(id, { diy })), the server and the client. Data: data/backups.json `units` / `tokens` /
|
||||
// `diy` (docs/DATA.md §18), composed with shared/standIn.js composeUnitRecord into a record shaped like a data/chess.json
|
||||
// record, so normalizeChess, resolveLoadout / loadoutRecord and the detail card read it unchanged.
|
||||
//
|
||||
// The rules (research 0.2.0 §2; PRTS 卫戍协议, 卫戍协议:盟约 下半/PRTS盟约记录; the owner's decisions of 2026-10-05):
|
||||
// - two slots per tier, 5 and 6: chess_char_5_diy1/2_a, chess_char_6_diy1/2_a, each with its `_b` elite twin (tier,
|
||||
// price, merge of the slot: buy 4, sell 1);
|
||||
// - the picks of a tier: the owned 6★ outside the chess pool (`diy.ownedPool`: no preset operator, no collab) and the
|
||||
// nine 6★ prototypes at both tiers, the six 4★ reserves (not 先锋 / 特种) at tier 5 only (`diy.prototypes[tier]`);
|
||||
// - a prototype may fill one tier-5 and one tier-6 slot, an owned operator one slot; the two picks of a tier differ;
|
||||
// - no 特质 (PRTS "甄选加入的干员不会拥有任何特质"); bonds from the operator's factions (`diy.operators[id].bonds`);
|
||||
// - the normal form is E2 Lv1, skill rank 4, no module; the elite E2 Lv60, rank 7, its module at stage 1 (tier 5) or 3
|
||||
// (tier 6) — the slot record's `status` (activity_table diyChessDict) picks the unit form;
|
||||
// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none); a prototype carries the skill
|
||||
// and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the
|
||||
// owner's decision of 2026-10-05);
|
||||
// - potential 0 for everyone [ASSUMED: no account].
|
||||
// Which operators have a kit (a pick without one is not offered) is the sim's kit registry
|
||||
// (server/sim/content/kits/index.js KITTED_CHARS), passed in as `kitted`.
|
||||
|
||||
import { composeUnitRecord, unitForm, statusKey } from './standIn.js';
|
||||
|
||||
/** The tiers that have 自选 slots. */
|
||||
export const DIY_TIERS = Object.freeze([5, 6]);
|
||||
|
||||
/**
|
||||
* @typedef {{ charId: string, skillIndex?: number|null, uniEquipId?: string|null }} DiyPick a 自选 pick: the operator,
|
||||
* its skill index (0-based; a prototype's is its locked one) and module (uniEquipId; null / absent / 'none' = none)
|
||||
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout a checked, complete pick
|
||||
* @typedef {{ chess?: Record<string, any>|null, backups?: any, rawChess?: (id: string) => any, rawBackups?: () => any }} DiyData
|
||||
* the game data — `{ chess, backups }` (data/chess.json, data/backups.json) or a sim DataSource (`rawChess(id)`,
|
||||
* `rawBackups()`)
|
||||
* @typedef {Iterable<string>|((charId: string) => boolean)|null|undefined} Kitted the charIds with a kit, or a predicate
|
||||
*/
|
||||
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const own = (o, k) => isObj(o) && Object.prototype.hasOwnProperty.call(o, k);
|
||||
|
||||
/** data/backups.json of `data`, or null. @param {DiyData|null|undefined} data */
|
||||
function backupsOf(data) {
|
||||
const b = typeof data?.rawBackups === 'function' ? data.rawBackups() : data?.backups;
|
||||
return isObj(b) ? b : null;
|
||||
}
|
||||
/** The data/chess.json record `id` of `data`, or null. @param {DiyData|null|undefined} data @param {string} id */
|
||||
function chessOf(data, id) {
|
||||
const r = typeof data?.rawChess === 'function' ? data.rawChess(id) : data?.chess?.[id];
|
||||
return isObj(r) ? r : null;
|
||||
}
|
||||
/** The `diy` part of data/backups.json, or null. @param {DiyData|null|undefined} data */
|
||||
function diyOf(data) {
|
||||
const d = backupsOf(data)?.diy;
|
||||
return isObj(d) && isObj(d.slots) ? d : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 自选 slot a chess id names: its base (normal) id, its tier and both records. Null for any other id.
|
||||
* @param {string} slotId a slot's normal (`_a`) or elite (`_b`) id
|
||||
* @param {DiyData} data
|
||||
*/
|
||||
export function diySlot(slotId, data) {
|
||||
const diy = diyOf(data);
|
||||
if (!diy || typeof slotId !== 'string') return null;
|
||||
const baseId = own(diy.slots, slotId) ? slotId : Object.keys(diy.slots).find((id) => diy.slots[id]?.goldenId === slotId);
|
||||
const s = baseId ? diy.slots[baseId] : null;
|
||||
if (!s) return null;
|
||||
return { baseId, goldenId: s.goldenId, tier: s.tier, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) };
|
||||
}
|
||||
|
||||
/** The base ids of the 自选 slots (data order: tier 5 then tier 6). @param {DiyData} data @returns {string[]} */
|
||||
export function diySlotIds(data) {
|
||||
return Object.keys(diyOf(data)?.slots ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The key of a DIY owner's summon variants in data/backups.json `tokens[id].variants`: the operator and the slot form.
|
||||
* @param {string} charId
|
||||
* @param {object} status the slot record's `status`
|
||||
*/
|
||||
export function diyTokenOwner(charId, status) {
|
||||
return `${charId}@${statusKey(status)}`;
|
||||
}
|
||||
|
||||
/** Whether `charId` is a prototype pick (原型干员) of `tier`. @param {DiyData} data @param {number} tier @param {string} charId */
|
||||
export function isPrototypePick(data, tier, charId) {
|
||||
return (diyOf(data)?.prototypes?.[tier] ?? []).includes(charId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill / module a prototype carries in a slot of `tier` (`diy.locked`), or null for anyone else.
|
||||
* @param {DiyData} data @param {number} tier @param {string} charId
|
||||
* @returns {{ skillIndex: number, uniEquipId: string|null }|null}
|
||||
*/
|
||||
export function lockedSelection(data, tier, charId) {
|
||||
const l = diyOf(data)?.locked?.[tier]?.[charId];
|
||||
return isObj(l) && Number.isInteger(l.skillIndex) ? { skillIndex: l.skillIndex, uniEquipId: l.uniEquipId ?? null } : null;
|
||||
}
|
||||
|
||||
/** A `kitted` option as a predicate (no option = everyone). @param {Kitted} kitted @returns {(id: string) => boolean} */
|
||||
function kittedTest(kitted) {
|
||||
if (kitted == null) return () => true;
|
||||
if (typeof kitted === 'function') return (id) => !!kitted(id);
|
||||
const set = new Set(kitted);
|
||||
return (id) => set.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The legal picks of a tier: its prototypes (`diy.prototypes[tier]`), then the owned 6★ (`diy.ownedPool`), in data
|
||||
* order. With `kitted`, only the operators that have a kit (a pick without one is not offered).
|
||||
* @param {number} tier 5 or 6
|
||||
* @param {{ data: DiyData, kitted?: Kitted }} opts
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function diyPool(tier, { data, kitted = null } = { data: null }) {
|
||||
const diy = diyOf(data);
|
||||
if (!diy || !DIY_TIERS.includes(tier)) return [];
|
||||
const ok = kittedTest(kitted);
|
||||
return [...new Set([...(diy.prototypes?.[tier] ?? []), ...(diy.ownedPool ?? [])])].filter(ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one pick against a slot and complete it: the operator must be a pick of the slot's tier; a prototype takes its
|
||||
* locked skill and module (a different one given is an error); an owned operator needs a skill index of its form and
|
||||
* may name a module of its elite form at the slot's stage. Both slot forms must exist in the data.
|
||||
* @param {string} slotId a slot's normal or elite id
|
||||
* @param {DiyPick|null|undefined} pick
|
||||
* @param {DiyData} data
|
||||
* @returns {{ ok: true, pick: DiyLoadout } | { error: string }}
|
||||
*/
|
||||
export function checkDiyPick(slotId, pick, data) {
|
||||
const slot = diySlot(slotId, data);
|
||||
if (!slot) return { error: `not a 自选 slot: ${slotId}` };
|
||||
if (!isObj(pick) || typeof pick.charId !== 'string' || !ID.test(pick.charId)) return { error: `${slot.baseId}: bad pick` };
|
||||
const { charId } = pick;
|
||||
const diy = diyOf(data);
|
||||
const proto = isPrototypePick(data, slot.tier, charId);
|
||||
if (!proto && !(diy.ownedPool ?? []).includes(charId)) return { error: `${charId} is not a tier-${slot.tier} 自选 pick` };
|
||||
const want = pick.skillIndex ?? null;
|
||||
const wantMod = pick.uniEquipId === undefined || pick.uniEquipId === 'none' ? null : pick.uniEquipId;
|
||||
if (want !== null && !(Number.isInteger(want) && want >= 0 && want <= 9)) return { error: `${charId}: bad skill index ${want}` };
|
||||
if (wantMod !== null && (typeof wantMod !== 'string' || !ID.test(wantMod))) return { error: `${charId}: bad module ${wantMod}` };
|
||||
const forms = [slot.normal, slot.golden].map((rec) => (rec ? unitForm(backupsOf(data), charId, rec.status) : null));
|
||||
if (forms.some((f) => !f)) return { error: `${charId}: no form for ${slot.baseId}` };
|
||||
let skillIndex, uniEquipId;
|
||||
if (proto) {
|
||||
const lk = lockedSelection(data, slot.tier, charId);
|
||||
if (!lk) return { error: `${charId}: no locked selection at tier ${slot.tier}` };
|
||||
if (want !== null && want !== lk.skillIndex) return { error: `${charId}: a prototype carries skill ${lk.skillIndex} (与系统补位时一致)` };
|
||||
if (pick.uniEquipId !== undefined && wantMod !== lk.uniEquipId) return { error: `${charId}: a prototype carries module ${lk.uniEquipId ?? 'none'}` };
|
||||
({ skillIndex, uniEquipId } = lk);
|
||||
} else {
|
||||
if (want === null) return { error: `${charId}: an owned pick needs a skill index` };
|
||||
skillIndex = want;
|
||||
uniEquipId = wantMod;
|
||||
}
|
||||
if (!forms.every((f) => (f.skills ?? []).some((s) => s && s.index === skillIndex))) return { error: `${charId}: no skill ${skillIndex}` };
|
||||
const elite = forms[1];
|
||||
if (uniEquipId !== null && !(elite.modules ?? []).some((m) => m && m.uniEquipId === uniEquipId)) {
|
||||
return { error: `${charId}: no module ${uniEquipId} at stage ${slot.golden?.status?.equipLevel ?? '?'}` };
|
||||
}
|
||||
return { ok: true, pick: { charId, skillIndex, uniEquipId } };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 自选 slot record (either form) filled with a checked pick: composeUnitRecord of the slot's identity (tier, price,
|
||||
* merge, status; no 特质) with the operator's form at the slot's status, the pick's skill and module (active on the elite
|
||||
* form only), the derived bonds — plus `diyFor` (the slot's base id: the record is a 自选 piece) and `charId` (the
|
||||
* operator). Null when the pick is not legal for the slot (checkDiyPick) or the data lacks a part.
|
||||
* @param {object} slot a DIY chess record (data/chess.json, `isDiy`)
|
||||
* @param {DiyPick} pick
|
||||
* @param {DiyData} data
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function diyRecordOf(slot, pick, data) {
|
||||
if (!isObj(slot) || !slot.isDiy || typeof slot.chessId !== 'string') return null;
|
||||
const c = checkDiyPick(slot.chessId, pick, data);
|
||||
if (!('ok' in c)) return null;
|
||||
const { charId, skillIndex, uniEquipId } = c.pick;
|
||||
const backups = backupsOf(data);
|
||||
const unit = backups?.units?.[charId] ?? null;
|
||||
const rec = composeUnitRecord(slot, unit, unitForm(backups, charId, slot.status),
|
||||
{ skillIndex, moduleId: uniEquipId, bonds: diyOf(data)?.operators?.[charId]?.bonds ?? null });
|
||||
if (!rec || !rec.skill) return null;
|
||||
rec.diyFor = slot.baseId ?? slot.chessId;
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record of a 自选 slot filled with `pick` (diyRecordOf): `elite` = the slot's `_b` form (E2 Lv60, module stage 1 at
|
||||
* tier 5 / 3 at tier 6), else the normal form (E2 Lv1, no module).
|
||||
* @param {string} slotId the slot's base id (an elite id is accepted and taken as the elite)
|
||||
* @param {DiyPick} pick
|
||||
* @param {{ elite?: boolean, data: DiyData }} opts
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function diyRecord(slotId, pick, { elite = false, data } = { data: null }) {
|
||||
const slot = diySlot(slotId, data);
|
||||
if (!slot) return null;
|
||||
return diyRecordOf(elite || slot.elite ? slot.golden : slot.normal, pick, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a whole 自选 roster: `picks` maps slot base ids to a pick (absent / null = an empty slot). Strict: an unknown
|
||||
* slot, an illegal pick (checkDiyPick), an operator without a kit (when `kitted` is given), the same operator twice in
|
||||
* one tier, or an owned operator in two slots rejects the roster; a prototype may fill a tier-5 and a tier-6 slot.
|
||||
* @param {any} picks
|
||||
* @param {{ data: DiyData, kitted?: Kitted }} opts
|
||||
* @returns {{ ok: true, picks: Record<string, DiyLoadout> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
|
||||
*/
|
||||
export function validateDiyPicks(picks, { data, kitted = null } = { data: null }) {
|
||||
if (!isObj(picks)) return { error: 'BAD_MSG', detail: 'bad 自选 picks' };
|
||||
const diy = diyOf(data);
|
||||
if (!diy) return { error: 'BAD_TARGET', detail: 'no 自选 data' };
|
||||
const ok = kittedTest(kitted);
|
||||
/** @type {Record<string, DiyLoadout>} */
|
||||
const out = {};
|
||||
const byTier = new Map();
|
||||
const ownedSeen = new Map();
|
||||
for (const [slotId, pick] of Object.entries(picks)) {
|
||||
if (!own(diy.slots, slotId)) return { error: 'BAD_TARGET', detail: `not a 自选 slot: ${slotId}` };
|
||||
if (pick == null) continue;
|
||||
const c = checkDiyPick(slotId, pick, data);
|
||||
if (!('ok' in c)) return { error: 'BAD_TARGET', detail: c.error };
|
||||
const { charId } = c.pick;
|
||||
if (!ok(charId)) return { error: 'BAD_TARGET', detail: `${charId} has no kit yet` };
|
||||
const tier = diy.slots[slotId].tier;
|
||||
const inTier = byTier.get(tier) ?? new Set();
|
||||
if (inTier.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills two tier-${tier} slots` };
|
||||
inTier.add(charId);
|
||||
byTier.set(tier, inTier);
|
||||
if (!isPrototypePick(data, tier, charId)) {
|
||||
if (ownedSeen.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills ${ownedSeen.get(charId)} and ${slotId} (an owned operator fills one slot)` };
|
||||
ownedSeen.set(charId, slotId);
|
||||
}
|
||||
out[slotId] = c.pick;
|
||||
}
|
||||
return { ok: true, picks: out };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// test/content/diy.test.js — 自选 pieces in battle (DATA.md §18, docs/SIM.md §12): simdata getChess(slotId, { diy }) /
|
||||
// getDiy, the PlayerBattleInput `diy` field through Battle, the BattleSpec path and the per-battle loadout view, the
|
||||
// kit lookup by charId (an owned 6★'s operator kit, a prototype's stand-in kit, a 预备干员's generic kit, the generic kit
|
||||
// for an operator without one), the summons of an owned pick (data/backups.json `tokens`, getToken with the pick as the
|
||||
// owner's loadout) and the wire info. The 推进之王 kit itself: test/content/op_siege.test.js.
|
||||
// Run: node --test test/content/diy.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, checkInvariants, flatStage, flatRoutes } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource, DataSource } from '../../server/sim/simdata.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { unitInfo } from '../../server/sim/snapshot.js';
|
||||
import { isDiyDef, kitOf } from '../../server/sim/content/index.js';
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
|
||||
const BACKUPS = load('backups');
|
||||
const SIEGE = 'char_112_siege';
|
||||
const T5 = 'chess_char_5_diy1_a', T6 = 'chess_char_6_diy1_a';
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
|
||||
const ENEMIES = { enemy_dummy: dummy('enemy_dummy') };
|
||||
|
||||
function battle(units, o = {}) {
|
||||
return makeBattle({ defs: { enemies: ENEMIES }, units, timeLimit: 300, autoFinish: false, seed: o.seed ?? 7, flags: { dpPerSec: 0 }, ...o });
|
||||
}
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('getChess(slotId, { diy }): the 自选 def — the slot\'s ids / tier, the pick\'s body, skill, module and bonds; diyFor, charId, loadout.diy, tokenOwner; cached; null for an illegal pick', () => {
|
||||
const ds = getDefaultSource();
|
||||
const pick = { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_003_siege' };
|
||||
const d = ds.getChess('chess_char_6_diy1_b', { diy: pick });
|
||||
assert.deepEqual([d.id, d.baseId, d.golden, d.tier, d.charId, d.diyFor, d.name, d.profession], ['chess_char_6_diy1_b', T6, true, 6, SIEGE, T6, '推进之王', 'PIONEER']);
|
||||
assert.deepEqual([d.bonds, d.raw.garrisonIds, d.skill.id, d.raw.module.id, d.raw.module.level, d.raw.module.active], [['victoriaShip'], [], 'skcom_charge_cost[3]', 'uniequip_003_siege', 3, true]);
|
||||
assert.deepEqual(d.loadout.diy, pick);
|
||||
assert.equal(d.tokenOwner, 'char_112_siege@2/60/7/3');
|
||||
assert.ok(isDiyDef(d) && Object.isFrozen(d));
|
||||
assert.equal(ds.getChess('chess_char_6_diy1_b', { diy: { ...pick } }), d, 'cached per (id, pick)');
|
||||
assert.equal(ds.getChess(T6, { diy: { charId: 'char_601_cguard' } }), null, 'a reserve is no tier-6 pick');
|
||||
assert.equal(ds.getChess(T5, { diy: { charId: 'char_609_acguad', skillIndex: 0 } }), null, 'a prototype off its locked skill');
|
||||
assert.equal(ds.getChess('chess_char_5_01_a', { diy: pick }), null, 'not a slot');
|
||||
const proto = ds.getChess(T5, { diy: { charId: 'char_617_sharp2' } });
|
||||
assert.deepEqual([proto.skill.id, proto.loadout.diy], ['skchr_sharp2_1', { charId: 'char_617_sharp2', skillIndex: 0, uniEquipId: 'uniequip_002_sharp2' }], '领主·Sharp: S1, its module (inactive on the normal form)');
|
||||
assert.equal(proto.raw.module.active, false);
|
||||
});
|
||||
|
||||
test('a battle fields 自选 pieces by their kit: an owned 6★\'s operator kit (keyed by its charId), a prototype\'s stand-in kit, a 预备干员\'s generic kit, the generic kit (+ its exact talents) without a kit', () => {
|
||||
// an injected kit keyed by the operator's charId — what kits/ops/op-<codename>.js registers (KITS[charId])
|
||||
const siegeKit = () => ({ skills: { skchr_siege_2: { kind: 'charges' } }, talents: [], diyProbe: true });
|
||||
const h = battle([
|
||||
{ diy: { slot: 5, charId: SIEGE, skillIndex: 1 }, row: 10, col: 3 },
|
||||
{ diy: { slot: 'chess_char_5_diy2_a', charId: 'char_609_acguad' }, elite: true, row: 10, col: 5 },
|
||||
{ diy: { slot: T6, charId: 'char_617_sharp2' }, elite: true, row: 10, col: 7 },
|
||||
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_017_huang', skillIndex: 2 }, row: 11, col: 3 },
|
||||
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_601_cguard' }, row: 11, col: 5 }, // illegal at tier 6: never fielded
|
||||
], { kits: { [SIEGE]: siegeKit, chess_char_5_diy1_a: () => { throw new Error('a 自选 piece never runs a kit of its slot id'); } } });
|
||||
h.step();
|
||||
const [siege, sharp, sharp2, huang] = [1, 2, 3, 4].map((uid) => h.unit(uid));
|
||||
assert.equal(h.unit(5), null, 'an illegal pick fields nothing');
|
||||
assert.deepEqual([siege.defId, siege.def.charId, siege.skill.id, siege.kit.diyProbe, siege.kit.skillSource], [T5, SIEGE, 'skchr_siege_2', true, 'skills']);
|
||||
assert.equal(kitOf(siege.def, { [T5]: 'slot', chess_char_5_diy1: 'bare', [SIEGE]: 'mine' }), 'mine', 'kitOf: by charId only');
|
||||
assert.equal(kitOf(siege.def, { [T5]: 'slot' }), undefined);
|
||||
assert.deepEqual([sharp.defId, sharp.def.charId, sharp.skill.id, !!sharp.kit.generic, sharp.kit.skillSource, sharp.def.raw.module.level], ['chess_char_5_diy2_b', 'char_609_acguad', 'skchr_acguad_3', false, 'skills', 1]);
|
||||
assert.deepEqual([sharp2.skill.id, !!sharp2.kit.generic, sharp2.def.raw.module.level, sharp2.def.raw.module.active], ['skchr_sharp2_1', false, 3, true], '领主·Sharp at tier 6: S1 + LOR-X stage 3');
|
||||
assert.deepEqual([huang.def.charId, huang.skill.id, !!huang.kit.generic, huang.def.bonds], ['char_017_huang', 'skchr_huang_3', true, ['yanShip', 'victoriaShip']], '煌 has no kit yet: the generic kit');
|
||||
// the elite reserve uses the generic kit with its exact stat talents (as a 补位 stand-in does)
|
||||
const h2 = battle([{ diy: { slot: 5, charId: 'char_601_cguard' }, elite: true, row: 10, col: 3 }]);
|
||||
h2.step();
|
||||
const g = h2.unit(1);
|
||||
assert.deepEqual([g.def.charId, g.skill.id, !!g.kit.generic], ['char_601_cguard', 'skcom_atk_up[3]', true], '预备干员-近卫 at tier 5: S3 攻击力强化·γ型, its 补位 row');
|
||||
assert.ok(Math.abs(g.s.atk - g.base.atk * 1.08) < 1e-6, 'its talent 攻击提升 (攻击力+8%) through genericTalents');
|
||||
done(h);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('BattleSpec path: buildBattleSpec keeps a well-formed `diy` (and drops a malformed one); the spec battle fields the 自选 def, with the per-unit pick exact when two players fill one slot id differently', () => {
|
||||
const players = [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_5_diy1_b', diy: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege', extra: 1 }, row: 10, col: 4 }] },
|
||||
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 0, bonds: {}, units: [
|
||||
{ uid: 1, kind: 'chess', chessId: 'chess_char_5_diy1_b', diy: { charId: 'char_611_acnipe' }, row: 11, col: 4 },
|
||||
{ uid: 2, kind: 'chess', chessId: 'chess_char_5_diy2_a', diy: { charId: 'not ok!' }, row: 12, col: 4 },
|
||||
] },
|
||||
];
|
||||
const spec = buildBattleSpec({ battleId: 'diy', kind: 'normal', seed: 3, stageId: null, players, spawns: [], routes: flatRoutes(), timeLimit: 30 });
|
||||
assert.deepEqual(spec.players[0].units[0].diy, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, 'reduced to the pick');
|
||||
assert.deepEqual(spec.players[1].units[0].diy, { charId: 'char_611_acnipe' });
|
||||
assert.equal('diy' in spec.players[1].units[1], false, 'a malformed pick is dropped');
|
||||
// the same players through the harness (a flat stage; the spec carries no stage object): each unit its own pick
|
||||
const h = makeBattle({ defs: { enemies: ENEMIES }, players: spec.players, timeLimit: 30, autoFinish: false, stage: flatStage(), flags: { dpPerSec: 0 } });
|
||||
h.step();
|
||||
const mine = h.b.allyUnits.find((u) => u.ownerId === 'p1' && u.uid === 1);
|
||||
const theirs = h.b.allyUnits.find((u) => u.ownerId === 'p2' && u.uid === 1);
|
||||
assert.deepEqual([mine.def.charId, mine.skill.id, theirs.def.charId, theirs.skill.id], [SIEGE, 'skchr_siege_3', 'char_611_acnipe', 'skchr_acnipe_3']);
|
||||
assert.ok(h.b.data.loadoutConflicts.includes('chess_char_5_diy1_b'), 'the id-only view notes the conflict; each unit still has its own def');
|
||||
assert.equal(h.b.allyUnits.some((u) => u.ownerId === 'p2' && u.uid === 2), false, 'a DIY slot without a pick fields nothing');
|
||||
done(h);
|
||||
// and createBattleFromSpec on a real stage builds the same 自选 def
|
||||
const stageId = Object.keys(load('stages')).find((id) => load('stages')[id].kind !== 'unite');
|
||||
const b = createBattleFromSpec({ ...spec, stageId, routes: [] }, getDefaultSource(), { quiet: true });
|
||||
b.step();
|
||||
assert.deepEqual(b.allyUnits.filter((u) => u.kind === 'op').map((u) => [u.ownerId, u.def.charId, u.def.loadout.diy.skillIndex]), [['p1', SIEGE, 2], ['p2', 'char_611_acnipe', 2]]);
|
||||
});
|
||||
|
||||
test('summons of an owned pick: battle.tokenDef resolves the variant of its form and pick (令 at tier 6 elite: S3 makes 弦惊, not 清平)', () => {
|
||||
const h = battle([{ diy: { slot: 6, charId: 'char_2023_ling', skillIndex: 2, uniEquipId: 'uniequip_002_ling' }, elite: true, row: 10, col: 4 }]);
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
assert.equal(u.def.tokenOwner, 'char_2023_ling@2/60/7/3');
|
||||
const rec3 = BACKUPS.tokens.token_10020_ling_soul3;
|
||||
const v = rec3.variants['char_2023_ling@2/60/7/3'];
|
||||
const t3 = h.b.tokenDef('token_10020_ling_soul3', u);
|
||||
assert.deepEqual([t3.id, t3.sources, t3.skill?.id], ['token_10020_ling_soul3', ['skill', 'display'], v.bySkill['2'].skill.skillId]);
|
||||
const stats = { ...v.stats, ...(v.byModule?.uniequip_002_ling?.stats ?? {}) };
|
||||
assert.deepEqual([t3.stats.maxHp, t3.stats.atk, t3.stats.def], [stats.maxHp, stats.atk, stats.def], 'the form\'s stats with the module\'s token attributes');
|
||||
assert.equal(h.b.producesToken(u, 'token_10020_ling_soul3'), true);
|
||||
assert.equal(h.b.producesToken(u, 'token_10020_ling_soul1'), false, 'S3 does not make 清平');
|
||||
const t1 = h.b.tokenDef('token_10020_ling_soul1', u);
|
||||
assert.deepEqual(t1.sources, ['display']);
|
||||
// the same token of the normal form (tier 5, no module) is the form's own variant
|
||||
const h2 = battle([{ diy: { slot: 5, charId: 'char_2023_ling', skillIndex: 0 }, row: 10, col: 4 }]);
|
||||
h2.step();
|
||||
const n1 = h2.b.tokenDef('token_10020_ling_soul1', h2.unit(1));
|
||||
assert.deepEqual([n1.sources, n1.stats.atk], [['skill', 'display'], BACKUPS.tokens.token_10020_ling_soul1.variants['char_2023_ling@2/1/4/0'].stats.atk]);
|
||||
done(h);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('wire info: a 自选 piece carries its pick (defId = the slot; spine / avatar = the operator\'s; no 补位 mark)', () => {
|
||||
const h = battle([{ diy: { slot: 5, charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, elite: true, row: 10, col: 4 }]);
|
||||
h.step();
|
||||
const info = unitInfo(h.unit(1));
|
||||
assert.deepEqual([info.defId, info.spine, info.avatar, info.tier, info.golden, info.skillIndex, info.moduleId, info.standInFor],
|
||||
['chess_char_5_diy1_b', SIEGE, `${SIEGE}_2`, 5, true, 2, 'uniequip_002_siege', undefined]);
|
||||
assert.deepEqual(info.diy, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' });
|
||||
const plain = battle([{ chessId: 'chess_char_1_02_a', row: 10, col: 4 }]);
|
||||
plain.step();
|
||||
assert.equal(unitInfo(plain.unit(1)).diy, undefined);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('a DataSource without backups.json fields no 自选 piece (null def), and its plain DIY slot def is unchanged', () => {
|
||||
const ds = new DataSource({ chess: { [T5]: load('chess')[T5] } }, null);
|
||||
assert.equal(ds.getChess(T5, { diy: { charId: SIEGE, skillIndex: 0 } }), null);
|
||||
assert.equal(ds.getChess(T5).name, '甄选干员');
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
// The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js
|
||||
// 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/).
|
||||
// KIT_FILES, STANDIN_KIT_FILES or OPERATOR_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`); a
|
||||
// 自选 file `op-<codename>.js` registers exactly the kit of the owned-6★ pick with that code name (data/backups.json
|
||||
// `diy.ownedPool`) and writes every skill of the operator under `skills`; the registry is the tier groups in order, then
|
||||
// the stand-ins, then the 自选 operators, and content/index.js serves that object; KITTED_CHARS = the 预备干员 + every
|
||||
// registered stand-in / operator charId; 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, STANDIN_KIT_FILES, STANDIN_KITS } from '../../server/sim/content/kits/index.js';
|
||||
import { KIT_FILES, TIER_KITS, KITS, STANDIN_KIT_FILES, STANDIN_KITS, OPERATOR_KIT_FILES, OPERATOR_KITS, GENERIC_KIT_CHARS, KITTED_CHARS } 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)), '../..');
|
||||
@@ -19,7 +22,7 @@ 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(), ...STANDIN_KIT_FILES];
|
||||
const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES, ...OPERATOR_KIT_FILES];
|
||||
assert.equal(new Set(listed).size, listed.length, 'a file listed twice');
|
||||
assert.deepEqual([...listed].sort(), jsIn('ops'));
|
||||
});
|
||||
@@ -39,6 +42,27 @@ test('a stand-in kit file standin-<codename>.js registers the kit of that stand-
|
||||
}
|
||||
});
|
||||
|
||||
test('a 自选 kit file op-<codename>.js registers the kit of that owned-6★ pick (data/backups.json diy.ownedPool), every skill under `skills`', async () => {
|
||||
const pool = BACKUPS.diy.ownedPool;
|
||||
for (const file of OPERATOR_KIT_FILES) {
|
||||
const m = /^op-([a-z0-9]+)\.js$/.exec(file);
|
||||
assert.ok(m, `${file}: name`);
|
||||
const charId = pool.find((id) => id.replace(/^char_\d+_/, '') === m[1]);
|
||||
assert.ok(charId, `${file}: no owned-6★ pick 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(OPERATOR_KITS[charId], mod.default[charId], `${file}: registered`);
|
||||
assert.equal(KITS[charId], mod.default[charId], `${file}: in the merged registry`);
|
||||
// every skill of every form is authored (a 自选 piece has no default skill: the pick chooses any)
|
||||
for (const [key, form] of Object.entries(BACKUPS.units[charId].forms)) {
|
||||
const kit = mod.default[charId](form.skills[0].bb, { ...form, skill: form.skills[0], charId }, {});
|
||||
assert.deepEqual(Object.keys(kit.skills || {}).sort(), form.skills.map((s) => s.skillId).sort(), `${file}@${key}: skills`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...KITTED_CHARS].sort(), [...new Set([...GENERIC_KIT_CHARS, ...Object.keys(STANDIN_KITS), ...Object.keys(OPERATOR_KITS)])].sort());
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -54,9 +78,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, 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');
|
||||
test('the registry is the tier groups in KIT_FILES order, then the stand-ins, then the 自选 operators, 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), ...Object.keys(OPERATOR_KITS)]);
|
||||
assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length + STANDIN_KIT_FILES.length + OPERATOR_KIT_FILES.length, 'one key per file, none twice');
|
||||
assert.equal(CONTENT_KITS, KITS);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// test/diy.test.js — the 自选 rules of shared/diy.js over the real data (data/chess.json, data/backups.json; DATA.md §18):
|
||||
// the legal picks per tier (diyPool, with and without the kit registry), one pick against a slot (checkDiyPick: the
|
||||
// prototypes' locked skill / module, an owned operator's choice of skill and module at the slot's stage), the composed
|
||||
// record (diyRecord: the slot's identity, no 特质, derived bonds, the operator's body at the slot form) and a whole roster
|
||||
// (validateDiyPicks). Research 0.2.0 §2; the owner's decisions of 2026-10-05.
|
||||
// Run: node --test test/diy.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, DIY_TIERS } from '../shared/diy.js';
|
||||
import { unitForm, composeUnitRecord } from '../shared/standIn.js';
|
||||
import { composeStats, composeTalents } from '../shared/loadoutRecord.js';
|
||||
import { normalizeChess, resolveLoadout, loadoutRecord } from '../server/sim/simdata.js';
|
||||
import { KITTED_CHARS, GENERIC_KIT_CHARS, STANDIN_KITS, OPERATOR_KITS } from '../server/sim/content/kits/index.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 DATA = { chess, backups };
|
||||
const { ownedPool, prototypes } = backups.diy;
|
||||
const ELITES = ['char_608_acpion', 'char_609_acguad', 'char_610_acfend', 'char_611_acnipe', 'char_612_accast', 'char_613_acmedc', 'char_614_acsupo', 'char_615_acspec', 'char_617_sharp2'];
|
||||
const RESERVES5 = ['char_601_cguard', 'char_602_cdfend', 'char_603_csnipe', 'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo'];
|
||||
const COLLAB = ['char_456_ash', 'char_1029_yato2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'];
|
||||
const SIEGE = 'char_112_siege';
|
||||
const T5 = 'chess_char_5_diy1_a', T5B = 'chess_char_5_diy2_a', T6 = 'chess_char_6_diy1_a', T6B = 'chess_char_6_diy2_a';
|
||||
|
||||
test('slots: two per tier (5, 6), each with its elite twin', () => {
|
||||
assert.deepEqual(DIY_TIERS, [5, 6]);
|
||||
assert.deepEqual(diySlotIds(DATA), [T5, T5B, T6, T6B]);
|
||||
assert.deepEqual(diySlot('chess_char_6_diy2_b', DATA) && { ...diySlot('chess_char_6_diy2_b', DATA), normal: undefined, golden: undefined },
|
||||
{ baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, elite: true, normal: undefined, golden: undefined });
|
||||
assert.equal(diySlot('chess_char_5_01_a', DATA), null, 'not a slot');
|
||||
assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3');
|
||||
});
|
||||
|
||||
test('diyPool: tier 5 = 15 prototypes + 72 owned 6★, tier 6 = 9 + 72; no preset, no collab; with the kit registry only kitted operators', () => {
|
||||
const p5 = diyPool(5, { data: DATA }), p6 = diyPool(6, { data: DATA });
|
||||
assert.deepEqual(p5, [...RESERVES5, ...ELITES, ...ownedPool]);
|
||||
assert.deepEqual(p6, [...ELITES, ...ownedPool]);
|
||||
assert.deepEqual([p5.length, p6.length], [87, 81]);
|
||||
const roster = new Set(Object.values(chess).map((c) => c.charId).filter(Boolean));
|
||||
for (const id of p5) assert.ok(!roster.has(id) && !COLLAB.includes(id), id);
|
||||
assert.deepEqual(diyPool(4, { data: DATA }), [], 'no 自选 slot at tier 4');
|
||||
// the kit registry: the 预备干员 run the generic kit (exact for their skills), the nine elites their stand-in kits, the
|
||||
// owned 6★ only with an operator kit file (kits/index.js OPERATOR_KIT_FILES), in pool order
|
||||
assert.deepEqual(GENERIC_KIT_CHARS.filter((id) => prototypes['5'].includes(id)), RESERVES5);
|
||||
for (const id of ELITES) assert.equal(typeof STANDIN_KITS[id], 'function', `${id}: stand-in kit`);
|
||||
const ops = ownedPool.filter((id) => typeof OPERATOR_KITS[id] === 'function');
|
||||
assert.deepEqual([...ops].sort(), Object.keys(OPERATOR_KITS).sort(), 'every operator kit is an owned-6★ pick');
|
||||
assert.deepEqual(diyPool(5, { data: DATA, kitted: KITTED_CHARS }), [...RESERVES5, ...ELITES, ...ops]);
|
||||
assert.deepEqual(diyPool(6, { data: DATA, kitted: KITTED_CHARS }), [...ELITES, ...ops]);
|
||||
assert.deepEqual(diyPool(6, { data: DATA, kitted: (id) => id === SIEGE }), [SIEGE], 'a predicate works too');
|
||||
assert.deepEqual(diyPool(5, { data: DATA, kitted: [] }), [], 'nothing kitted, nothing offered');
|
||||
});
|
||||
|
||||
test('checkDiyPick: a prototype carries its locked skill / module (与系统补位时一致) — omitted ⇒ filled in, another ⇒ refused', () => {
|
||||
for (const [slot, tier] of [[T5, 5], [T6, 6]]) {
|
||||
for (const id of prototypes[tier]) {
|
||||
const lk = lockedSelection(DATA, tier, id);
|
||||
assert.deepEqual(checkDiyPick(slot, { charId: id }, DATA), { ok: true, pick: { charId: id, ...lk } }, `${id}@${tier}`);
|
||||
assert.deepEqual(checkDiyPick(slot, { charId: id, skillIndex: lk.skillIndex, uniEquipId: lk.uniEquipId }, DATA).ok, true);
|
||||
const other = lk.skillIndex === 0 ? 1 : 0;
|
||||
if (unitForm(backups, id, chess[slot].status).skills.some((s) => s.index === other)) {
|
||||
assert.match(checkDiyPick(slot, { charId: id, skillIndex: other }, DATA).error, /prototype carries skill/, `${id}@${tier}: skill-locked`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(lockedSelection(DATA, 5, 'char_609_acguad'), { skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
|
||||
assert.deepEqual(lockedSelection(DATA, 6, 'char_617_sharp2'), { skillIndex: 0, uniEquipId: 'uniequip_002_sharp2' });
|
||||
assert.deepEqual(lockedSelection(DATA, 5, 'char_605_cmedic'), { skillIndex: 2, uniEquipId: null });
|
||||
assert.match(checkDiyPick(T5, { charId: 'char_609_acguad', uniEquipId: 'none' }, DATA).error, /prototype carries module/);
|
||||
assert.equal(lockedSelection(DATA, 5, SIEGE), null, 'an owned operator has no lock');
|
||||
});
|
||||
|
||||
test('checkDiyPick: an owned 6★ chooses any of its 3 skills and any module of its elite form at the slot\'s stage (or none); illegal picks are refused', () => {
|
||||
for (const slot of [T5, T6]) {
|
||||
for (const s of [0, 1, 2]) {
|
||||
for (const m of [undefined, null, 'none', 'uniequip_002_siege', 'uniequip_003_siege']) {
|
||||
const c = checkDiyPick(slot, { charId: SIEGE, skillIndex: s, uniEquipId: m }, DATA);
|
||||
assert.deepEqual(c, { ok: true, pick: { charId: SIEGE, skillIndex: s, uniEquipId: m && m !== 'none' ? m : null } }, `${slot} S${s + 1} ${m}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const bad = [
|
||||
[T5, { charId: SIEGE }, /needs a skill index/],
|
||||
[T5, { charId: SIEGE, skillIndex: 3 }, /no skill 3/],
|
||||
[T5, { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_001_siege' }, /no module uniequip_001_siege/],
|
||||
[T5, { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_002_acguad' }, /no module/],
|
||||
[T6, { charId: 'char_601_cguard' }, /not a tier-6 自选 pick/],
|
||||
[T5, { charId: 'char_600_cpione' }, /not a tier-5/],
|
||||
[T5, { charId: 'char_607_cspec' }, /not a tier-5/],
|
||||
[T5, { charId: 'char_102_texas', skillIndex: 0 }, /not a tier-5/],
|
||||
[T6, { charId: 'char_1012_skadi2', skillIndex: 0 }, /not a tier-6/],
|
||||
[T5, { charId: 'char_456_ash', skillIndex: 0 }, /not a tier-5/],
|
||||
[T5, null, /bad pick/],
|
||||
[T5, { charId: 42 }, /bad pick/],
|
||||
['chess_char_5_01_a', { charId: SIEGE, skillIndex: 0 }, /not a 自选 slot/],
|
||||
];
|
||||
for (const [slot, pick, re] of bad) assert.match(checkDiyPick(slot, pick, DATA).error, re, JSON.stringify(pick));
|
||||
});
|
||||
|
||||
test('diyRecord: the slot\'s identity (tier, price, merge, status), no 特质, derived bonds, the operator\'s body at the slot form; module active on the elite only, stage 1 / 3', () => {
|
||||
const cases = [[T5, false, '2/1/4/0', 0], [T5, true, '2/60/7/1', 1], [T6, false, '2/1/4/0', 0], [T6, true, '2/60/7/3', 3]];
|
||||
for (const [slotId, elite, key, stage] of cases) {
|
||||
for (const mod of [null, 'uniequip_002_siege', 'uniequip_003_siege']) {
|
||||
const r = diyRecord(slotId, { charId: SIEGE, skillIndex: 1, uniEquipId: mod }, { elite, data: DATA });
|
||||
const slot = chess[elite ? backups.diy.slots[slotId].goldenId : slotId];
|
||||
const form = backups.units[SIEGE].forms[key];
|
||||
const label = `${slot.chessId} ${mod}`;
|
||||
assert.deepEqual([r.chessId, r.baseId, r.isGolden, r.tier, r.price, r.sellPrice, r.upgradeNum, r.chessType, r.isDiy], [slot.chessId, slotId, elite, slot.tier, 4, 1, elite ? 0 : 3, 'DIY', true], label);
|
||||
assert.deepEqual([r.charId, r.diyFor, r.name, r.profession, r.rarity], [SIEGE, slotId, '推进之王', 'PIONEER', 6], label);
|
||||
assert.deepEqual([r.garrisonIds, r.bonds, r.standInFor], [[], ['victoriaShip'], undefined], `${label}: no 特质, 维多利亚 from 维多利亚 / 格拉斯哥帮`);
|
||||
assert.deepEqual(r.status, slot.status, label);
|
||||
assert.deepEqual([r.skill.skillId, r.skill.level], ['skchr_siege_2', elite ? 7 : 4], `${label}: S2 at the slot's rank`);
|
||||
assert.deepEqual(r.skills.filter((s) => s.isDefault).map((s) => s.index), [1]);
|
||||
const m = mod && elite ? form.modules.find((x) => x.uniEquipId === mod) : null;
|
||||
assert.deepEqual(r.stats, composeStats(form.stats, m?.attr), `${label}: stats`);
|
||||
assert.deepEqual(r.talents, composeTalents(form.talents, m?.talentChanges), `${label}: talents`);
|
||||
assert.deepEqual(r.trait, m?.traitOverride ?? form.trait, `${label}: trait`);
|
||||
assert.equal(!!r.module?.active, !!m, `${label}: module active`);
|
||||
if (m) assert.equal(r.module.level, stage, `${label}: stage ${stage}`);
|
||||
if (elite) assert.deepEqual(r.modules.map((x) => [x.uniEquipId, x.level, x.isDefault]), form.modules.map((x) => [x.uniEquipId, stage, x.uniEquipId === mod]), label);
|
||||
assert.equal(r.assets.avatar, elite ? `${SIEGE}_2` : SIEGE, `${label}: E2 art on the elite`);
|
||||
// chess-shaped: the loadout machinery and the sim read it unchanged
|
||||
assert.equal(loadoutRecord(r, resolveLoadout(r, null)), r);
|
||||
const def = normalizeChess(r);
|
||||
assert.deepEqual([def.id, def.baseId, def.charId, def.diyFor, def.golden, def.tier, def.skill.id], [slot.chessId, slotId, SIEGE, slotId, elite, slot.tier, 'skchr_siege_2'], label);
|
||||
}
|
||||
}
|
||||
// a prototype's derived bond is 协防; 煌 gets 炎 + 维多利亚 (subPower only); the composed record equals composeUnitRecord's
|
||||
const sharp = diyRecord(T6, { charId: 'char_609_acguad' }, { elite: true, data: DATA });
|
||||
assert.deepEqual([sharp.bonds, sharp.skill.skillId, sharp.module.id, sharp.module.level, sharp.garrisonIds], [['emptyShip'], 'skchr_acguad_3', 'uniequip_002_acguad', 3, []]);
|
||||
assert.deepEqual(diyRecord(T5, { charId: 'char_017_huang', skillIndex: 0 }, { data: DATA }).bonds, ['yanShip', 'victoriaShip']);
|
||||
const slot = chess.chess_char_5_diy2_b;
|
||||
const { diyFor, ...rest } = diyRecordOf(slot, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_003_siege' }, DATA);
|
||||
assert.equal(diyFor, T5B);
|
||||
assert.deepEqual(rest, composeUnitRecord(slot, backups.units[SIEGE], unitForm(backups, SIEGE, slot.status), { skillIndex: 2, moduleId: 'uniequip_003_siege', bonds: ['victoriaShip'] }));
|
||||
assert.equal(diyRecord(T6, { charId: 'char_601_cguard' }, { data: DATA }), null, 'illegal ⇒ null');
|
||||
assert.equal(diyRecordOf(chess.chess_char_5_01_a, { charId: SIEGE, skillIndex: 0 }, DATA), null, 'not a slot record');
|
||||
});
|
||||
|
||||
test('validateDiyPicks: a prototype may fill a tier-5 and a tier-6 slot, an owned operator one slot, the picks of a tier differ; unkitted operators are refused', () => {
|
||||
const sharp = { charId: 'char_609_acguad' };
|
||||
const ok = validateDiyPicks({ [T5]: sharp, [T5B]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, [T6]: sharp, [T6B]: null }, { data: DATA, kitted: [...KITTED_CHARS, SIEGE] });
|
||||
assert.deepEqual(ok, { ok: true, picks: {
|
||||
[T5]: { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' },
|
||||
[T5B]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' },
|
||||
[T6]: { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' },
|
||||
} });
|
||||
assert.deepEqual(validateDiyPicks({}, { data: DATA }), { ok: true, picks: {} }, 'empty slots are fine');
|
||||
const err = (picks, opts = {}) => validateDiyPicks(picks, { data: DATA, ...opts });
|
||||
assert.match(err({ [T5]: sharp, [T5B]: sharp }).detail, /fills two tier-5 slots/);
|
||||
assert.match(err({ [T5]: { charId: SIEGE, skillIndex: 0 }, [T6]: { charId: SIEGE, skillIndex: 1 } }).detail, /an owned operator fills one slot/);
|
||||
assert.match(err({ [T5]: { charId: 'char_017_huang', skillIndex: 0 } }, { kitted: KITTED_CHARS.filter((id) => id !== 'char_017_huang') }).detail, /has no kit yet/);
|
||||
assert.match(err({ [T5]: sharp }, { kitted: (id) => id !== 'char_609_acguad' }).detail, /char_609_acguad has no kit yet/);
|
||||
assert.deepEqual(err({ [T5]: { charId: 'char_017_huang', skillIndex: 0 } }).ok, true, 'without the registry every legal pick passes');
|
||||
assert.match(err({ chess_char_5_01_a: sharp }).detail, /not a 自选 slot/);
|
||||
assert.match(err({ [T6]: { charId: 'char_602_cdfend' } }).detail, /not a tier-6/);
|
||||
assert.match(err({ [T5]: { charId: 'char_609_acguad', skillIndex: 0 } }).detail, /prototype carries skill 2/);
|
||||
assert.deepEqual(err('x'), { error: 'BAD_MSG', detail: 'bad 自选 picks' });
|
||||
assert.equal(err([sharp]).error, 'BAD_MSG');
|
||||
});
|
||||
@@ -17,7 +17,12 @@
|
||||
// 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
|
||||
// names): the composed record (standInRec) replaces that chess id's record for the whole battle.
|
||||
// `{ diy: { slot, charId, skillIndex?, uniEquipId? }, elite?, row, col }` fields a 自选 piece — the
|
||||
// production path (simdata getChess(slotId, { diy }), shared/diy.js): `slot` = a DIY slot's base id
|
||||
// (`chess_char_5_diy1_a`) or its tier (5 / 6 ⇒ that tier's first slot), `elite` = its `_b` form (E2
|
||||
// Lv60, module stage 1 at tier 5 / 3 at tier 6); the operator's kit is KITS[charId]; a prototype may
|
||||
// omit skillIndex / uniEquipId (its locked ones)
|
||||
// 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
|
||||
@@ -179,6 +184,20 @@ function buildData(defs) {
|
||||
return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource());
|
||||
}
|
||||
|
||||
/**
|
||||
* A harness `units[]` entry with `diy` → its PlayerBattleInput fields: `chessId` = the slot's normal or elite id, `diy` =
|
||||
* the pick ({ charId, skillIndex, uniEquipId } as given). Other entries unchanged.
|
||||
*/
|
||||
function diyEntry(u, data = getDefaultSource()) {
|
||||
if (!u || !u.diy || typeof u.diy !== 'object') return u;
|
||||
const slots = data.rawBackups?.()?.diy?.slots ?? {};
|
||||
const { slot, ...pick } = u.diy;
|
||||
const base = typeof slot === 'number' ? Object.keys(slots).find((id) => slots[id].tier === slot) : slot;
|
||||
if (!base || !slots[base]) throw new Error(`diy: unknown slot ${slot}`);
|
||||
const { elite, ...rest } = u;
|
||||
return { ...rest, chessId: elite ? slots[base].goldenId : base, diy: pick };
|
||||
}
|
||||
|
||||
/** `units[].standIn` objects → the composed records for `defs.chess` (null when there are none). */
|
||||
function standInDefs(units) {
|
||||
let out = null;
|
||||
@@ -225,7 +244,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, ...(u.standIn ? { standIn: true } : null) })),
|
||||
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...diyEntry(u), ...(u.standIn ? { standIn: true } : null) })),
|
||||
bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [],
|
||||
}];
|
||||
const hookNames = opts.hooks ?? ALL_HOOKS;
|
||||
|
||||
Reference in new issue
Block a user