From ec0a2fc669f94ae8774d215131b74c6fbf5d2de4 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 20:38:08 +0800 Subject: [PATCH] =?UTF-8?q?feedback7=20R22-LOADOUT:=20the=20battle=20takes?= =?UTF-8?q?=20each=20operator's=20potential=20and=20=E7=BB=83=E5=BA=A6=20f?= =?UTF-8?q?rom=20its=20spec=20entry;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/SIM.md | 29 +++- server/sim/battle/players.js | 11 +- server/sim/content/index.js | 5 +- server/sim/content/kits/ops/op-doroth.js | 3 +- server/sim/content/kits/ops/op-mcnist.js | 3 +- server/sim/content/kits/shared/summoner.js | 8 +- server/sim/content/tokens.js | 4 +- server/sim/simdata.js | 62 +++++++-- server/sim/snapshot.js | 4 + server/sim/spec.js | 14 +- server/sim/units.js | 15 ++- shared/diy.js | 29 ++-- shared/loadoutRecord.js | 41 +++--- shared/protocol.js | 7 +- test/sim/loadout.test.js | 4 +- test/sim/potential-battle.test.js | 147 +++++++++++++++++++++ 16 files changed, 324 insertions(+), 62 deletions(-) create mode 100644 test/sim/potential-battle.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 08a53a3f..0db377c7 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -531,8 +531,9 @@ ATK / DEF / max HP bonus of the 卫戍 systems — 盟约, 策略 (bands), 装 卫戍协议:盟约 下半/PRTS盟约记录 "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"). Content builds those with `content/support directMods({ atk, def, hp })` (constants.js `DIRECT_BONUS_STACKING` 'add'; 'multiply' = the v2.5 per-source ×(1 + x), which compounded with layers: user report after playtest #6). `Πmul` is for 最终乘算 / "提升至X%" -effects (炎佑 ×1.5 at 9 炎, 虚弱, 停顿 …) and the char_attribute_mul 特质 ("攻击力和生命值+20%", a rune on the base -attributes). `atkFinal` is the **最终加算** (PRTS `A_f = F_t[(A + D_p)(1 + D_t) + F_p]`: added after the 直接乘算, inside +effects (炎佑 ×1.5 at 9 炎, 虚弱, 停顿 …), the char_attribute_mul 特质 ("攻击力和生命值+20%", a rune on the base +attributes) and the unit's 练度 `cultMul` (自持有, the same char_attribute_mul key, 0.2.2: ATK / DEF / max HP of the owned +operator's tier, §12). `atkFinal` is the **最终加算** (PRTS `A_f = F_t[(A + D_p)(1 + D_t) + F_p]`: added after the 直接乘算, inside the 最终乘算): 阿戈尔's devoured base ATK ("基础攻击力(最终加算)", DESIGN §24.7) — a skill's ATK +% does not scale it. Damage multipliers (`dmgDealtMul`, "伤害提升至X%"; `*TakenMul`, 脆弱 / "受到的伤害+X%") multiply each other (PRTS 游戏数据基础 "同种倍率间叠乘"), same-named statuses keep the strongest — catalogue statuses (§3) and the content @@ -1318,7 +1319,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing; `extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor?, diy? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too, board and held pieces alike with the stand-in's art (the owner's recall of the official mode, 2026-10-06), and so do the m.result lineups; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) +- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor?, diy?, potential?, cultivate? }` (`potential` / `cultivate`: an ally operator's potential below 6 and its 练度 tier, 0.2.2 — a teammate's card composes its numbers from them) (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too, board and held pieces alike with the stand-in's art (the owner's recall of the official mode, 2026-10-06), and so do the m.result lineups; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`spine`/`avatar` are asset ids from data). - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); @@ -1405,8 +1406,14 @@ never displaced, §6), tags, abilities, skills, talent, raw`. Tokens use `varian `skcom_withdraw` token skills are ignored. Operator loadouts (DESIGN §16, DATA.md §2.2 / §14): `getChess(id, { skillIndex, moduleId })` = the def with the selected skill (`def.skill`, its bb / SP / trigger) and, for elites, the module's stats / trait / talents; `def.loadout` = the -resolved loadout `{ skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault }` (illegal choices fall back to the -default; cached per loadout; the default loadout is the same object as `getChess(id)`). `getToken(id, ownerChessId, +resolved loadout `{ skillIndex, moduleId, potential, skillIsDefault, moduleIsDefault, potentialIsDefault, isDefault }` +(illegal choices fall back to the default; cached per loadout; the default loadout is the same object as `getChess(id)`). +Potential (0.2.2, DATA.md §2.3, shared/potential.js): a loadout's `potential` (潜能 1–6; default 6 = the data's full +potential) composes the record at it first (`loadoutRecord` → `atPotential`: stats, statsBase, talents, module talent +changes); `getToken` composes the owner variant at the owner's `loadout.potential` (talents; a 自选 summon's deploy +limit / holding / count) and `getDiy(slotId, pick, potential)` an owned pick's form (a prototype has none: `diyProto`); +the kits that read a summon variant raw (content/tokens.js `ownVariant`, `kits/shared/summoner.js tokenStat`, 多萝西, +麦克尼斯特) compose it the same way. `getToken(id, ownerChessId, ownerLoadout)` = the summon for that owner loadout (`bySkill` / `byModule` merged; `def.sources` ⊆ ['talent', 'skill', 'display'] and `def.count` of that loadout — `[]` / `['display']`: the owner does not make it). Every Battle gets a per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owner)` use the loadout the inputs give that @@ -1418,7 +1425,17 @@ the source's `backups` (data/backups.json; `rawBackups()` follows the fallback c the replaced operator's, `loadout` the backup selection (`standIn: true`); its skill / module ignore the loadout's ("对于补位干员其技能不可更改", shared/standIn.js). A PRESET / DIY chess or a source without backups (a browser that did not fetch backups.json) gives the chess's own def. A PlayerBattleInput entry carries it as `standIn: true` (spec.js keeps -nothing else); the per-battle view maps id-only lookups of that chess id to the stand-in. +nothing else — no `potential` / `cultivate`: a stand-in has neither); the per-battle view maps id-only lookups of that +chess id to the stand-in. +潜能 / 练度 in a PlayerBattleInput operator entry (0.2.2; the owner's decision of 2026-10-08 — the player's 干员调配 settings, +PlayerState.battleInput states both for every owned operator, bots the defaults 6 / 3): `potential` (1–6) → the def of +`getChess(chessId, { …, potential })`; `cultivate` (0–3) → `unit.cultivate` and `unit.cultMul` = `data.cultivateMul(n)`, +the effects.json CHAR_MAP `aceffect_char_` `char_attribute_mul` (×ATK / ×DEF / ×max HP: 1 / 1.05·1.05·1 / 1.1·1.05·1.05 +/ 1.1 each), a multiplier of its own in the Πmul of §2 (never summed with the 直接乘算 percentages: PRTS 盟约记录 +「…与自持有干员的属性加成独立」); a stand-in or a prototype 自选 def never takes it, summons neither [ASSUMED: the official +buff names the operator]. spec.js keeps each only when well-formed (never on a stand-in or a summon); absent = full +potential and no 练度 (a raw unit: tests, tools). `unitStatsEntry` counts the 练度 in the unit's own numbers (`base`), as +the record cards do; UnitInfo carries `potential` (below 6) and `cultivate`. 自选 (DATA.md §18): `getChess(slotId, { diy })` = `getDiy(slotId, pick)` — shared/diy.js `diyRecordOf` of the DIY slot record (`_a` normal, `_b` elite) with the pick over this source, normalised: `id` / `baseId` / `golden` / `tier` of the slot, `bonds` the pick's derived ones, `raw.garrisonIds` `[]` (no 特质), every combat field of the operator at the slot's diff --git a/server/sim/battle/players.js b/server/sim/battle/players.js index bed7dc89..8989193e 100644 --- a/server/sim/battle/players.js +++ b/server/sim/battle/players.js @@ -9,6 +9,7 @@ import { normDir, mirrorDir } from '../dir.js'; import { SkillRuntime } from '../skills.js'; import { resolveProfile } from '../professions.js'; import { normalizeToken } from '../simdata.js'; +import { isPotential, isCultivate } from '../../../shared/potential.js'; import { setupUnitKit } from '../content/index.js'; import { clone } from './util.js'; @@ -141,10 +142,12 @@ export class BattlePlayers { // the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's // choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups); `standIn: // true` fields the chess as its 补位 stand-in (simdata getStandIn: the stand-in's body, the chess's identity); `diy` - // fills a 自选 slot with its pick (simdata getDiy: the slot's identity, the operator's body, skill and module) + // fills a 自选 slot with its pick (simdata getDiy: the slot's identity, the operator's body, skill and module); + // `potential` (潜能 1–6, 0.2.2) composes the def at it (never a stand-in's) const lo = { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null }; if (inp.standIn === true) lo.standIn = true; if (inp.diy && typeof inp.diy === 'object') lo.diy = inp.diy; + if (inp.standIn !== true && isPotential(inp.potential)) lo.potential = inp.potential; const def = this.data.getChess(inp.chessId, lo); if (!def) { this.log(`unknown chess ${inp.chessId}${lo.diy ? ' (illegal 自选 pick)' : ''}`); return null; } // a DIY slot has no body of its own (甄选干员): it fights only as a 自选 piece (its `diy` pick) @@ -152,6 +155,12 @@ export class BattlePlayers { const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir }); u.items = [...(inp.items ?? [])]; u.carry = inp.carryState ?? null; + // 练度 (自持有, 0.2.2): the owned operator's ×ATK / ×DEF / ×max HP (units.js Πmul) — never a 补位 stand-in's nor a + // prototype 自选 pick's (another character than the one the player owns) + if (isCultivate(inp.cultivate) && !def.standInFor && !def.raw?.diyProto) { + const mul = typeof this.data.cultivateMul === 'function' ? this.data.cultivateMul(inp.cultivate) : null; + if (mul) { u.cultivate = inp.cultivate; u.cultMul = Object.freeze({ ...mul }); u.markDirty(); } + } return u; } diff --git a/server/sim/content/index.js b/server/sim/content/index.js index 42873676..7ae3f221 100644 --- a/server/sim/content/index.js +++ b/server/sim/content/index.js @@ -28,6 +28,7 @@ import { genericKit, genericTalents } from './generic.js'; import { withUnitLoadouts } from '../simdata.js'; +import { isPotential } from '../../../shared/potential.js'; // Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing // top-level code, missing file) is logged and replaced by an empty module instead of breaking the server. @@ -136,7 +137,8 @@ function inputEntry(unit) { * The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means * the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a * multi-player field). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say), as - * does `diy` (自选: the pick of a DIY slot's piece). Null when the unit has no input entry. + * do `diy` (自选: the pick of a DIY slot's piece) and `potential` (0.2.2: 1–6, never a stand-in's). Null when the unit + * has no input entry. */ function inputLoadout(unit) { const x = inputEntry(unit); @@ -144,6 +146,7 @@ function inputLoadout(unit) { const extra = { ...(x.standIn === true ? { standIn: true } : null), ...(x.diy && typeof x.diy === 'object' ? { diy: x.diy } : null), + ...(x.standIn !== true && isPotential(x.potential) ? { potential: x.potential } : null), }; if (x.skillIndex == null && x.moduleId == null) return extra; return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...extra }; diff --git a/server/sim/content/kits/ops/op-doroth.js b/server/sim/content/kits/ops/op-doroth.js index f25da316..90292bb5 100644 --- a/server/sim/content/kits/ops/op-doroth.js +++ b/server/sim/content/kits/ops/op-doroth.js @@ -58,6 +58,7 @@ import { num, talentBb, moduleBb, traitBb, up, toggleBuff, summonTileFree } from import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js'; import { bodyOnTile } from '../../../body.js'; import { COLS } from '../../../constants.js'; +import { atPotential } from '../../../../../shared/potential.js'; const S1 = 'skchr_doroth_1'; const S2 = 'skchr_doroth_2'; @@ -112,7 +113,7 @@ export default { /** The token's deploy limit for her loadout (variant stats, the module's when it carries one); her max stock too. */ function limitOf(battle, unit) { const raw = battle.data.rawToken?.(RESONATOR); - const v = raw?.variants?.[unit.def?.tokenOwner]; + const v = atPotential(raw?.variants?.[unit.def?.tokenOwner], unit.def?.loadout?.potential); const mod = unit.def?.loadout?.moduleId; const n = num(v?.byModule?.[mod]?.stats?.deployLimit, num(v?.stats?.deployLimit, num(raw?.deployLimit, 0))); if (n > 0) return n; diff --git a/server/sim/content/kits/ops/op-mcnist.js b/server/sim/content/kits/ops/op-mcnist.js index 31cd8bc2..fe4722f7 100644 --- a/server/sim/content/kits/ops/op-mcnist.js +++ b/server/sim/content/kits/ops/op-mcnist.js @@ -59,6 +59,7 @@ import { num, skillRec, batMod, up } from '../shared/tier1.js'; import { COLS } from '../../../constants.js'; import { dirVec } from '../../../dir.js'; import { hypot } from '../../../detmath.js'; +import { atPotential } from '../../../../../shared/potential.js'; const S1 = 'skchr_mcnist_1'; const S2 = 'skchr_mcnist_2'; @@ -284,7 +285,7 @@ export default { }, talents: [ { install(battle, unit) { // 结构性原理: her pieces run the token's kit (set up before the battle starts) - const raw = battle.data.rawToken?.(MCGRAF)?.variants?.[unit.def?.tokenOwner] ?? null; + const raw = atPotential(battle.data.rawToken?.(MCGRAF)?.variants?.[unit.def?.tokenOwner] ?? null, unit.def?.loadout?.potential); for (const t of battle.allyUnits) { if (!isGrafOf(t, unit) || t.alive || t.deployed) continue; // its life: the base variant's (the SO-A stage-3 skill@duration −1 is 集成战略-only) diff --git a/server/sim/content/kits/shared/summoner.js b/server/sim/content/kits/shared/summoner.js index 41e4df46..c3785449 100644 --- a/server/sim/content/kits/shared/summoner.js +++ b/server/sim/content/kits/shared/summoner.js @@ -27,6 +27,7 @@ // its owner), else the engine's removal reason. import { num, up } from './tier1.js'; +import { atPotential } from '../../../../../shared/potential.js'; /** Seconds between two tries of a piece waiting to come back (its owner off the field, timer, holding, DP, tile). */ export const DECK_RETRY = 0.25; @@ -73,8 +74,9 @@ export function summonTriggerArea(battle, owner, skillId, area) { } /** - * A field of a summon's token record at its owner's form and module (data/backups.json `tokens[id].variants[@ - * ]`, `byModule[uniEquipId]`): the stats the normalised def drops (`deployLimit`, `deckStack`). + * A field of a summon's token record at its owner's form, module and potential (data/backups.json `tokens[id].variants[@ + * ]`, `byModule[uniEquipId]`; shared/potential.js atPotential): the stats the normalised def drops (`deployLimit`, + * `deckStack`). * @param {object} battle * @param {object} owner the summoner unit * @param {string} tokenId @@ -82,7 +84,7 @@ export function summonTriggerArea(battle, owner, skillId, area) { */ export function tokenStat(battle, owner, tokenId, key) { const raw = battle.data?.rawToken?.(tokenId) ?? null; - const v = raw?.variants?.[owner?.def?.tokenOwner] ?? null; + const v = atPotential(raw?.variants?.[owner?.def?.tokenOwner] ?? null, owner?.def?.loadout?.potential); const mod = owner?.def?.loadout?.diy?.uniEquipId ?? owner?.def?.loadout?.moduleId ?? null; const st = (mod && v?.byModule?.[mod]?.stats) || v?.stats || raw?.stats || null; return st && Number.isFinite(st[key]) ? st[key] : (Number.isFinite(raw?.[key]) ? raw[key] : null); diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 77f8a7c4..00dea798 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -93,6 +93,7 @@ import { bardRegen } from '../professions.js'; import { normDir, localOrder } from '../dir.js'; import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js'; import { hypot } from '../detmath.js'; +import { atPotential } from '../../../shared/potential.js'; const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d)); const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); @@ -157,7 +158,8 @@ function ownVariant(raw, owner) { const oid = owner?.def?.tokenOwner ?? owner?.defId; if (!vs || typeof vs !== 'object' || !oid) return null; const v = vs[oid] ?? vs[String(oid).replace(/_b$/, '_a')] ?? null; - return v ? withLoadout(v, owner.def?.loadout) : null; + // at the owner's potential (0.2.2: a 自选 summon's deploy limit / count, the talents — shared/potential.js) + return v ? withLoadout(atPotential(v, owner.def?.loadout?.potential), owner.def?.loadout) : null; } /** diff --git a/server/sim/simdata.js b/server/sim/simdata.js index 70921635..2ab6e468 100644 --- a/server/sim/simdata.js +++ b/server/sim/simdata.js @@ -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); } } diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 8c1794bb..00815524 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -59,6 +59,10 @@ export function unitInfo(u) { standInFor: u.side === 'ally' && u.kind === 'op' && typeof d.standInFor === 'string' ? d.standInFor : undefined, // 自选: the pick of a DIY slot's piece (the detail card composes its record, shared/diy.js diyRecord) diy: u.side === 'ally' && u.kind === 'op' && d.diyFor && d.loadout?.diy ? { ...d.loadout.diy } : undefined, + // 0.2.2: an ally operator's potential below 6 and its 练度 tier (a teammate's unit shows ITS owner's numbers; absent: + // full potential, no 练度 — a stand-in, a prototype 自选 pick) + potential: u.side === 'ally' && u.kind === 'op' && d.loadout?.potentialIsDefault === false ? d.loadout.potential : undefined, + cultivate: u.side === 'ally' && u.kind === 'op' && Number.isInteger(u.cultivate) ? u.cultivate : undefined, }; } diff --git a/server/sim/spec.js b/server/sim/spec.js index 225099a1..df6bcdcb 100644 --- a/server/sim/spec.js +++ b/server/sim/spec.js @@ -25,9 +25,15 @@ // 自选 (DATA.md §18): an operator entry of a DIY slot (`chessId` its `_a` / `_b` id) carries `diy: { charId, skillIndex?, // uniEquipId? }` (kept when well-formed; the data layer checks legality — shared/diy.js checkDiyPick) and is fielded as // simdata getChess(id, { diy }). +// Potential and 练度 (0.2.2, shared/potential.js — the player's 干员调配 settings, the owner's decision of 2026-10-08): an +// operator entry may carry `potential` (潜能 1–6: the def at that potential, its summons' talents at it) and +// `cultivate` (练度 0–3: the effects.json CHAR_MAP aceffect_char_1…4 — a ×ATK / ×DEF / ×max HP of the unit, its own +// multiplier); each kept only when well-formed and never on a stand-in. Absent: full potential and no 练度 (a raw +// unit — the match always states both for an owned operator: PlayerState.battleInput). import { Battle } from './Battle.js'; import { toDataSource, withUnitLoadouts } from './simdata.js'; +import { isPotential, isCultivate } from '../../shared/potential.js'; import { BOSS_POOL_MIN_HP } from './constants.js'; export const SPEC_VERSION = 1; @@ -101,14 +107,18 @@ function cleanDiyPick(d) { /** * Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality), `standIn` only when - * exactly `true` (补位) and `diy` only as a well-formed pick (自选). Mutates `u`. + * exactly `true` (补位), `diy` only as a well-formed pick (自选), `potential` (1–6) / `cultivate` (0–3) only when + * well-formed and not on a stand-in. Mutates `u`. */ export function sanitizeUnitLoadout(u) { if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex; if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId; if ('standIn' in u && u.standIn !== true) delete u.standIn; if ('diy' in u) { const d = cleanDiyPick(u.diy); if (d) u.diy = d; else delete u.diy; } - if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; delete u.diy; } + if ('potential' in u && !isPotential(u.potential)) delete u.potential; + if ('cultivate' in u && !isCultivate(u.cultivate)) delete u.cultivate; + if (u.standIn === true) { delete u.potential; delete u.cultivate; } + if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; delete u.diy; delete u.potential; delete u.cultivate; } return u; } diff --git a/server/sim/units.js b/server/sim/units.js index 42412ab4..431c487e 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -1,7 +1,7 @@ // server/sim/units.js — Unit model (operators, tokens, enemies, devices) and stat aggregation (DESIGN §5.2). // // Aggregation (recomputed lazily whenever buffs change — `unit.markDirty()`): -// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul +// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul (Πmul includes the unit's 练度 `cultMul`, 0.2.2) // res = clamp((base + Σflat) × Πmul, 0, 100) // aspd = clamp(base + Σaspd, 20, 600) (base is 100 for almost everyone; floor 20 = PRTS 数值范围) // interval = bat × (1 + ΣbatPct) × 100 / aspd (ΣbatPct floored at −0.9) @@ -16,6 +16,8 @@ import { DIR_VEC, normDir } from './dir.js'; const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); const EMPTY = Object.freeze({}); +/** No 练度 multiplier (a unit without one: enemies, summons, stand-ins, a raw spec unit). */ +const NO_MUL = Object.freeze({ atk: 1, def: 1, hp: 1 }); /** Aggregates can overflow (huge stacked *Mul mods → Infinity) — fall back to `d` so no stat is ever non-finite. */ const fin = (v, d) => (Number.isFinite(v) ? v : d); @@ -97,6 +99,10 @@ export class Unit { this.persist = { redeployMul: 1, freeRedeploys: 0 }; this.isBoss = false; this.bossPool = null; + // 练度 (自持有, 0.2.2; battle/players.js): the owned operator's tier (0–3, effects.json aceffect_char_1…4) and its + // char_attribute_mul — ATK / DEF / max HP, a multiplier of its own (never summed with the 直接乘算 percentages) + this.cultivate = null; + this.cultMul = null; } markDirty() { this._dirty = true; } @@ -123,12 +129,13 @@ export class Unit { const a = (k) => add[k] ?? 0; const m = (k) => mul[k] ?? 1; const b = this.base; + const cm = this.cultMul || NO_MUL; const bHp = fin(b.maxHp, 1) > 0 ? fin(b.maxHp, 1) : 1; - const maxHp = Math.max(1, fin((bHp + a('hpFlat')) * Math.max(0, 1 + a('hpPct')) * m('hpMul'), bHp)); + const maxHp = Math.max(1, fin((bHp + a('hpFlat')) * Math.max(0, 1 + a('hpPct')) * m('hpMul') * cm.hp, bHp)); // PRTS 游戏数据基础 属性基本公式 A_f = F_t[(A + D_p)(1 + D_t) + F_p]: `atkFinal` is the 最终加算 (FINAL_ADDITION) — // added after the percentages, inside the Πmul (阿戈尔's devoured base ATK, DESIGN §24.7) - const atk = Math.max(0, fin(((b.atk + a('atkFlat')) * Math.max(0, 1 + a('atkPct')) + a('atkFinal')) * m('atkMul'), fin(b.atk, 0))); - const def = Math.max(0, fin((b.def + a('defFlat')) * Math.max(0, 1 + a('defPct')) * m('defMul'), fin(b.def, 0))); + const atk = Math.max(0, fin(((b.atk + a('atkFlat')) * Math.max(0, 1 + a('atkPct')) + a('atkFinal')) * m('atkMul') * cm.atk, fin(b.atk, 0))); + const def = Math.max(0, fin((b.def + a('defFlat')) * Math.max(0, 1 + a('defPct')) * m('defMul') * cm.def, fin(b.def, 0))); const res = clamp(fin((b.res + a('resFlat')) * m('resMul'), fin(b.res, 0)), 0, 100); const aspd = clamp(fin(b.aspd + a('aspd'), 100), ASPD_MIN, ASPD_MAX); const bBat = fin(b.bat, 1) > 0 ? fin(b.bat, 1) : 1; diff --git a/shared/diy.js b/shared/diy.js index ae17534a..d283dad0 100644 --- a/shared/diy.js +++ b/shared/diy.js @@ -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 }); } /** diff --git a/shared/loadoutRecord.js b/shared/loadoutRecord.js index a4cf6aef..930aed11 100644 --- a/shared/loadoutRecord.js +++ b/shared/loadoutRecord.js @@ -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; diff --git a/shared/protocol.js b/shared/protocol.js index 19282a2a..3a25e688 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -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]]) diff --git a/test/sim/loadout.test.js b/test/sim/loadout.test.js index 8c4e942c..e516d2cb 100644 --- a/test/sim/loadout.test.js +++ b/test/sim/loadout.test.js @@ -24,7 +24,7 @@ const WTRMAN = 'token_10030_mlyss_wtrman'; test('resolveLoadout: legal choices resolve, anything else falls back to the default', { skip }, () => { const n = C[INSIDE]; - assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, skillIsDefault: true, moduleIsDefault: true, isDefault: true }); + assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, potential: 6, skillIsDefault: true, moduleIsDefault: true, potentialIsDefault: true, isDefault: true }); assert.equal(resolveLoadout(n, { skillIndex: 0 }).skillIndex, 0); assert.equal(resolveLoadout(n, { skill: 0 }).skillIndex, 0, 'client shape { skill, module } accepted'); assert.equal(resolveLoadout(n, { skillIndex: 2 }).skillIndex, 1, 'S3 is not unlocked at E1 → default'); @@ -80,7 +80,7 @@ test('getChess(id, loadout): selected skill (bb, SP, trigger) and module (stats, assert.equal(s1.skill.id, 'skchr_inside_1'); assert.deepEqual(s1.skill.bb, C[INSIDE].skills[0].bb); assert.equal(s1.skill.spCost, C[INSIDE].skills[0].spCost); - assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, skillIsDefault: false, moduleIsDefault: true, isDefault: false }); + assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, potential: 6, skillIsDefault: false, moduleIsDefault: true, potentialIsDefault: true, isDefault: false }); assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats'); assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def'); assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated'); diff --git a/test/sim/potential-battle.test.js b/test/sim/potential-battle.test.js new file mode 100644 index 00000000..48a2e82b --- /dev/null +++ b/test/sim/potential-battle.test.js @@ -0,0 +1,147 @@ +// test/sim/potential-battle.test.js — potential and 练度 on a battle unit (0.2.2, the owner's decision of 2026-10-08: +// 「调配干员里自己设置吧,默认满潜满加成」): a PlayerBattleInput operator entry's `potential` (潜能 1–6) composes its def at +// that potential (stats, talents, its summons' talents at the owner's potential), its `cultivate` (练度 0–3: effects.json +// aceffect_char_1…4 char_attribute_mul) multiplies ATK / DEF / max HP on top of every other bonus (Πmul, never summed +// with the 直接乘算 percentages: PRTS 盟约记录 「…与自持有干员的属性加成独立」); a stand-in and a prototype 自选 pick take +// neither. The spec keeps both when well-formed, so the browser and the server field the same unit (docs/SIM.md §12). +// Run: node --test test/sim/potential-battle.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle } from '../helpers/battleHarness.js'; +import { buildBattleSpec, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js'; +import { getDefaultSource } from '../../server/sim/simdata.js'; +import { unitInfo } from '../../server/sim/snapshot.js'; +import { unitStatsEntry } from '../../shared/protocol.js'; +import { tokenStat } from '../../server/sim/content/kits/shared/summoner.js'; + +const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET): E1 Lv55, 潜能 6 = ATK 435 / cost 15 / 66 s +const T6 = 'chess_char_6_diy1_a'; +const unit = (h, id) => h.b.allyUnits.find((u) => u.defId === id && u.kind === 'op'); +const talentBb = (u, name) => u.def.talents.find((t) => t.name === name)?.bb; + +test('potential on a battle unit: the def at that potential (stats, redeploy, cost, talent), 潜能 6 / absent = the data record', () => { + const at = (potential) => { + const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, ...(potential != null ? { potential } : null) }], timeLimit: 1 }); + const u = unit(h, VENDLA); + return [u.base.atk, u.base.cost, u.base.respawnTime, talentBb(u, '土壤基肥改良').heal_scale, u.def.loadout.potential]; + }; + assert.deepEqual(at(1), [413, 17, 70, 1.08, 1]); + assert.deepEqual(at(3), [413, 16, 66, 1.08, 3]); + assert.deepEqual(at(5), [435, 16, 66, 1.11, 5]); + assert.deepEqual(at(6), [435, 15, 66, 1.11, 6]); + assert.deepEqual(at(null), [435, 15, 66, 1.11, 6], 'absent: full potential'); + // the elite with another module: statsBase at the potential + the module's attr + const h = makeBattle({ units: [{ chessId: 'chess_char_1_06_b', moduleId: 'none', potential: 1, row: 10, col: 5 }], timeLimit: 1 }); + assert.deepEqual([unit(h, 'chess_char_1_06_b').base.atk, unit(h, 'chess_char_1_06_b').base.cost], [508, 17]); +}); + +test('练度 on a battle unit: ×ATK / ×DEF / ×max HP of its tier, a multiplier of its own on top of the percentages', () => { + const field = (cultivate, extra = {}) => { + const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, ...(cultivate != null ? { cultivate } : null), ...extra }], timeLimit: 1 }); + return unit(h, VENDLA); + }; + const raw = field(null); + assert.deepEqual([raw.s.atk, raw.s.def, raw.s.maxHp, raw.cultivate, raw.cultMul], [435, 77, 1137, null, null], 'absent: no 练度'); + const t = (c) => { const u = field(c); return [u.s.atk, u.s.def, u.s.maxHp]; }; + assert.deepEqual(t(0), [435, 77, 1137], '未精英化 +0%'); + assert.deepEqual(t(1), [435 * 1.05, 77 * 1.05, 1137], '精英1: ATK / DEF +5%'); + assert.deepEqual(t(2), [435 * 1.1, 77 * 1.05, 1137 * 1.05], '精英2: ATK +10%, DEF / HP +5%'); + assert.deepEqual(t(3), [435 * 1.1, 77 * 1.1, 1137 * 1.1], '精英2 Lv.60: +10% each'); + // with a 直接乘算 bonus (+20 % ATK) the tier multiplies the result — never 1 + 0.2 + 0.1 + const u = field(3); + u.buffs.push({ key: 'test:atk', mods: { atkPct: 0.2 }, timeLeft: Infinity, duration: Infinity, stacks: 1, maxStacks: 1 }); + u.markDirty(); + assert.equal(u.s.atk, 435 * 1.2 * 1.1); + // the deployed unit starts at its multiplied max HP; the card's own numbers include the tier + const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, cultivate: 3 }], timeLimit: 3 }); + h.run(1); + const d = unit(h, VENDLA); + assert.ok(d.alive && Math.abs(d.hp - 1137 * 1.1) < 1e-9, `HP ${d.hp}`); + const e = unitStatsEntry(d, d.s); + assert.deepEqual([e.atk, e.base.atk, e.maxHp, e.base.maxHp, e.def, e.base.def], [479, 479, 1251, 1251, 85, 85], 'no green delta for the tier'); + assert.deepEqual([unitInfo(d).cultivate, unitInfo(d).potential], [3, undefined]); +}); + +test('a 补位 stand-in and a prototype 自选 pick take neither; an owned 自选 pick takes both', () => { + const h = makeBattle({ + units: [ + { chessId: 'chess_char_3_21_a', standIn: true, potential: 1, cultivate: 3, row: 10, col: 3 }, + { diy: { slot: 6, charId: 'char_609_acguad' }, potential: 1, cultivate: 3, row: 10, col: 5 }, + { diy: { slot: 'chess_char_6_diy2_a', charId: 'char_003_kalts', skillIndex: 0 }, potential: 1, cultivate: 3, row: 11, col: 5 }, + ], + timeLimit: 1, + }); + const ops = h.b.allyUnits.filter((u) => u.kind === 'op'); + const st = ops.find((u) => u.def.standInFor); + const proto = ops.find((u) => u.def.charId === 'char_609_acguad'); + const kalts = ops.find((u) => u.def.charId === 'char_003_kalts'); + assert.deepEqual([st.cultMul, st.def.loadout.potentialIsDefault ?? true], [null, true], 'stand-in'); + assert.deepEqual([proto.cultMul, proto.def.loadout.potential], [null, 6], 'prototype'); + // 凯尔希 潜能1: E2 Lv1 ATK 392 / cost 20 / 70 s (潜能 6: 417 / 18 / 66), ×1.1 + assert.deepEqual([kalts.base.atk, kalts.base.cost, kalts.base.respawnTime, kalts.def.loadout.potential], [392, 20, 70, 1]); + assert.equal(kalts.s.atk, 392 * 1.1); + // the spec drops both on a stand-in entry (and on summons) + const spec = buildBattleSpec({ players: [{ playerId: 'p1', units: [ + { uid: 1, kind: 'chess', chessId: 'chess_char_3_21_a', standIn: true, potential: 2, cultivate: 1, row: 10, col: 3 }, + { uid: 2, kind: 'token', tokenId: 'token_10028_vigil_wolf', potential: 2, cultivate: 1, row: 10, col: 4, ownerUid: 1 }, + ] }] }); + for (const u of spec.players[0].units) assert.deepEqual(['potential' in u, 'cultivate' in u], [false, false], u.kind); +}); + +test('summons follow their owner\'s potential: Mon3tr\'s 不毁重构 and 望\'s 棋子 deploy limit / holding', () => { + const ds = getDefaultSource(); + const owner = (potential) => ds.getChess(T6, { diy: { charId: 'char_003_kalts', skillIndex: 0 }, potential }); + const mon = (p) => ds.getToken('token_10002_kalts_mon3tr', T6, owner(p).loadout).talents.find((t) => t.name === '不毁重构').bb; + assert.deepEqual([mon(4).stun, mon(4).value], [3, 1200]); + assert.deepEqual([mon(5).stun, mon(5).value], [3.5, 1400]); + assert.deepEqual([mon(null).stun, mon(null).value], [3.5, 1400]); + // 望 (tier-6 slot, normal form E2 Lv1): her 棋子 hold 7 / deploy 6 below 潜能3, 8 / 7 from it (the kit's tokenStat) + const wang = (potential) => { + const h = makeBattle({ units: [{ diy: { slot: 6, charId: 'char_2027_wang', skillIndex: 0 }, potential, row: 10, col: 5 }], timeLimit: 1 }); + const u = h.b.allyUnits.find((x) => x.kind === 'op'); + return [tokenStat(h.b, u, 'token_10064_wang_stone1', 'deployLimit'), tokenStat(h.b, u, 'token_10064_wang_stone1', 'deckStack')]; + }; + assert.deepEqual(wang(2), [6, 7]); + assert.deepEqual(wang(3), [7, 8]); + // a chess owner (tokens.json): 夕's “小自在” 化境 15 层 below 潜能5 + const dusk = (p) => ds.getToken('token_10015_dusk_drgn', 'chess_char_5_12_a', ds.getChess('chess_char_5_12_a', { potential: p }).loadout).talents[0].bb.max_stack_cnt; + assert.deepEqual([dusk(4), dusk(5)], [15, 18]); +}); + +test('spec round trip: potential / 练度 kept when well-formed, dropped otherwise; the JSON spec fields the same battle', () => { + const units = [ + { uid: 1, kind: 'chess', chessId: VENDLA, potential: 2, cultivate: 1, row: 10, col: 5 }, + { uid: 2, kind: 'chess', chessId: 'chess_char_1_02_a', potential: 7, cultivate: 4, row: 10, col: 3 }, + { uid: 3, kind: 'chess', chessId: 'chess_char_1_14_a', potential: '3', cultivate: 2.5, row: 11, col: 3 }, + ]; + const spec = buildBattleSpec({ battleId: 'b1', fieldId: 'n:p1', kind: 'normal', seed: 7, stageId: 'act2autochess_m01', timeLimit: 30, + players: [{ playerId: 'p1', units }], spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 3, interval: 1 }] }); + const [a, b, c] = spec.players[0].units; + assert.deepEqual([a.potential, a.cultivate], [2, 1]); + assert.deepEqual(['potential' in b, 'cultivate' in b, 'potential' in c, 'cultivate' in c], [false, false, false, false]); + const run = (s) => { + const battle = createBattleFromSpec(s, undefined, { quiet: true }); + battle.start(); + const u = battle.allyUnits.find((x) => x.uid === 1); + const out = [u.base.atk, u.base.cost, u.s.atk, u.s.def]; + while (!battle.finished) battle.step(); + return { out, digest: resultDigest(battle.result()).hash }; + }; + const direct = run(spec); + const wire = run(JSON.parse(JSON.stringify(spec))); + assert.deepEqual(direct.out, [413, 16, 413 * 1.05, 77 * 1.05]); + assert.deepEqual(wire, direct); +}); + +test('two players field the same chess at different potentials and 练度 on one field: each unit is exact', () => { + const players = [ + { playerId: 'p1', side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: VENDLA, potential: 1, cultivate: 0, row: 10, col: 5 }] }, + { playerId: 'p2', side: 'L', colOffset: 8, units: [{ uid: 2, kind: 'chess', chessId: VENDLA, potential: 6, cultivate: 3, row: 10, col: 5 }] }, + ]; + const battle = createBattleFromSpec(buildBattleSpec({ kind: 'unite', seed: 3, stageId: 'act2autochess_m01', players, timeLimit: 10 }), undefined, { quiet: true }); + battle.start(); + const [u1, u2] = [1, 2].map((uid) => battle.allyUnits.find((x) => x.uid === uid)); + assert.deepEqual([u1.base.atk, u1.s.atk, u1.base.cost], [413, 413, 17]); + assert.deepEqual([u2.base.atk, u2.s.atk, u2.base.cost], [435, 435 * 1.1, 15]); +});