feedback7 R22-LOADOUT: the data carries every potential (潜能 1–6), not only full potential; golden: unchanged

Potential becomes a per-player setting (the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」;
official: 调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」). 0.2.1 baked full potential into the data (§26.14).

- tools/build-data.mjs runs the chess / backups / tokens builders once per potential rank (ctx.potRank, 0–5) and keeps
  the full-potential build annotated with what each lower rank changes: `potDown` { rank: { dotted.path: value } } on
  chess records, backups.json forms and token variants (stats, statsBase; 望's 棋子 deployLimit / deckStack, 多萝西's
  共振装置 count), and chained talent entries (`potMin` / `potBelow`: the candidates by requiredPotentialRank, the
  fields a lower rank changes) in talents / talentsBase / modules[].talentChanges / variant talents. A self-check proves
  shared/potential.js atRank rebuilds every rank exactly; the unit-form parity runs at every rank.
- shared/potential.js (new): atPotential / atRank, talentAtRank, stripPotential, and the 练度 helpers used next
  (cultivateMul from the effects.json CHAR_MAP records aceffect_char_1…4, cultivationOf, cultivatedStats).
- composeTalents / composeUnitRecord return potential-resolved lists (no chains): a composed record is built at one
  potential (its form resolved first).
- JSON compare: the new chess / backups / tokens minus the annotations equal the old files byte for byte; every other
  data file identical; data/i18n/* rebuilt (the chained talent texts translated at the old rates: en 95.4 %, ja 95.2 %,
  ko 95.2 %, zh-TW 90 %). Data 5.21 → 5.51 MB.
- Docs: DATA.md §2.3 (new), §2, §7, §14, §16, §18; research/03-operators.md; kits/README.md.
- Tests: test/potential.test.js (new, 7): every chess and 自选 form at every potential = the raw keyframes plus the
  potential steps up to it, every talent = the last candidate with requiredPotentialRank ≤ the rank, 刺玫 at each
  potential, summons at the owner's potential (Mon3tr 3 s / 1200, 望's 棋子 6 / 7, “小自在” 15), the 练度 table =
  cultivateEffectList; data.test.js compares the composed talents without annotations.
This commit is contained in:
sganggs committed 2026-10-08 20:29:06 +08:00
1 parent ae22335cfe
commit 7e09c63da8
17 files changed
+614 -69

No files matched your search

+12 -4
View File
@@ -40,15 +40,23 @@ export function composeStats(statsBase, attr) {
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).
* 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((t) => ({ ...t }));
for (const ch of changes || []) {
const { talentIndex, ...rest } = ch;
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) {
+207
View File
@@ -0,0 +1,207 @@
// shared/potential.js — operator potential (潜能 1–6) as a runtime input, and the 自持有 练度 bonus (0.2.2; the owner's
// decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」). Pure ESM shared by the build (tools/build-data.mjs
// proves the composition below rebuilds every record at every potential), the simulation (server/sim/simdata.js), the
// server (PlayerState) and the client (干员调配 / detail cards).
//
// Official (调度手册 autochess_shop_3 「卫戍协议中干员潜能由自身已持有干员潜能决定」; PRTS 卫戍协议 「干员的潜能等级始终以自身
// 持有状态为准」): an operator fights at the player's own potential; the season's chess rows carry none. The data records
// (data/chess.json, data/backups.json forms, the token variants of data/tokens.json and backups.json `tokens`) are
// built at FULL potential (潜能 6 = rank 5, the default) and carry what any lower potential changes (docs/DATA.md §2.3):
// * `potDown` on a record / form / token variant: `{ "<rank>": { "<dotted.path>": value } }` — the exact value at that
// rank of every leaf (stats.cost, statsBase.atk, count, stats.deployLimit, byModule.<id>.stats.deckStack …) that
// differs from the rank above; applied from rank 4 down to the wanted rank (each overwrites), so a leaf ends at its
// value for that rank (the potentialRanks attribute steps: 「部署费用-1」, 「攻击力+22」, 「再部署时间-4秒」 …);
// * on a talent entry (talents / talentsBase / modules[].talentChanges / a variant's talents and byModule talents):
// `potMin` = the lowest rank the entry holds for and `potBelow` = the fields the entry below it changes (desc /
// descRaw / bb …; itself chained): the talent candidates by `requiredPotentialRank` (「天赋效果增强」: 刺玫's
// 土壤基肥改良 11% → 8% below rank 4). A summon's talents follow its OWNER's potential (their candidates mirror the
// owner's steps; DESIGN §26.14).
// Data rank r = potential − 1 (character_table `potentialRanks[r]` is the step from rank r to r + 1).
//
// 练度 (cultivate 0–3, activity_table autoChessData.cultivateEffectList): the effects.json CHAR_MAP records
// aceffect_char_1 … 4 — 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — whose `char_attribute_mul` buff multiplies ATK /
// DEF / max HP (×1 / ×1.05 ATK, DEF / ×1.1 ATK, ×1.05 DEF, HP / ×1.1 all three): a multiplier of its own (the sim's Πmul),
// never summed with the 直接乘算 bonuses (PRTS 盟约记录 「…均为直接乘算,与自持有干员的属性加成独立」). Only an owned operator
// of the same name has it — initial, elite and 自选 forms alike; a 补位 stand-in and a prototype 自选 pick have neither
// potential nor 练度 (`cultivationOf`).
/** Potential bounds (潜能 1–6) and the default (full potential, the data's own). */
export const POTENTIAL_MIN = 1;
export const POTENTIAL_MAX = 6;
export const POTENTIAL_DEFAULT = 6;
/** The data rank every record is built at (potential 6). */
export const FULL_RANK = POTENTIAL_MAX - 1;
/** The 练度 tiers: effects.json CHAR_MAP effect ids in cultivateEffectList order (cultivateNum 1 / 1001 / 2001 / 2060). */
export const CULTIVATE_EFFECTS = Object.freeze(['aceffect_char_1', 'aceffect_char_2', 'aceffect_char_3', 'aceffect_char_4']);
export const CULTIVATE_MAX = CULTIVATE_EFFECTS.length - 1;
/** The default 练度: 精英阶段2-60级 (the owner's 「默认满潜满加成」). */
export const CULTIVATE_DEFAULT = CULTIVATE_MAX;
/** Is `v` a potential (integer 1–6)? */
export const isPotential = (v) => Number.isInteger(v) && v >= POTENTIAL_MIN && v <= POTENTIAL_MAX;
/** Is `v` a 练度 tier (integer 0–3)? */
export const isCultivate = (v) => Number.isInteger(v) && v >= 0 && v <= CULTIVATE_MAX;
/** The data rank of a potential (anything else: full potential). */
export const rankOf = (potential) => (isPotential(potential) ? potential - 1 : FULL_RANK);
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const own = (o, k) => isObj(o) && Object.prototype.hasOwnProperty.call(o, k);
/**
* A talent entry at data rank `rank`: its `potBelow` chain followed while `rank < potMin`, returned without the chain
* fields (a copy only when the entry carries them).
* @param {any} t @param {number} rank
*/
export function talentAtRank(t, rank) {
if (!isObj(t) || !(own(t, 'potMin') || own(t, 'potBelow'))) return t;
const { potMin: _m, potBelow: _b, ...out } = t;
// each `potBelow` holds the fields its rank changes (a partial entry); merged down the chain
let x = t;
while (isObj(x.potBelow) && rank < (Number.isInteger(x.potMin) ? x.potMin : 0)) {
x = x.potBelow;
for (const [k, v] of Object.entries(x)) if (k !== 'potMin' && k !== 'potBelow') out[k] = v;
}
return out;
}
/** A talent list at data rank `rank` (talentAtRank of every entry). */
export function talentsAtRank(list, rank) {
return Array.isArray(list) ? list.map((t) => talentAtRank(t, rank)) : list;
}
/** `obj` with the `potDown` leaves of ranks FULL_RANK − 1 … `rank` applied (copy-on-write; `potDown` dropped). */
function applyPotDown(obj, rank) {
const out = { ...obj };
delete out.potDown;
const down = obj.potDown;
if (!isObj(down)) return out;
const copied = new Set([out]);
for (let r = FULL_RANK - 1; r >= rank; r--) {
const step = down[String(r)];
if (!isObj(step)) continue;
for (const [path, value] of Object.entries(step)) {
const keys = path.split('.');
let cur = out;
for (let i = 0; i < keys.length - 1; i++) {
const k = Array.isArray(cur) ? Number(keys[i]) : keys[i];
const next = cur[k];
if (!next || typeof next !== 'object') { cur = null; break; }
if (!copied.has(next)) { cur[k] = Array.isArray(next) ? next.slice() : { ...next }; copied.add(cur[k]); }
cur = cur[k];
}
if (cur) cur[Array.isArray(cur) ? Number(keys[keys.length - 1]) : keys[keys.length - 1]] = value;
}
}
return out;
}
/**
* A record / backups form / token variant at data rank `rank`: its `potDown` applied and every talent list resolved —
* `talents`, `talentsBase`, `modules[].talentChanges`, `byModule[*].talents` (a variant's `bySkill` holds no talents).
* `rank` ≥ FULL_RANK (or no potential data) returns `obj` itself.
* @template T
* @param {T} obj
* @param {number} rank
* @returns {T}
*/
export function atRank(obj, rank) {
if (!isObj(obj) || !(Number.isInteger(rank) && rank >= 0 && rank < FULL_RANK)) return obj;
const out = applyPotDown(obj, rank);
for (const k of ['talents', 'talentsBase']) if (Array.isArray(out[k])) out[k] = talentsAtRank(out[k], rank);
if (Array.isArray(out.modules)) {
out.modules = out.modules.map((m) => (isObj(m) && Array.isArray(m.talentChanges) ? { ...m, talentChanges: talentsAtRank(m.talentChanges, rank) } : m));
}
if (isObj(out.byModule)) {
const bm = {};
for (const [id, m] of Object.entries(out.byModule)) bm[id] = isObj(m) && Array.isArray(m.talents) ? { ...m, talents: talentsAtRank(m.talents, rank) } : m;
out.byModule = bm;
}
return out;
}
/**
* A data/chess.json record (or a backups form, or a token variant) at `potential` (1–6; anything else = full
* potential, the record itself) — atRank of its data rank.
* @template T
* @param {T} rec
* @param {number|null|undefined} potential
* @returns {T}
*/
export function atPotential(rec, potential) {
return isPotential(potential) && potential < POTENTIAL_MAX ? atRank(rec, potential - 1) : rec;
}
/** Whether a record / form / variant changes below full potential at all (`potDown` or a chained talent list). */
export function hasPotentialData(obj) {
if (!isObj(obj)) return false;
if (isObj(obj.potDown) && Object.keys(obj.potDown).length) return true;
const chained = (list) => Array.isArray(list) && list.some((t) => isObj(t) && isObj(t.potBelow));
if (chained(obj.talents) || chained(obj.talentsBase)) return true;
if (Array.isArray(obj.modules) && obj.modules.some((m) => chained(m?.talentChanges))) return true;
return isObj(obj.byModule) && Object.values(obj.byModule).some((m) => chained(m?.talents));
}
/**
* A deep copy of `v` without the potential annotations (`potDown`, `potMin`, `potBelow`): what a record built at one
* rank looks like (the build's self-check; tests).
*/
export function stripPotential(v) {
if (Array.isArray(v)) return v.map(stripPotential);
if (!isObj(v)) return v;
const out = {};
for (const [k, x] of Object.entries(v)) if (k !== 'potDown' && k !== 'potMin' && k !== 'potBelow') out[k] = stripPotential(x);
return out;
}
// ---- 练度 (自持有) ---------------------------------------------------------------------------------------------------
/**
* The ATK / DEF / max HP multipliers of 练度 tier `cultivate` (0–3): the `char_attribute_mul` buff of its effects.json
* CHAR_MAP record (a key the record does not name is ×1: aceffect_char_1 names only atk 1). Null for no tier / no data.
* @param {any} effects data/effects.json (or a lookup `(id) => record`)
* @param {number|null|undefined} cultivate
* @returns {{ atk: number, def: number, hp: number } | null}
*/
export function cultivateMul(effects, cultivate) {
if (!isCultivate(cultivate)) return null;
const id = CULTIVATE_EFFECTS[cultivate];
const rec = typeof effects === 'function' ? effects(id) : own(effects, id) ? effects[id] : null;
const buff = isObj(rec) && Array.isArray(rec.buffs) ? rec.buffs.find((b) => isObj(b) && b.key === 'char_attribute_mul') : null;
if (!buff) return null;
const bb = isObj(buff.bb) ? buff.bb : {};
const m = (k) => (typeof bb[k] === 'number' && Number.isFinite(bb[k]) && bb[k] > 0 ? bb[k] : 1);
return { atk: m('atk'), def: m('def'), hp: m('max_hp') };
}
/**
* Stats with a 练度 multiplier applied to ATK / DEF / max HP (what the unit's own numbers read: the detail card, the
* 干员调配 局内数值). The sim keeps the multiplier on the unit (units.js Πmul): these are the same products.
* @param {any} stats @param {{ atk: number, def: number, hp: number } | null} mul
*/
export function cultivatedStats(stats, mul) {
if (!isObj(stats) || !mul) return stats;
const out = { ...stats };
if (typeof out.atk === 'number') out.atk *= mul.atk;
if (typeof out.def === 'number') out.def *= mul.def;
if (typeof out.maxHp === 'number') out.maxHp *= mul.hp;
return out;
}
/**
* The potential / 练度 a player's piece of record `rec` fights at, from the player's per-operator settings `ops`
* (`{ [charId]: { potential?, cultivate? } }`; a missing entry or field is the default — 潜能 6, 精英阶段2-60级), or null
* when it has neither: a 补位 stand-in (`standInFor`) or a prototype 自选 pick (`diyProto`) — another character than
* the operator the player owns.
* @param {any} rec a chess record or a composed 自选 / stand-in record
* @param {Record<string, { potential?: number, cultivate?: number }> | null | undefined} ops
* @returns {{ potential: number, cultivate: number } | null}
*/
export function cultivationOf(rec, ops) {
if (!isObj(rec) || rec.standInFor || rec.diyProto || typeof rec.charId !== 'string' || !rec.charId) return null;
const e = own(ops, rec.charId) && isObj(ops[rec.charId]) ? ops[rec.charId] : null;
return {
potential: e && isPotential(e.potential) ? e.potential : POTENTIAL_DEFAULT,
cultivate: e && isCultivate(e.cultivate) ? e.cultivate : CULTIVATE_DEFAULT,
};
}
+7 -4
View File
@@ -15,6 +15,7 @@
// chess's ids, so its kit must be looked up by its `charId`, never by `baseId` (that is the replaced operator's kit).
import { composeStats, composeTalents } from './loadoutRecord.js';
import { stripPotential } from './potential.js';
/** The key of a unit form: a chess record's `status` as `${phase}/${level}/${skillLevel}/${equipLevel}`. */
export function statusKey(status) {
@@ -44,7 +45,9 @@ const byNatural = (a, b) => String(a).localeCompare(String(b), 'en', { numeric:
* The same rules as tools/build-data.mjs buildChess: stats = form stats + the module's `attr`, trait = the module's
* `traitOverride` or the form's, talents = form talents + the module's `talentChanges`; `module` is active only when
* `status.equipLevel > 0` and the module has that level; an elite with `equipLevel > 0` carries
* `statsBase` / `traitBase` / `talentsBase` / `modules[]`; `tokens` are the summons the selection produces.
* `statsBase` / `traitBase` / `talentsBase` / `modules[]`; `tokens` are the summons the selection produces. The record is
* composed at ONE potential: pass the form at the wanted potential (shared/potential.js atPotential — a stand-in has none)
* and the result carries no potential annotation (`potDown`, chained talents).
* @param {object} identity the data/chess.json record whose identity the unit takes (IDENTITY_FIELDS)
* @param {object} unit backups.units[charId]
* @param {object} form backups.units[charId].forms[statusKey(identity.status)]
@@ -63,7 +66,7 @@ export function composeUnitRecord(identity, unit, form, { skillIndex, moduleId =
const skill = skills.find((s) => s && s.index === skillIndex) ?? null;
const mods = Array.isArray(form.modules) ? form.modules : null;
const mod = moduleId && mods ? mods.find((m) => m && m.uniEquipId === moduleId) ?? null : null;
const talents = mod ? composeTalents(form.talents, mod.talentChanges) : form.talents;
const talents = composeTalents(form.talents, mod ? mod.talentChanges : null);
// summons: listed by the character, produced by the selected skill or a talent (buildChess `tokens`)
const known = new Set(form.tokens || []);
const tokenIds = new Set([...(form.displayTokens || []), skill?.overrideTokenKey, ...(talents || []).map((t) => t?.tokenKey)]
@@ -95,8 +98,8 @@ export function composeUnitRecord(identity, unit, form, { skillIndex, moduleId =
if (golden && equipLevel > 0) {
out.statsBase = form.stats;
out.traitBase = form.trait;
out.talentsBase = form.talents;
out.modules = (mods || []).map((m) => ({ ...m, isDefault: m.uniEquipId === moduleId }));
out.talentsBase = stripPotential(form.talents);
out.modules = (mods || []).map((m) => ({ ...stripPotential(m), isDefault: m.uniEquipId === moduleId }));
}
if (standInFor) out.standInFor = standInFor;
return out;