feedback7 R22-LOADOUT: the battle takes each operator's potential and 练度 from its spec entry; golden: unchanged

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.
This commit is contained in:
sganggs committed 2026-10-08 20:38:08 +08:00
1 parent 7e09c63da8
commit ec0a2fc669
16 files changed
+324 -62

No files matched your search

+49 -13
View File
@@ -27,6 +27,10 @@
// 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.
// Potential (潜能 1–6, 0.2.2 — the player's 干员调配 setting; shared/potential.js): a loadout's `potential` composes the
// chess / 自选 record at it (`def.loadout.potential`; full potential 6 is the default and the data's own) and the
// summons' variants at their owner's potential (getToken / getDiyToken: talents, a 自选 summon's deploy limit / count).
// A 补位 stand-in and a prototype 自选 pick have no potential.
//
// 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.
@@ -36,6 +40,7 @@
// getSimData() the injected data (browser) or the generated data (Node), or null.
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
import { atPotential, isPotential, POTENTIAL_DEFAULT, cultivateMul } from '../../shared/potential.js';
import { standInRecord } from '../../shared/standIn.js';
import { diyRecordOf, diyTokenOwner } from '../../shared/diy.js';
import { normHitArea } from './body.js';
@@ -230,7 +235,7 @@ export { composeStats, composeTalents, loadoutRecord };
/** Cache key suffix of a resolved non-default loadout ('' for the default). */
export function loadoutKey(lo) {
return lo && !lo.isDefault ? `|s${lo.skillIndex}|m${lo.moduleId ?? ''}` : '';
return lo && !lo.isDefault ? `|s${lo.skillIndex}|m${lo.moduleId ?? ''}${lo.potentialIsDefault === false ? `|p${lo.potential}` : ''}` : '';
}
/** Build an immunity Set from an object `{stun:true,…}` or an array `['stun', 'feared']`. */
@@ -539,6 +544,7 @@ export class DataSource {
stages: asMap(unwrap(raw.stages, 'stages'), 'id') ?? {},
waves: asMap(unwrap(raw.waves, 'waves'), 'id') ?? {},
backups: raw.backups && typeof raw.backups === 'object' && raw.backups.units ? raw.backups : null,
effects: raw.effects && typeof raw.effects === 'object' ? raw.effects : null,
};
this.fallback = fallback;
this._chess = new Map();
@@ -557,6 +563,20 @@ export class DataSource {
rawWave(id) { return this.raw.waves[id] ?? this.fallback?.rawWave(id) ?? null; }
/** data/backups.json (`{ units, diy }`) of this source or its fallback, or null. */
rawBackups() { return this.raw.backups ?? (typeof this.fallback?.rawBackups === 'function' ? this.fallback.rawBackups() : null); }
/** A data/effects.json record of this source, its fallback, or the default game data (Node / the injected data). */
rawEffect(id) {
const own = this.raw.effects && Object.prototype.hasOwnProperty.call(this.raw.effects, id) ? this.raw.effects[id] : null;
if (own) return own;
if (typeof this.fallback?.rawEffect === 'function') return this.fallback.rawEffect(id);
const fx = generatedCache && generatedCache.effects;
return fx && Object.prototype.hasOwnProperty.call(fx, id) ? fx[id] : null;
}
/**
* The 练度 (自持有) multipliers of tier `cultivate` (0–3): ATK / DEF / max HP from the effects.json CHAR_MAP record's
* char_attribute_mul (shared/potential.js cultivateMul), or null.
* @param {number} cultivate
*/
cultivateMul(cultivate) { return cultivateMul((id) => this.rawEffect(id), cultivate); }
/**
* Chess def for the default loadout, or for `loadout` `{ skillIndex, moduleId }` (DESIGN §16; resolved with
@@ -565,10 +585,11 @@ export class DataSource {
* `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. `loadout.diy` (`{ charId, skillIndex, uniEquipId }`): the 自选 def of a DIY slot
* (getDiy; null when the pick is not legal for that slot).
* (getDiy; null when the pick is not legal for that slot). `loadout.potential` (1–6): the record at that potential
* (loadoutRecord; not for a stand-in).
*/
getChess(id, loadout = null) {
if (loadout && loadout.diy && typeof loadout.diy === 'object') return this.getDiy(id, loadout.diy);
if (loadout && loadout.diy && typeof loadout.diy === 'object') return this.getDiy(id, loadout.diy, loadout.potential);
if (loadout && loadout.standIn === true) {
const sd = this.getStandIn(id);
if (sd) return sd;
@@ -615,19 +636,26 @@ export class DataSource {
* 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).
* or when the data lacks it. An owned pick is composed at `potential` (1–6, default 6; shared/diy.js diyRecordOf —
* `def.loadout.potential`, its summons' variants follow); a prototype has none. Cached per (id, pick, potential).
* @param {string} id
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick
* @param {number|null} [potential]
*/
getDiy(id, pick) {
const key = `${id}|diy|${pick?.charId ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}`;
getDiy(id, pick, potential = null) {
const pot = isPotential(potential) ? potential : POTENTIAL_DEFAULT;
const key = `${id}|diy|${pick?.charId ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}|p${pot}`;
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;
const rec = r && r.isDiy ? diyRecordOf(r, pick, this, { potential: pot }) : 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 } };
const own = rec.diyProto ? POTENTIAL_DEFAULT : pot;
d.loadout = {
...resolveLoadout(rec, null), potential: own, potentialIsDefault: own === POTENTIAL_DEFAULT,
diy: { charId: rec.charId, skillIndex: rec.skill.index, uniEquipId: rec.module?.id ?? null },
};
d.tokenOwner = diyTokenOwner(rec.charId, rec.status);
freezeDef(d);
}
@@ -650,7 +678,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);
if (ownerChessId && ownerLoadout && ownerLoadout.diy && typeof ownerLoadout.diy === 'object') return this.getDiyToken(id, ownerChessId, ownerLoadout.diy, ownerLoadout.potential);
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);
@@ -674,6 +702,8 @@ export class DataSource {
}
if (r && r.variants && !owner) owner = Object.keys(r.variants)[0] ?? null;
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
// the owner's potential (0.2.2): the variant's talents (and counts) at it — before the skill / module parts merge
if (variant && olo && olo.potentialIsDefault === false) variant = atPotential(variant, olo.potential);
if (variant && olo && !olo.isDefault) {
if (!olo.skillIsDefault && variant.bySkill && variant.bySkill[olo.skillIndex]) variant = { ...variant, ...variant.bySkill[olo.skillIndex] };
if (!olo.moduleIsDefault && variant.byModule && variant.byModule[olo.moduleId]) variant = { ...variant, ...variant.byModule[olo.moduleId] };
@@ -691,14 +721,17 @@ export class DataSource {
* @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`
* @param {number|null} [potential] the owner's potential (its `loadout.potential`; default 6): the variant at it
*/
getDiyToken(id, slotId, pick) {
getDiyToken(id, slotId, pick, potential = null) {
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 ?? ''}`;
const pot = isPotential(potential) ? potential : POTENTIAL_DEFAULT;
const ck = `${id}|diy|${owner ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}|p${pot}`;
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) variant = atPotential(variant, pot);
if (variant) {
const si = pick.skillIndex;
if (Number.isInteger(si) && variant.bySkill && variant.bySkill[si]) variant = { ...variant, ...variant.bySkill[si] };
@@ -800,7 +833,7 @@ export function toDataSource(data) {
* 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, and one with `diy` (自选: `{ charId,
* skillIndex, uniEquipId }`) to its 自选 def (`getChess(id, { diy })`).
* skillIndex, uniEquipId }`) to its 自选 def (`getChess(id, { diy })`); a `potential` (1–6) rides along (0.2.2).
* @param {object} ds DataSource
* @param {object[]} players PlayerBattleInput[] (spec players)
*/
@@ -813,13 +846,16 @@ export function withUnitLoadouts(ds, players) {
for (const u of (p && Array.isArray(p.units) ? p.units : [])) {
if (!u || u.kind === 'token' || typeof u.chessId !== 'string') continue;
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 pot = u.standIn !== true && isPotential(u.potential) ? u.potential : null;
if (u.skillIndex != null || u.moduleId != null || u.standIn === true || diy || pot != null) any = true;
const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null };
if (u.standIn === true) lo.standIn = true;
if (diy) lo.diy = diy;
if (pot != null) lo.potential = pot;
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
|| (prev.potential ?? null) !== (lo.potential ?? null)
|| JSON.stringify(prev.diy ?? null) !== JSON.stringify(lo.diy ?? null)) conflicts.add(u.chessId);
}
}