feedback7: merge fb7-loadout — per-operator 潜能 (1–6) and 练度 (未精英化 / 精英1 / 精英2 / 精英2 Lv.60) set in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (the owner's decision of 2026-10-08); PR #301 ported as one aligned list; #398 the skill frame, #400 REA-Y's heal text
- Conflicts (the branch started at ae22335c): the four i18n packs (union of keys), game.js DetailPanel props (ops + the
voices' voice=true), detailPanel.js resolveDetail (cultOpts + the voices' tap), match/views.js (both helpers),
docs/PLAYING.md and design/network.md merged line by line against the base. test/match/bosshp.test.js's ATK formula
now includes the unit's 练度 multiplier (cultMul).
- Grok review: MERGE AFTER FIXES — the note and the module card at the set potential fixed on the branch.
- Golden: every owned operator ×1.1 ATK / DEF / max HP by default — roster 49, bonds 46, fields 22, matches 18, diy 134
(stand-in battles and prototype picks unchanged). Broad node --test 5666 (0 fail after the bosshp formula; 2 skipped);
lint 0 errors; typecheck; i18n strict clean (1152 msgids).
This commit is contained in:
commit
0ced21d321
82 files changed
+9156
-6840
No files matched your search
+19
-10
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-19
@@ -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);
|
||||
@@ -40,15 +49,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) {
|
||||
@@ -68,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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+62
-9
@@ -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, isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from './potential.js';
|
||||
|
||||
// ---- tiny validators -------------------------------------------------------
|
||||
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||
@@ -70,12 +71,15 @@ export function isBattleResult(v) {
|
||||
// ---- operator loadout (DESIGN §16): room.loadout { entries } -------------------------------------------------
|
||||
|
||||
/**
|
||||
* `room.loadout { entries }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
||||
* (the per-browser loadout of the 干员调配 screen). Structural limits below; the semantic check against the game data
|
||||
* (known visible chess, legal skill index for the normal AND the elite status, legal module of the elite) is
|
||||
* `checkLoadout` — used by the server (lobby, match) and by the client to sanitise a stored loadout before sending.
|
||||
* `room.loadout { entries, ops? }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
||||
* (the per-browser loadout of the 干员调配 screen); `ops` (0.2.2) = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }`,
|
||||
* the player's per-operator 潜能 and 练度 (shared/potential.js; absent / missing = 潜能 6, 精英2 Lv.60 — the owner's
|
||||
* decision of 2026-10-08). Structural limits below; the semantic checks against the game data — known visible chess,
|
||||
* legal skill index for the normal AND the elite status, legal module of the elite (`checkLoadout`); an operator of the
|
||||
* 干员调配 roster or the 自选 owned pool (`checkLoadoutOps`) — are used by the server (lobby, match) and by the client to
|
||||
* sanitise a stored loadout before sending.
|
||||
*/
|
||||
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9 });
|
||||
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9, ops: 256 });
|
||||
/** The "no module" choice of an elite (模组: 不装备). */
|
||||
export const MODULE_NONE = 'none';
|
||||
const isLoadoutEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'skill' || k === 'module')
|
||||
@@ -179,6 +183,52 @@ export function resolveLoadout(loadout, chess, getChess) {
|
||||
return { skillIndex, moduleId };
|
||||
}
|
||||
|
||||
/** One `room.loadout.ops` entry: `{ potential?: 1–6, cultivate?: 0–3 }`, at least one of them. */
|
||||
const isOpsEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'potential' || k === 'cultivate')
|
||||
&& optional(isPotential)(e.potential) && optional(isCultivate)(e.cultivate);
|
||||
/** Structural check of `room.loadout.ops` (0.2.2): a map of ≤ 256 charIds → `{ potential?, cultivate? }`. */
|
||||
export const isLoadoutOps = (v) => isMap(v, LOADOUT_LIMITS.ops, isId, isOpsEntry);
|
||||
|
||||
/**
|
||||
* The operators a player sets a potential / 练度 for (0.2.2): the charIds of the 干员调配 roster — the visible normal chess
|
||||
* (checkLoadout's targets; a hidden chess shares its visible twin's charId: 锡人, 耶拉 …) — and the owned 6★ 自选 picks
|
||||
* (data/backups.json `diy.ownedPool`). A PRESET (特许) operator the player does not own is set by hand (潜能 1, 未精英化:
|
||||
* the official 「未持有的特许按1潜」 and +0 %); a 补位 stand-in or a prototype pick takes neither.
|
||||
* @param {Record<string, any>|null|undefined} chess data/chess.json
|
||||
* @param {any} [backups] data/backups.json
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function cultivationCharIds(chess, backups = null) {
|
||||
const out = new Set();
|
||||
for (const c of Object.values(chess && typeof chess === 'object' ? chess : {})) {
|
||||
if (c && !c.isGolden && !c.isDiy && c.visible !== false && !c.isHidden && (!c.baseId || c.baseId === c.chessId) && isId(c.charId)) out.add(c.charId);
|
||||
}
|
||||
for (const id of Array.isArray(backups?.diy?.ownedPool) ? backups.diy.ownedPool : []) if (isId(id)) out.add(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic check + normalisation of `room.loadout.ops` (0.2.2) — strict like checkLoadout: an operator `isOperator` does
|
||||
* not know (cultivationCharIds) rejects the whole message. Entries equal to the defaults (潜能 6, 练度 3) are dropped, the
|
||||
* rest stored complete `{ potential, cultivate }`. `undefined` / `null` = no settings (`{}`).
|
||||
* @param {any} ops
|
||||
* @param {(charId: string) => boolean} isOperator
|
||||
* @returns {{ ok: true, ops: Record<string, { potential: number, cultivate: number }> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
|
||||
*/
|
||||
export function checkLoadoutOps(ops, isOperator) {
|
||||
if (ops == null) return { ok: true, ops: {} };
|
||||
if (!isLoadoutOps(ops)) return { error: 'BAD_MSG', detail: 'bad operator settings' };
|
||||
const out = {};
|
||||
for (const id of Object.keys(ops)) {
|
||||
if (typeof isOperator !== 'function' || !isOperator(id)) return { error: 'BAD_TARGET', detail: `unknown operator ${id}` };
|
||||
const potential = ops[id].potential ?? POTENTIAL_DEFAULT;
|
||||
const cultivate = ops[id].cultivate ?? CULTIVATE_DEFAULT;
|
||||
if (potential === POTENTIAL_DEFAULT && cultivate === CULTIVATE_DEFAULT) continue;
|
||||
out[id] = { potential, cultivate };
|
||||
}
|
||||
return { ok: true, ops: out };
|
||||
}
|
||||
|
||||
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -276,7 +326,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
|
||||
@@ -292,7 +343,8 @@ const statView = (x) => ({
|
||||
* range?: Array<[number, number]>, dir?: string }}
|
||||
*/
|
||||
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]])
|
||||
@@ -342,8 +394,9 @@ export const C2S = {
|
||||
// host confirmed — a seat that changed hands meanwhile is refused
|
||||
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
|
||||
'room.start': {},
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
||||
'room.loadout': { entries: isLoadoutEntries },
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK — `ops` (0.2.2):
|
||||
// the per-operator 潜能 / 练度 (absent = none set: every operator at 潜能 6, 精英2 Lv.60)
|
||||
'room.loadout': { entries: isLoadoutEntries, ops: isLoadoutOps, $optional: ['ops'] },
|
||||
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
|
||||
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
|
||||
'room.ownership': { notOwned: isNotOwnedList },
|
||||
|
||||
+7
-4
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user