diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 52a79eb0..96039d6f 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -1391,7 +1391,7 @@ function MatchScreen() { onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null} ${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }} - bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat} + bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} ops=${priv?.ops ?? null} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat} onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null} ${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid} diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 215b725a..4aae1763 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -37,7 +37,7 @@ import { useEffect } from '../../vendor/hooks.module.js'; import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js'; -import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js'; +import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter, unitPick, unitCultivation } from './gameLogic.js'; import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js'; import { abilityRows } from './abilityLines.js'; import { data } from '../data.js'; @@ -383,7 +383,7 @@ function skillTextNote(sk) { return note ? html`

<${Icon} name="info" />${t(note)}

` : null; } -export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) { +export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null, cultOpts = null }) { const m = data.get('assets'); const hp = hpOf(live, snapHp); // 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are @@ -395,7 +395,9 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond // still apply (the owner's recall of the official mode, 2026-10-06) const si = standIn && standIn.standInFor ? standIn : null; const body = si || chess; - const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess); + // 0.2.2: at the operator's 潜能 / 练度 — the player's settings (`cultOpts.ops`) or a teammate's unit's own + // (`cultOpts.cultivation`); a stand-in has neither + const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess, { ...(cultOpts || {}), effects: data.get('effects') }); const c = chess; // stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16) const fr = lo?.record || body; @@ -755,9 +757,12 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( // (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1) const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null; const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c); - if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) }; + // 0.2.2: a teammate's member card reads its unit's 潜能 / 练度 when the row hands it on (`target.cultivation`), else + // the defaults (never the viewer's own settings) + const cv = foreign ? (target.cultivation !== undefined ? { cultivation: target.cultivation } : { ops: null }) : null; + if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}) }; const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c); - return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null; + return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}) } : null; } if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; } if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; } @@ -776,10 +781,13 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( else if (c && own?.piece) si = ownSi(c); // 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows // m.private.diy like the piece - const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null; + const pick = c && u.diy && typeof u.diy === 'object' ? unitPick(u) : own?.piece ? ownDiyPick(priv, c) : null; const dr = pick ? diyRecordFor(c, pick, dd) : null; - if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick }; - if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si }; + // 0.2.2: another player's unit says its 潜能 / 练度 itself (UnitInfo potential / cultivate); an own piece's unit follows + // m.private.ops (the panel's `cultOpts`) + const cv = own?.piece ? null : { cultOpts: { cultivation: unitCultivation(u) } }; + if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick, ...cv }; + if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si, ...cv }; const t = data.lookup('tokens', u.defId) || diyToken(u.defId); if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) }; const en = data.lookup('enemies', u.defId); @@ -795,13 +803,14 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( * bonds: the owner's m.private.bonds (counts / tiers of the bond chips); offBonds: the bonds this mode never activates * (gameLogic modeOffBonds — their chips and the 变形同构体 pairing lines read 本局禁用); loadout: m.private.loadout (DESIGN §16) for * the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null - * = the defaults + * = the defaults; ops: m.private.ops (0.2.2 潜能 / 练度 of the player's own operators and cards — a unit the detail + * resolved as another player's carries its own, `detail.cultOpts`) * live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4× * a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers * voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request: * 整备期不播干员语音), so the game screen passes its combat flag */ -export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) { +export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, ops = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) { const getter = typeof live === 'function' ? live : null; useTicker(detail && getter ? 250 : 0); // 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes, @@ -834,7 +843,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} - standIn=${detail.standIn || null} diy=${detail.diy || null} />` : null} + standIn=${detail.standIn || null} diy=${detail.diy || null} cultOpts=${detail.cultOpts || { ops }} />` : null} ${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null} ${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null} ${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null} diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index a3082bd2..162e3c13 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -42,7 +42,7 @@ export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, ra export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js'; export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js'; export { normalizeResult } from './gameLogic/result.js'; -export { chessLoadout, unitLoadout } from './gameLogic/loadout.js'; +export { chessLoadout, unitLoadout, unitCultivation } from './gameLogic/loadout.js'; export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js'; -export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js'; +export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, unitPick, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js'; export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js'; diff --git a/public/js/ui/gameLogic/diy.js b/public/js/ui/gameLogic/diy.js index 720730da..fe6ff3f4 100644 --- a/public/js/ui/gameLogic/diy.js +++ b/public/js/ui/gameLogic/diy.js @@ -8,6 +8,8 @@ // player's units say it themselves (UnitInfo `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The // composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use. // `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies). +// 0.2.2: a pick may carry `potential` (the owner's 潜能 of the operator — m.private.ops for the player's own, UnitInfo +// `potential` for another player's unit): the record is composed at it (an owned pick; a prototype has none). import { diyRecordOf } from '../../../../shared/diy.js'; import { isObj } from './shared.js'; @@ -26,7 +28,10 @@ export function ownDiyPick(priv, chess) { if (!isObj(chess) || !chess.isDiy) return null; const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId; const p = diyPicks(priv)[slot]; - return isObj(p) && typeof p.charId === 'string' ? p : null; + if (!isObj(p) || typeof p.charId !== 'string') return null; + // 0.2.2: at the player's 潜能 of that operator (m.private.ops; none set = 6) + const pot = isObj(priv?.ops) && isObj(priv.ops[p.charId]) ? priv.ops[p.charId].potential : null; + return Number.isInteger(pot) && pot >= 1 && pot < 6 ? { ...p, potential: pot } : p; } /** Per backups object: `chessId|charId|skill|module` → composed record (or null). */ @@ -44,10 +49,11 @@ export function diyRecordFor(chess, pick, data) { if (!isObj(pick) || !isObj(data?.backups)) return null; let m = CACHE.get(data.backups); if (!m) { m = new Map(); CACHE.set(data.backups, m); } - const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`; + const pot = Number.isInteger(pick.potential) ? pick.potential : null; + const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}|${pot ?? ''}`; if (m.has(key)) return m.get(key); let rec; - try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; } + try { rec = diyRecordOf(chess, pick, data, { potential: pot }); } catch { rec = null; } m.set(key, rec); return rec; } @@ -65,10 +71,16 @@ export function ownDiyRecord(chess, priv, data) { */ export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) { if (!isObj(chess) || !chess.isDiy) return null; - if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null; + if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unitPick(unit), data) : null; return ownDiyRecord(chess, priv, data); } +/** A unit's 自选 pick with its potential (UnitInfo `diy` + `potential`, 0.2.2), or null. */ +export function unitPick(unit) { + if (!isObj(unit) || !isObj(unit.diy)) return null; + return Number.isInteger(unit.potential) ? { ...unit.diy, potential: unit.potential } : unit.diy; +} + /** * id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own * pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js diff --git a/public/js/ui/gameLogic/loadout.js b/public/js/ui/gameLogic/loadout.js index ac4f9017..58d6f769 100644 --- a/public/js/ui/gameLogic/loadout.js +++ b/public/js/ui/gameLogic/loadout.js @@ -2,6 +2,7 @@ import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js'; import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js'; +import { cultivationOf, cultivateMul, cultivatedStats, isPotential, isCultivate } from '../../../../shared/potential.js'; import { isObj } from './shared.js'; import { t } from '../../../../shared/i18n.js'; @@ -12,14 +13,21 @@ import { t } from '../../../../shared/i18n.js'; * The skill / module a chess fights with under the player's loadout (m.private.loadout, DESIGN §16) — what the shop * card and the detail panel show. Works with data that already lists every selectable skill (`skills[]`, elite * `modules[]`) and with older data (only the default `skill` / `module`). + * 0.2.2: the record (stats, talents) is at the operator's 潜能 and its stats carry the 练度 multiplier — the player's + * settings (`opts.ops`: m.private.ops / the 干员调配 store; missing = 潜能 6, 精英2 Lv.60), or a unit's own + * (`opts.cultivation`: a teammate's UnitInfo — null = none); a 补位 stand-in / prototype 自选 record has neither + * (shared/potential.js cultivationOf). The 练度 needs `opts.effects` (data/effects.json). A composed 自选 record is + * already at its potential (gameLogic/diy.js: the pick's `potential`). * @param {any} chess the chess record (normal or elite) * @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be * missing); null → defaults * @param {(id: string) => any} [getChess] + * @param {{ ops?: any, cultivation?: { potential?: number|null, cultivate?: number|null } | null, effects?: any }} [opts] * @returns {{ skill: any, skillIndex: number|null, defaultSkill: boolean, module: { id: string, name: string, typeName: string, none: boolean }|null, - * defaultModule: boolean, changed: boolean, choices: number } | null} + * defaultModule: boolean, changed: boolean, choices: number, record: any, + * cultivation: { potential: number, cultivate: number|null } | null } | null} */ -export function chessLoadout(chess, loadout, getChess = () => null) { +export function chessLoadout(chess, loadout, getChess = () => null, opts = {}) { if (!isObj(chess)) return null; const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null; let r = { skillIndex: null, moduleId: null }; @@ -43,12 +51,42 @@ export function chessLoadout(chess, loadout, getChess = () => null) { } defaultModule = opt.defaultModule == null || id === opt.defaultModule; } - // the record the unit fights with (stats / 特性 / talents of the chosen module or none — the battle's own composition, - // shared/loadoutRecord.js): the detail card must show what the sim runs + // 0.2.2: the operator's 潜能 / 练度 (the player's settings, or the unit's own) + const cv = cultivationFor(chess, opts); + // the record the unit fights with (stats / 特性 / talents of the chosen module or none, at its potential — the + // battle's own composition, shared/loadoutRecord.js): the detail card must show what the sim runs let record = chess; - try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId })) || chess; } catch { /* the record as is */ } + try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId, potential: cv ? cv.potential : null })) || chess; } catch { /* the record as is */ } + const mul = cv && isCultivate(cv.cultivate) ? cultivateMul(opts?.effects, cv.cultivate) : null; + if (mul && record && record.stats) record = { ...record, stats: cultivatedStats(record.stats, mul) }; return { skill, skillIndex: skill?.index ?? null, defaultSkill, module, defaultModule, changed: !defaultSkill || !defaultModule, - choices: Math.max(skills.length, opt.skills?.length || 0), record }; + choices: Math.max(skills.length, opt.skills?.length || 0), record, cultivation: cv }; +} + +/** + * The 潜能 / 练度 of a card's record (chessLoadout `opts`): the unit's own (`cultivation`, null = none) or the player's + * settings (`ops`, shared/potential.js cultivationOf — null for a stand-in / prototype 自选 record). + * @returns {{ potential: number, cultivate: number|null } | null} + */ +function cultivationFor(chess, opts) { + if (opts && opts.cultivation !== undefined) { + const c = opts.cultivation; + if (!isObj(c) || !isObj(chess) || chess.standInFor || chess.diyProto) return null; + return { potential: isPotential(c.potential) ? c.potential : 6, cultivate: isCultivate(c.cultivate) ? c.cultivate : null }; + } + return cultivationOf(chess, opts ? opts.ops : null); +} + +/** + * The 潜能 / 练度 a battle / scouting unit fights at (UnitInfo `potential` — below 6 only — and `cultivate`, 0.2.2): for + * chessLoadout `opts.cultivation`; null when the unit has neither (a stand-in, a prototype 自选 piece, a raw unit). + * @param {any} unit UnitInfo + */ +export function unitCultivation(unit) { + if (!isObj(unit)) return null; + const pot = isPotential(unit.potential) ? unit.potential : 6; + const cult = isCultivate(unit.cultivate) ? unit.cultivate : null; + return cult == null && pot === 6 ? null : { potential: pot, cultivate: cult }; } /** diff --git a/public/js/ui/loadoutModel.js b/public/js/ui/loadoutModel.js index 1981a711..b11713e1 100644 --- a/public/js/ui/loadoutModel.js +++ b/public/js/ui/loadoutModel.js @@ -8,8 +8,13 @@ // (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks // them only the default skill / module is offered. The option rules are shared with the server // (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted. +// 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08): `ops` = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` — +// per operator, so the normal, elite and 自选 forms share them; only values that differ from the defaults (潜能 6, +// 精英2 Lv.60) are kept; stored beside the entries (`sp.pref.loadout` = { v: 1, entries, ops }; an older stored loadout +// has none = the defaults) and sent with `room.loadout { entries, ops }` (shared/protocol.js checkLoadoutOps). -import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js'; +import { loadoutOptions, checkLoadout, checkLoadoutOps, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js'; +import { isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from '../../../shared/potential.js'; import { t, N_ } from '../../../shared/i18n.js'; export { MODULE_NONE }; @@ -56,8 +61,95 @@ export function parseStored(raw) { return out; } -/** Serialised form for localStorage. */ -export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} }); +/** + * Parse the stored per-operator 潜能 / 练度 (0.2.2; any junk → {}): structurally valid entries only, defaults dropped. + * An older stored loadout (no `ops`) gives {} — every operator at the defaults. + * @param {any} raw `{ v, entries, ops }` + * @returns {Record} + */ +export function parseStoredOps(raw) { + const src = isObj(raw) && isObj(raw.ops) ? raw.ops : null; + const out = {}; + if (!src) return out; + for (const [id, e] of Object.entries(src)) { + if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break; + if (UNSAFE_IDS.has(id) || !/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue; + const x = {}; + if (isPotential(e.potential) && e.potential !== POTENTIAL_DEFAULT) x.potential = e.potential; + if (isCultivate(e.cultivate) && e.cultivate !== CULTIVATE_DEFAULT) x.cultivate = e.cultivate; + if (Object.keys(x).length) out[id] = x; + } + return out; +} + +/** Serialised form for localStorage (`ops`: the per-operator 潜能 / 练度, 0.2.2). */ +export const toStored = (entries, ops = {}) => ({ v: LOADOUT_VERSION, entries: entries || {}, ops: ops || {} }); + +// ---- 潜能 / 练度 (0.2.2) ------------------------------------------------------------------------------------------------ + +/** + * The effective 潜能 / 练度 of operator `charId` under the stored settings (the defaults for a missing entry or field). + * @param {Record|null|undefined} ops @param {string|null|undefined} charId + * @returns {{ potential: number, cultivate: number, changed: boolean }} + */ +export function opsOf(ops, charId) { + const e = charId && isObj(ops) && Object.hasOwn(ops, charId) && isObj(ops[charId]) ? ops[charId] : null; + const potential = e && isPotential(e.potential) ? e.potential : POTENTIAL_DEFAULT; + const cultivate = e && isCultivate(e.cultivate) ? e.cultivate : CULTIVATE_DEFAULT; + return { potential, cultivate, changed: potential !== POTENTIAL_DEFAULT || cultivate !== CULTIVATE_DEFAULT }; +} + +/** + * Set (part of) one operator's 潜能 / 练度; an entry equal to the defaults is removed. Returns a new map. + * @param {Record} ops @param {string} charId @param {{ potential?: number, cultivate?: number }} patch + */ +export function setOps(ops, charId, patch) { + if (typeof charId !== 'string' || !charId) return ops; + const cur = opsOf(ops, charId); + const potential = patch && isPotential(patch.potential) ? patch.potential : cur.potential; + const cultivate = patch && isCultivate(patch.cultivate) ? patch.cultivate : cur.cultivate; + const out = { ...(ops || {}) }; + delete out[charId]; + const e = {}; + if (potential !== POTENTIAL_DEFAULT) e.potential = potential; + if (cultivate !== CULTIVATE_DEFAULT) e.cultivate = cultivate; + if (Object.keys(e).length) out[charId] = e; + return out; +} + +/** Remove one operator's 潜能 / 练度 (恢复默认). */ +export function resetOps(ops, charId) { + if (!ops || !Object.hasOwn(ops, charId)) return ops; + const out = { ...ops }; + delete out[charId]; + return out; +} + +/** + * The settings to send (`room.loadout.ops`): every stored entry the server would keep for the loaded data (an operator + * of the 干员调配 roster or the 自选 owned pool, `isOperator` — shared/protocol.js cultivationCharIds), the rest dropped one + * by one; defaults dropped. + * @param {Record} ops @param {(charId: string) => boolean} isOperator + * @returns {Record} + */ +export function sanitizeOps(ops, isOperator) { + const out = {}; + for (const [id, e] of Object.entries(ops || {})) { + if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break; + const one = {}; + if (isPotential(e?.potential)) one.potential = e.potential; + if (isCultivate(e?.cultivate)) one.cultivate = e.cultivate; + if (!Object.keys(one).length) continue; + const res = checkLoadoutOps({ [id]: one }, isOperator); + if (res.ok && res.ops[id]) { + const x = {}; + if (res.ops[id].potential !== POTENTIAL_DEFAULT) x.potential = res.ops[id].potential; + if (res.ops[id].cultivate !== CULTIVATE_DEFAULT) x.cultivate = res.ops[id].cultivate; + out[id] = x; + } + } + return out; +} // ---- export / import ---------------------------------------------------------------------------------------------- @@ -71,19 +163,23 @@ export const LOADOUT_EXPORT_KIND = 'stronghold.loadout'; export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024; /** - * Portable payload of a loadout, as downloaded / copied by 导出. + * Portable payload of a loadout, as downloaded / copied by 导出 — with the per-operator 潜能 / 练度 (`ops`, 0.2.2; an + * older build reading it ignores the field). * @param {Record} entries `room.loadout.entries` - * @param {{ now?: number }} [o] + * @param {{ now?: number, ops?: Record }} [o] */ -export function exportPayload(entries, { now = Date.now() } = {}) { +export function exportPayload(entries, { now = Date.now(), ops = {} } = {}) { const clean = {}; for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e }; + const cleanOps = {}; + for (const [id, e] of Object.entries(ops || {})) if (isObj(e)) cleanOps[id] = { ...e }; return { kind: LOADOUT_EXPORT_KIND, v: LOADOUT_VERSION, exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(), count: Object.keys(clean).length, entries: clean, + ops: cleanOps, }; } @@ -97,8 +193,10 @@ export function serializeExport(entries, opts) { * `{ [chessId]: { skill, module } }` map all work, as does the serialised text of any of them. Parsing is STRUCTURAL * only — the caller still runs `sanitizeEntries` against the loaded data, because a preset from another season may name * chess / skills / modules this build does not have. `__proto__` / `constructor` keys are skipped (see parseStored). + * A payload with `ops` (0.2.2) brings the per-operator 潜能 / 练度 too (`ops` in the result — absent when the payload has + * none: an older export leaves the current settings alone); a payload of settings only imports. * @param {any} input payload object or serialised text - * @returns {{ ok: true, entries: Record } | { ok: false, error: string }} + * @returns {{ ok: true, entries: Record, ops?: Record } | { ok: false, error: string }} */ export function parseImport(input) { let raw = input; @@ -115,8 +213,9 @@ export function parseImport(input) { const kind = typeof raw.kind === 'string' ? raw.kind : null; if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') }; const entries = parseStored(raw); - if (!Object.keys(entries).length) return { ok: false, error: t('里面没有有效的调配条目') }; - return { ok: true, entries }; + const ops = isObj(raw.ops) ? parseStoredOps(raw) : null; + if (!Object.keys(entries).length && !(ops && Object.keys(ops).length)) return { ok: false, error: t('里面没有有效的调配条目') }; + return ops ? { ok: true, entries, ops } : { ok: true, entries }; } // ---- options & choices --------------------------------------------------------------------------------------------- @@ -281,8 +380,9 @@ export function rosterOf(list) { * @param {Record} entries stored loadout (for changedOnly) * @param {(id: string) => any} getChess * @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names) + * @param {Record|null} [ops] the per-operator 潜能 / 练度 (changedOnly counts them too) */ -export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) { +export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null, ops = null) { const q = String(f.query || '').trim().toLowerCase(); return roster.filter((c) => { if (f.tier && c.tier !== f.tier) return false; @@ -290,7 +390,7 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null if (f.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false; if (f.changedOnly) { const golden = c.goldenId ? getChess(c.goldenId) : null; - if (!effectiveChoice(entries, c, golden).changed) return false; + if (!effectiveChoice(entries, c, golden).changed && !opsOf(ops, c.charId).changed) return false; } if (q) { const hay = [c.name, c.appellation, c.subProfessionName, t(PROF_NAME[c.profession]), ...(c.bonds || []).map((b) => getBond(b)?.name)] @@ -302,12 +402,14 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null } /** Number of chess whose choice differs from the defaults (only loadout slots of the loaded data count: an entry of a - * retired / hidden chess is never sent nor applied). */ -export function changedCount(entries, getChess) { + * retired / hidden chess is never sent nor applied) — with `ops` (0.2.2), also those whose operator's 潜能 / 练度 differ. */ +export function changedCount(entries, getChess, ops = null, roster = null) { + const ids = new Set(Object.keys(entries || {})); + if (ops && Object.keys(ops).length && Array.isArray(roster)) for (const c of roster) if (opsOf(ops, c.charId).changed) ids.add(c.chessId); let n = 0; - for (const id of Object.keys(entries || {})) { + for (const id of ids) { const { base, golden } = recordsOf(id, getChess); - if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++; + if (isLoadoutSlot(base) && base.chessId === id && (effectiveChoice(entries, base, golden).changed || opsOf(ops, base.charId).changed)) n++; } return n; } diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js index a49a0d38..325edbe2 100644 --- a/public/js/ui/loadoutSync.js +++ b/public/js/ui/loadoutSync.js @@ -7,9 +7,9 @@ // (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after // every `welcome` (new or resumed // session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every -// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries }` +// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries, ops }` // (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never -// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops +// the whole loadout; `ops` = the per-operator 潜能 / 练度 of 0.2.2, sanitizeOps against chess.json + backups.json), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops // the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the // legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the // loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the @@ -18,7 +18,8 @@ import { createStore, loadPref, savePref } from '../store.js'; import { data } from '../data.js'; -import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js'; +import { LOADOUT_PREF, parseStored, parseStoredOps, toStored, sanitizeEntries, sanitizeOps } from './loadoutModel.js'; +import { cultivationCharIds } from '../../../shared/protocol.js'; import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js'; import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js'; import { toast } from './toasts.js'; @@ -30,6 +31,9 @@ export const RETRY_MS = 1500; function readStored() { try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; } } +function readStoredOps() { + try { return parseStoredOps(loadPref(LOADOUT_PREF, null)); } catch { return {}; } +} function readStoredOwnership() { try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; } } @@ -40,6 +44,7 @@ function readStoredDiy() { /** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */ export const loadoutStore = createStore({ entries: readStored(), + ops: readStoredOps(), // 0.2.2 潜能 / 练度: { [charId]: { potential?, cultivate? } } ({} = every operator 潜能 6, 精英2 Lv.60) notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned) diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty) diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome) @@ -53,28 +58,46 @@ export const loadoutStore = createStore({ diySync: 'idle', }); -/** Replace the stored entries (persisted at once; the sync picks the change up). */ +/** Replace the stored entries (persisted at once with the settings; the sync picks the change up). */ export function setEntries(entries) { const next = entries && typeof entries === 'object' ? entries : {}; - savePref(LOADOUT_PREF, toStored(next)); + savePref(LOADOUT_PREF, toStored(next, loadoutStore.get().ops)); loadoutStore.set({ entries: next }); } +/** Replace the stored per-operator 潜能 / 练度 (0.2.2; persisted at once with the entries; the sync picks it up). */ +export function setOpsMap(ops) { + const next = ops && typeof ops === 'object' ? ops : {}; + savePref(LOADOUT_PREF, toStored(loadoutStore.get().entries, next)); + loadoutStore.set({ ops: next }); +} + /** * Apply a parsed entry map (an imported preset). Sanitised against the loaded data first, then persisted and synced * like any ordinary edit — so a preset from another build never sends the server an entry it would refuse. An import * that keeps nothing (every chess unknown, or every choice already the default) changes NOTHING: wiping the current * loadout over it would be a loss the player never asked for. + * With `ops` (an import that carries the per-operator 潜能 / 练度, 0.2.2: `isOperator` from the loaded data) those are + * sanitised and replace the current ones as well; without, the current ones stay. * @param {Record} entries `parseImport(...).entries` * @param {(id: string) => any} lookup chess lookup - * @returns {{ applied: number, dropped: number }} entries kept / entries that were not imported + * @param {{ ops?: Record|null, isOperator?: (charId: string) => boolean }} [o] + * @returns {{ applied: number, dropped: number, ops?: number }} entries kept / entries (and settings) not imported / settings + * kept (only with `ops`) */ -export function applyLoadoutEntries(entries, lookup) { +export function applyLoadoutEntries(entries, lookup, { ops = null, isOperator = null } = {}) { const asked = Object.keys(entries || {}).length; const clean = sanitizeEntries(entries, lookup); const applied = Object.keys(clean).length; - if (applied) setEntries(clean); - return { applied, dropped: Math.max(0, asked - applied) }; + const cleanOps = ops && isOperator ? sanitizeOps(ops, isOperator) : null; + const nOps = cleanOps ? Object.keys(cleanOps).length : 0; + const askedOps = ops ? Object.keys(ops).length : 0; + if (applied || nOps) { + if (cleanOps) loadoutStore.set({ ops: cleanOps }); + setEntries(applied ? clean : loadoutStore.get().entries); + } + const dropped = Math.max(0, asked - applied) + Math.max(0, askedOps - nOps); + return cleanOps ? { applied, dropped, ops: nOps } : { applied, dropped }; } /** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */ @@ -128,14 +151,16 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) { data.load('assets'); data.load('local'); data.load('backups'); + data.load('effects'); // 0.2.2: the 练度 multipliers of the 局内数值 (effects.json CHAR_MAP) loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) }); } export const closeLoadout = () => loadoutStore.set({ open: false }); /** - * One setting kept in sync with the server (see the header): `key` = the store field, `stateKey` = its sync state field, - * `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing: - * nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player. + * One setting kept in sync with the server (see the header): `key` = the store field (or a list of them: the loadout's + * `entries` and `ops`), `stateKey` = its sync state field, `msgType` / `field` = the C2S message (`field` null: the payload + * is the message body), `prepare()` → the payload to send (may await data; null = the data is missing: nothing is sent, + * state 'error'), `lockedText` = what a refused edit tells the player. */ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) { const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) }; @@ -177,7 +202,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, pendingJson = json; setState('sending'); try { - await net.request(msgType, { [field]: payload }); + await net.request(msgType, field ? { [field]: payload } : payload); if (my !== seq) return; pendingJson = null; lastSent = json; @@ -201,8 +226,9 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, } const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); }); + const keys = Array.isArray(key) ? key : [key]; const offStore = target.subscribe((s, prev) => { - if (s[key] !== prev[key]) { edited = true; schedule(); } + if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); } // closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo // briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when // INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered. @@ -229,20 +255,29 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, * timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType }} deps * @returns {{ flush: () => Promise, dispose: () => void }} */ -export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) { +export function installLoadoutSync({ net, getChessReady, getBackupsReady, lookupChess, operatorIds, timers, target = loadoutStore, notify } = {}) { const ready = getChessReady || (() => data.load('chess')); + const readyBackups = getBackupsReady || (() => data.load('backups')); const lookup = lookupChess || ((id) => data.lookup('chess', id)); + // the operators a 潜能 / 练度 may name (shared/protocol.js cultivationCharIds: the roster + the 自选 owned pool) + const opIds = operatorIds || (() => cultivationCharIds(data.get('chess'), data.get('backups'))); return installPrefSync({ - net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout', + net, timers, target, notify, key: ['entries', 'ops'], stateKey: 'sync', msgType: 'room.loadout', field: null, tag: 'loadout', lockedText: N_('本局的干员调配已锁定,修改将在下一局生效'), async prepare() { const current = target.get().entries; + const ops = target.get().ops; + const hasOps = !!ops && Object.keys(ops).length > 0; // an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download // chess.json in the lobby just for this - if (!current || Object.keys(current).length === 0) return {}; + if ((!current || Object.keys(current).length === 0) && !hasOps) return { entries: {}, ops: {} }; const loaded = await ready(); if (loaded == null) return null; - return sanitizeEntries(target.get().entries, lookup); + const entries = sanitizeEntries(target.get().entries, lookup); + if (!hasOps) return { entries, ops: {} }; + if ((await readyBackups()) == null) return null; + const ids = opIds(); + return { entries, ops: sanitizeOps(target.get().ops, (id) => ids.has(id)) }; }, }); } diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index e8a275c2..76d9319f 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -84,7 +84,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free const willMerge = !!hint; const bonds = Array.isArray(c?.bonds) ? c.bonds : []; const disabled = !!reason; - const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null; + const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess, { ops: priv?.ops ?? null, effects: data.get('effects') }) : null; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); }; const name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`; const card = html`