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

+19 -10
View File
@@ -19,11 +19,14 @@
// still field any module, as the kits test them); 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);
// - full potential for everyone, as every chess (the owner's decision of 2026-10-07: the backups.json forms).
// - an owned pick fights at its player's potential and 练度, as every chess (0.2.2, the owner's decision of 2026-10-08 —
// default 潜能 6 / 精英2 Lv.60; the backups.json forms carry the lower potentials, shared/potential.js); a prototype
// pick has neither (`diyProto` on its composed record).
// 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';
import { atPotential } from './potential.js';
/** The tiers that have 自选 slots. */
export const DIY_TIERS = Object.freeze([5, 6]);
@@ -191,40 +194,46 @@ export function checkDiyPick(slotId, pick, data) {
/**
* 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.
* merge, status; no 特质) with the operator's form at the slot's status — an owned pick's at `potential` (1–6, default
* 6: shared/potential.js atPotential) — 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), `charId` (the operator) and, for a prototype pick,
* `diyProto: true` (no potential, no 练度: shared/potential.js cultivationOf). 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
* @param {{ potential?: number|null }} [opts]
* @returns {object|null}
*/
export function diyRecordOf(slot, pick, data) {
export function diyRecordOf(slot, pick, data, { potential = null } = {}) {
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),
const proto = isPrototypePick(data, slot.tier, charId);
const form = unitForm(backups, charId, slot.status);
const rec = composeUnitRecord(slot, unit, proto ? form : atPotential(form, potential),
{ skillIndex, moduleId: uniEquipId, bonds: diyOf(data)?.operators?.[charId]?.bonds ?? null });
if (!rec || !rec.skill) return null;
rec.diyFor = slot.baseId ?? slot.chessId;
if (proto) rec.diyProto = true;
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).
* tier 5 / 3 at tier 6), else the normal form (E2 Lv1, no module); `potential` as diyRecordOf.
* @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
* @param {{ elite?: boolean, data: DiyData, potential?: number|null }} opts
* @returns {object|null}
*/
export function diyRecord(slotId, pick, { elite = false, data } = { data: null }) {
export function diyRecord(slotId, pick, { elite = false, data, potential = null } = { data: null }) {
const slot = diySlot(slotId, data);
if (!slot) return null;
return diyRecordOf(elite || slot.elite ? slot.golden : slot.normal, pick, data);
return diyRecordOf(elite || slot.elite ? slot.golden : slot.normal, pick, data, { potential });
}
/**
+26 -15
View File
@@ -4,16 +4,20 @@
// 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.)
// 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.
* 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 }|null} [loadout]
* @returns {{ skillIndex: number|null, moduleId: string|null, skillIsDefault: boolean, moduleIsDefault: boolean,
* isDefault: boolean }|null} null without a record; `moduleId` null for chess without module choices
* @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;
@@ -26,9 +30,14 @@ export function resolveRecordLoadout(rec, loadout = 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;
return { skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault: skillIsDefault && moduleIsDefault };
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);
@@ -76,16 +85,18 @@ export function composeTalents(base, changes) {
}
/**
* The chess record as the selected loadout makes it (a new object; the input is never mutated): `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} rec data/chess.json record
* @param {object} lo resolveRecordLoadout(rec, …)
* 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(rec, lo) {
if (!rec || !lo || lo.isDefault) return rec;
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;
+5 -2
View File
@@ -4,6 +4,7 @@
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
import { isDroppableChess } from './standIn.js';
import { diySlotIds, validateDiyPicks } from './diy.js';
import { cultivatedStats } from './potential.js';
// ---- tiny validators -------------------------------------------------------
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
@@ -270,7 +271,8 @@ const statView = (x) => ({
/**
* The detail card's stats of a sim unit (server/sim/units.js Unit): its effective stats `s` (the aggregated `unit.s`,
* or the last ones the sim computed) next to its own `unit.base` (no buffs) — max HP, ATK, DEF, RES, attack interval
* or the last ones the sim computed) next to its own numbers — `unit.base` with its 练度 multiplier (`unit.cultMul`,
* 0.2.2: part of the operator's own numbers, as on the record cards; no buffs) — max HP, ATK, DEF, RES, attack interval
* (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the
* `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser
* runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid
@@ -285,7 +287,8 @@ const statView = (x) => ({
* range?: Array<[number, number]> }}
*/
export function unitStatsEntry(u, s = null) {
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
const own = u && u.base && typeof u.base === 'object' ? u.base : {};
const base = u && u.cultMul ? cultivatedStats(own, u.cultMul) : own;
const cur = s && typeof s === 'object' ? s : base;
const range = u?.side !== 'enemy' && Array.isArray(u?.liveRangeGrid)
? u.liveRangeGrid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])).map((p) => [p[0], p[1]])