feedback7 R22-LOADOUT: the battle takes each operator's potential and 练度 from its spec entry; golden: unchanged
A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.
- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
(shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
This commit is contained in:
1 parent
7e09c63da8
commit
ec0a2fc669
16 files changed
+324
-62
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[<charId>@
|
||||
* <status>]`, `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[<charId>@
|
||||
* <status>]`, `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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+49
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user