A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.
- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
(shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
188 lines
11 KiB
JavaScript
188 lines
11 KiB
JavaScript
// shared/loadoutRecord.js — operator loadouts (DESIGN §16, DATA.md §2.2): a data/chess.json record as the selected
|
||
// skill / module make it. Pure ESM shared by the simulation (server/sim/simdata.js re-exports it: getChess(id, loadout)
|
||
// builds unit defs from it) and the client UI (the detail card shows the stats / 特性 / talents the unit fights with —
|
||
// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait; attackRangeGrid = the range
|
||
// it is deployed with, also the board overlay and the deploy wheel — extendedGrid is the battle's own 攻击距离 growth,
|
||
// re-exported by server/sim/targeting.js). One implementation, so the card and the battle never disagree. (Which
|
||
// choices a player may make: shared/protocol.js loadoutOptions.) A loadout may carry the operator's potential (潜能 1–6,
|
||
// the player's 干员调配 setting since 0.2.2 — shared/potential.js): the record is composed at it first.
|
||
|
||
import { GEO } from './constants.js';
|
||
import { atPotential, isPotential, POTENTIAL_DEFAULT } from './potential.js';
|
||
|
||
/**
|
||
* Resolve a loadout against a chess record. `potential` (潜能 1–6; anything else = 6, full potential — the default)
|
||
* rides along: loadoutRecord composes the record at it (shared/potential.js atPotential).
|
||
* @param {object|null} rec data/chess.json record
|
||
* @param {{ skillIndex?: number, moduleId?: string, skill?: number, module?: string, potential?: number }|null} [loadout]
|
||
* @returns {{ skillIndex: number|null, moduleId: string|null, potential: number, skillIsDefault: boolean,
|
||
* moduleIsDefault: boolean, potentialIsDefault: boolean, isDefault: boolean }|null} null without a record;
|
||
* `moduleId` null for chess without module choices
|
||
*/
|
||
export function resolveRecordLoadout(rec, loadout = null) {
|
||
if (!rec || typeof rec !== 'object') return null;
|
||
const skills = Array.isArray(rec.skills) ? rec.skills : null;
|
||
const defSkill = rec.skill && Number.isInteger(rec.skill.index) ? rec.skill.index : (skills?.find((s) => s && s.isDefault)?.index ?? null);
|
||
const lo = loadout && typeof loadout === 'object' ? loadout : {};
|
||
const wantSkill = lo.skillIndex ?? lo.skill;
|
||
const skillIndex = skills && Number.isInteger(wantSkill) && skills.some((s) => s && s.index === wantSkill) ? wantSkill : defSkill;
|
||
const mods = Array.isArray(rec.modules) ? rec.modules : null;
|
||
const defMod = mods ? (mods.find((m) => m && m.isDefault)?.uniEquipId ?? 'none') : null;
|
||
const wantMod = lo.moduleId ?? lo.module;
|
||
const moduleId = mods && (wantMod === 'none' || (typeof wantMod === 'string' && mods.some((m) => m && m.uniEquipId === wantMod))) ? wantMod : defMod;
|
||
const potential = isPotential(lo.potential) ? lo.potential : POTENTIAL_DEFAULT;
|
||
const skillIsDefault = skillIndex === defSkill;
|
||
const moduleIsDefault = moduleId === defMod;
|
||
const potentialIsDefault = potential === POTENTIAL_DEFAULT;
|
||
return {
|
||
skillIndex, moduleId, potential, skillIsDefault, moduleIsDefault, potentialIsDefault,
|
||
isDefault: skillIsDefault && moduleIsDefault && potentialIsDefault,
|
||
};
|
||
}
|
||
|
||
const clean6 = (v) => (typeof v !== 'number' || !Number.isFinite(v) || Number.isInteger(v) || Math.abs(v) >= 1e6 ? v : Math.round(v * 1e6) / 1e6);
|
||
|
||
/** Stats with a module: the no-module `statsBase` + the module's flat `attr` (same arithmetic as tools/build-data.mjs). */
|
||
export function composeStats(statsBase, attr) {
|
||
const s = { ...(statsBase || {}) };
|
||
for (const [f, v] of Object.entries(attr || {})) s[f] = clean6((s[f] || 0) + v);
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* A talent entry without its potential chain (shared/potential.js: `potMin` / `potBelow`).
|
||
* @param {any} t
|
||
* @returns {any}
|
||
*/
|
||
const unchained = ({ potMin: _m, potBelow: _b, ...t }) => t;
|
||
|
||
/**
|
||
* Talents with a module: apply ModuleRecord.talentChanges to the no-module talents — the merge rule of
|
||
* tools/build-data.mjs mergeTalentChanges (override of an existing index: module values win, base keys the module does
|
||
* not restate are kept; otherwise appended; empty placeholders dropped). The result carries no potential chains: a merged
|
||
* list is composed at one potential (resolve the inputs first — shared/potential.js atPotential).
|
||
*/
|
||
export function composeTalents(base, changes) {
|
||
const talents = (base || []).map(unchained);
|
||
for (const ch0 of changes || []) {
|
||
const { talentIndex, ...rest } = unchained(ch0);
|
||
const rec = { index: talentIndex, ...rest, fromModule: true };
|
||
const at = talentIndex >= 0 ? talents.findIndex((x) => x.index === talentIndex) : -1;
|
||
if (at >= 0) {
|
||
const old = talents[at];
|
||
talents[at] = {
|
||
...rec,
|
||
name: rec.name || old.name, desc: rec.desc ?? old.desc, descRaw: rec.descRaw ?? old.descRaw,
|
||
bb: { ...old.bb, ...rec.bb }, bbStr: { ...old.bbStr, ...rec.bbStr },
|
||
rangeGrid: rec.rangeGrid || old.rangeGrid, tokenKey: rec.tokenKey || old.tokenKey,
|
||
hidden: old.hidden && rec.hidden,
|
||
};
|
||
} else {
|
||
talents.push(rec);
|
||
}
|
||
}
|
||
return talents.filter((t) => t.name || t.desc || Object.keys(t.bb || {}).length || t.tokenKey);
|
||
}
|
||
|
||
/**
|
||
* The chess record as the selected loadout makes it (a new object; the input is never mutated): at a potential below 6
|
||
* the record at that potential first (stats / statsBase / talents / talentsBase / module talent changes — shared/potential.js
|
||
* atPotential); `skill` = the selected SkillRecord; golden chess with a non-default module choice: `stats` = statsBase +
|
||
* module attr, `trait` = the module's traitOverride or traitBase, `talents` = talentsBase + talentChanges, `module` =
|
||
* the chosen module (`active:false`, id null for 'none'). A talent that summons through a container token (凛御银灰)
|
||
* follows the selected skill's token. The default loadout returns `rec` itself.
|
||
* @param {object} rec0 data/chess.json record
|
||
* @param {object} lo resolveRecordLoadout(rec0, …)
|
||
*/
|
||
export function loadoutRecord(rec0, lo) {
|
||
if (!rec0 || !lo || lo.isDefault) return rec0;
|
||
const rec = lo.potentialIsDefault === false ? atPotential(rec0, lo.potential) : rec0;
|
||
const out = { ...rec };
|
||
if (!lo.moduleIsDefault && Array.isArray(rec.modules)) {
|
||
const m = lo.moduleId === 'none' ? null : rec.modules.find((x) => x.uniEquipId === lo.moduleId) ?? null;
|
||
out.stats = composeStats(rec.statsBase ?? rec.stats, m ? m.attr : null);
|
||
out.trait = (m && m.traitOverride) || rec.traitBase || rec.trait;
|
||
out.talents = composeTalents(rec.talentsBase ?? rec.talents, m ? m.talentChanges : null);
|
||
out.module = m
|
||
? { id: m.uniEquipId, name: m.name ?? null, type: m.typeName ?? null, level: m.level ?? rec.module?.level ?? 0, active: true }
|
||
: { id: null, name: null, type: null, level: rec.module?.level ?? 0, active: false };
|
||
}
|
||
if (!lo.skillIsDefault && Array.isArray(rec.skills)) {
|
||
const s = rec.skills.find((x) => x.index === lo.skillIndex);
|
||
if (s) {
|
||
out.skill = s;
|
||
if (rec.assets) out.assets = { ...rec.assets, skillIcon: s.iconId ?? rec.assets.skillIcon };
|
||
const tok = s.overrideTokenKey;
|
||
if (tok && (rec.tokens || []).includes(tok) && (out.talents || []).some((t) => t && t.containerTokenKey)) {
|
||
out.talents = out.talents.map((t) => (t && t.containerTokenKey ? { ...t, tokenKey: tok } : t));
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The attack range a (loadout-resolved) chess record fights with from its deployment — the detail card without a live
|
||
* entry, the board's range overlay and the deploy wheel (DESIGN §16), the same tiles the battle unit starts with (prep
|
||
* m.unitStats `range`): the selected skill's grid when it reads "被动效果:攻击范围扩大" (引星棘刺 S3 3-9: her own range
|
||
* while she carries it, her kit); else an elite whose equipped module reads "攻击范围扩大" uses that module's own grid
|
||
* — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the centre tile [0,3] — as the
|
||
* kits do (kits/shared/tier4.js moduleRangeGrid, shared/tier5.js moduleRangeUp); anything else its `rangeGrid`. Then grown by the 特性's
|
||
* permanent 攻击距离 (traitRangeExtend: 信仰搅拌机 SPT-Y "攻击距离+1"). A running skill's range is the live entry's.
|
||
* @param {object|null} rec loadoutRecord(…) output (or a data/chess.json record: its default module)
|
||
* @returns {number[][]|null}
|
||
*/
|
||
export function attackRangeGrid(rec) {
|
||
if (!rec || typeof rec !== 'object') return null;
|
||
let g = Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null;
|
||
const sk = rec.skill;
|
||
const m = rec.module;
|
||
if (sk && Array.isArray(sk.rangeGrid) && sk.rangeGrid.length && /被动效果:攻击范围扩大/.test(String(sk.desc ?? ''))) {
|
||
g = sk.rangeGrid;
|
||
} else if (rec.isGolden && m && m.active && m.id && /攻击范围扩大/.test(String(rec.trait?.moduleDesc ?? ''))) {
|
||
const mod = (Array.isArray(rec.modules) ? rec.modules : []).find((x) => x && x.uniEquipId === m.id);
|
||
const mg = (mod?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
|
||
if (mg) g = mg;
|
||
}
|
||
const ext = traitRangeExtend(rec);
|
||
return g && ext ? extendedGrid(g, ext) : g;
|
||
}
|
||
|
||
/**
|
||
* The permanent 攻击距离 (ability_range_forward_extend) a record's 特性 grants — a module's, e.g. 信仰搅拌机 SPT-Y
|
||
* "攻击距离+1" (its kit's rangeUp: a persistent rangeExtend buff, s.baseRangeExtend); 0 for one that works "在集成战略中" only
|
||
* (空弦 ISW-A). The other 攻击距离 of the mode are skills' (their running range).
|
||
* @param {object|null} rec loadoutRecord(…) output
|
||
*/
|
||
export function traitRangeExtend(rec) {
|
||
const t = rec && typeof rec === 'object' ? rec.trait : null;
|
||
if (!t || typeof t !== 'object' || /集成战略/.test(String(t.moduleDesc ?? ''))) return 0;
|
||
const n = Math.floor(Number(t.bb?.ability_range_forward_extend) || 0);
|
||
return n > 0 ? n : 0;
|
||
}
|
||
|
||
/**
|
||
* A range grid (`[dRow, dCol]`, facing RIGHT) grown by `extend` (rangeExtend / 攻击距离, DESIGN §3): every row gains
|
||
* the whole tiles 1 … ⌊extend⌋ beyond its far (+dCol) end — the relative form of what server/sim/targeting.js
|
||
* absoluteRangeKeys builds, deduplicated, junk entries dropped. One implementation for the battle (re-exported by
|
||
* targeting.js: Battle._refreshRange keeps it as `unit.liveRangeGrid`, the card's live 攻击范围, when an extend applies)
|
||
* and the record's attackRangeGrid.
|
||
* @param {Array<[number, number]>|null|undefined} grid
|
||
* @param {number} [extend]
|
||
* @returns {Array<[number, number]>}
|
||
*/
|
||
export function extendedGrid(grid, extend = 0) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
const add = (dr, dc) => { const k = `${dr},${dc}`; if (!seen.has(k)) { seen.add(k); out.push([dr, dc]); } };
|
||
if (!Array.isArray(grid)) return out;
|
||
const cells = grid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1]));
|
||
for (const [dr, dc] of cells) add(dr, dc);
|
||
if (extend > 0 && Number.isFinite(extend)) {
|
||
const maxByRow = new Map();
|
||
for (const [dr, dc] of cells) maxByRow.set(dr, Math.max(maxByRow.get(dr) ?? -Infinity, dc));
|
||
for (const [dr, mx] of maxByRow) for (let k = 1; k <= Math.min(extend, GEO.COLS); k++) add(dr, mx + k);
|
||
}
|
||
return out;
|
||
}
|