feedback7 R22-LOADOUT: the browser stores, syncs and shows each operator's 潜能 / 练度 (cards at the player's settings); golden: unchanged
- ui/loadoutModel.js: `ops` beside the entries (sp.pref.loadout { v, entries, ops }; an older stored loadout has none =
潜能 6, 精英2 Lv.60): parseStoredOps, opsOf / setOps / resetOps (defaults never stored), sanitizeOps (only what the
server keeps: the roster and the 自选 owned pool); 导出 carries `ops`, 导入 replaces the settings when the payload has
them (an older export leaves them alone; settings alone import); the 已调整 count and 仅看已调整 count them.
- ui/loadoutSync.js: the store's `ops` (setOpsMap persists both); the loadout sync watches entries and ops and sends
room.loadout { entries, ops } (the ops sanitised against chess.json + backups.json; nothing set: no download);
openLoadout loads effects.json (the 练度 multipliers).
- Cards: gameLogic chessLoadout(chess, loadout, getChess, { ops | cultivation, effects }) composes the record at the
operator's potential and multiplies its stats by the 练度 tier (the player's m.private.ops — shop / hand / board cards
and the detail panel; a teammate's unit its UnitInfo potential / cultivate: unitCultivation; a teammate's bond-popup
card the defaults, never the viewer's settings; a stand-in neither); a 自选 card is composed at its pick's potential
(ownDiyPick from m.private.ops, unitPick from UnitInfo).
- Tests: test/ui/potential-ui.test.js (new, 7): storage, helpers, 导出 / 导入, the 已调整 count, the sync (sanitised ops,
settings-only edits, no download when none), the cards (刺玫 413 / 17 at 潜能1, ×1.05 at 精英1, a teammate's unit, a
stand-in), 自选 cards at 潜能1 (凯尔希 392 / 20) and the 练度 on top; test/ui/loadout.test.js expects the envelope's
`ops` and room.loadout's `ops: {}`.
This commit is contained in:
1 parent
96d9441ff1
commit
7a0937e3dd
10 files changed
+441
-61
No files matched your search
@@ -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}
|
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); }}
|
${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}
|
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
|
||||||
|
|
||||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||||
|
|||||||
+20
-11
@@ -37,7 +37,7 @@
|
|||||||
import { useEffect } from '../../vendor/hooks.module.js';
|
import { useEffect } from '../../vendor/hooks.module.js';
|
||||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.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 { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||||
import { abilityRows } from './abilityLines.js';
|
import { abilityRows } from './abilityLines.js';
|
||||||
import { data } from '../data.js';
|
import { data } from '../data.js';
|
||||||
@@ -383,7 +383,7 @@ function skillTextNote(sk) {
|
|||||||
return note ? html`<p class="dhint dhint--rule" data-skill-note=${sk.skillId}><${Icon} name="info" />${t(note)}</p>` : null;
|
return note ? html`<p class="dhint dhint--rule" data-skill-note=${sk.skillId}><${Icon} name="info" />${t(note)}</p>` : 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 m = data.get('assets');
|
||||||
const hp = hpOf(live, snapHp);
|
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
|
// 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)
|
// still apply (the owner's recall of the official mode, 2026-10-06)
|
||||||
const si = standIn && standIn.standInFor ? standIn : null;
|
const si = standIn && standIn.standInFor ? standIn : null;
|
||||||
const body = si || chess;
|
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;
|
const c = chess;
|
||||||
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
||||||
const fr = lo?.record || body;
|
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)
|
// (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 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);
|
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);
|
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 === '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; }
|
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);
|
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
|
// 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
|
// 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;
|
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 };
|
// 0.2.2: another player's unit says its 潜能 / 练度 itself (UnitInfo potential / cultivate); an own piece's unit follows
|
||||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si };
|
// 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);
|
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) };
|
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
|
||||||
const en = data.lookup('enemies', u.defId);
|
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
|
* 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
|
* (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 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×
|
* 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
|
* 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:
|
* 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
|
* 整备期不播干员语音), 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;
|
const getter = typeof live === 'function' ? live : null;
|
||||||
useTicker(detail && getter ? 250 : 0);
|
useTicker(detail && getter ? 250 : 0);
|
||||||
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
|
// 选中干员 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
|
|||||||
<div class="dpanel__scroll">
|
<div class="dpanel__scroll">
|
||||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
${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}
|
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 === '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 === '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}
|
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||||
|
|||||||
@@ -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 { 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 { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||||
export { normalizeResult } from './gameLogic/result.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 { 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';
|
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.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
|
// 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.
|
// 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).
|
// `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 { diyRecordOf } from '../../../../shared/diy.js';
|
||||||
import { isObj } from './shared.js';
|
import { isObj } from './shared.js';
|
||||||
@@ -26,7 +28,10 @@ export function ownDiyPick(priv, chess) {
|
|||||||
if (!isObj(chess) || !chess.isDiy) return null;
|
if (!isObj(chess) || !chess.isDiy) return null;
|
||||||
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
|
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
|
||||||
const p = diyPicks(priv)[slot];
|
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). */
|
/** 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;
|
if (!isObj(pick) || !isObj(data?.backups)) return null;
|
||||||
let m = CACHE.get(data.backups);
|
let m = CACHE.get(data.backups);
|
||||||
if (!m) { m = new Map(); CACHE.set(data.backups, m); }
|
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);
|
if (m.has(key)) return m.get(key);
|
||||||
let rec;
|
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);
|
m.set(key, rec);
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
@@ -65,10 +71,16 @@ export function ownDiyRecord(chess, priv, data) {
|
|||||||
*/
|
*/
|
||||||
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
|
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
|
||||||
if (!isObj(chess) || !chess.isDiy) return 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);
|
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
|
* 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
|
* pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js';
|
import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js';
|
||||||
import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js';
|
import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js';
|
||||||
|
import { cultivationOf, cultivateMul, cultivatedStats, isPotential, isCultivate } from '../../../../shared/potential.js';
|
||||||
import { isObj } from './shared.js';
|
import { isObj } from './shared.js';
|
||||||
import { t } from '../../../../shared/i18n.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
|
* 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
|
* 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`).
|
* `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} chess the chess record (normal or elite)
|
||||||
* @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be
|
* @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be
|
||||||
* missing); null → defaults
|
* missing); null → defaults
|
||||||
* @param {(id: string) => any} [getChess]
|
* @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,
|
* @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;
|
if (!isObj(chess)) return null;
|
||||||
const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null;
|
const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null;
|
||||||
let r = { skillIndex: null, moduleId: 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;
|
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,
|
// 0.2.2: the operator's 潜能 / 练度 (the player's settings, or the unit's own)
|
||||||
// shared/loadoutRecord.js): the detail card must show what the sim runs
|
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;
|
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,
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+117
-15
@@ -8,8 +8,13 @@
|
|||||||
// (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks
|
// (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
|
// 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.
|
// (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';
|
import { t, N_ } from '../../../shared/i18n.js';
|
||||||
|
|
||||||
export { MODULE_NONE };
|
export { MODULE_NONE };
|
||||||
@@ -56,8 +61,95 @@ export function parseStored(raw) {
|
|||||||
return out;
|
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<string, { potential?: number, cultivate?: number }>}
|
||||||
|
*/
|
||||||
|
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<string, any>|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<string, any>} 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<string, any>} ops @param {(charId: string) => boolean} isOperator
|
||||||
|
* @returns {Record<string, { potential?: number, cultivate?: number }>}
|
||||||
|
*/
|
||||||
|
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 ----------------------------------------------------------------------------------------------
|
// ---- export / import ----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -71,19 +163,23 @@ export const LOADOUT_EXPORT_KIND = 'stronghold.loadout';
|
|||||||
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
|
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<string, any>} entries `room.loadout.entries`
|
* @param {Record<string, any>} entries `room.loadout.entries`
|
||||||
* @param {{ now?: number }} [o]
|
* @param {{ now?: number, ops?: Record<string, any> }} [o]
|
||||||
*/
|
*/
|
||||||
export function exportPayload(entries, { now = Date.now() } = {}) {
|
export function exportPayload(entries, { now = Date.now(), ops = {} } = {}) {
|
||||||
const clean = {};
|
const clean = {};
|
||||||
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
|
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 {
|
return {
|
||||||
kind: LOADOUT_EXPORT_KIND,
|
kind: LOADOUT_EXPORT_KIND,
|
||||||
v: LOADOUT_VERSION,
|
v: LOADOUT_VERSION,
|
||||||
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
||||||
count: Object.keys(clean).length,
|
count: Object.keys(clean).length,
|
||||||
entries: clean,
|
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
|
* `{ [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
|
* 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).
|
* 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
|
* @param {any} input payload object or serialised text
|
||||||
* @returns {{ ok: true, entries: Record<string, any> } | { ok: false, error: string }}
|
* @returns {{ ok: true, entries: Record<string, any>, ops?: Record<string, any> } | { ok: false, error: string }}
|
||||||
*/
|
*/
|
||||||
export function parseImport(input) {
|
export function parseImport(input) {
|
||||||
let raw = input;
|
let raw = input;
|
||||||
@@ -115,8 +213,9 @@ export function parseImport(input) {
|
|||||||
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
||||||
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
|
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
|
||||||
const entries = parseStored(raw);
|
const entries = parseStored(raw);
|
||||||
if (!Object.keys(entries).length) return { ok: false, error: t('里面没有有效的调配条目') };
|
const ops = isObj(raw.ops) ? parseStoredOps(raw) : null;
|
||||||
return { ok: true, entries };
|
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 ---------------------------------------------------------------------------------------------
|
// ---- options & choices ---------------------------------------------------------------------------------------------
|
||||||
@@ -281,8 +380,9 @@ export function rosterOf(list) {
|
|||||||
* @param {Record<string, any>} entries stored loadout (for changedOnly)
|
* @param {Record<string, any>} entries stored loadout (for changedOnly)
|
||||||
* @param {(id: string) => any} getChess
|
* @param {(id: string) => any} getChess
|
||||||
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
|
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
|
||||||
|
* @param {Record<string, any>|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();
|
const q = String(f.query || '').trim().toLowerCase();
|
||||||
return roster.filter((c) => {
|
return roster.filter((c) => {
|
||||||
if (f.tier && c.tier !== f.tier) return false;
|
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.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false;
|
||||||
if (f.changedOnly) {
|
if (f.changedOnly) {
|
||||||
const golden = c.goldenId ? getChess(c.goldenId) : null;
|
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) {
|
if (q) {
|
||||||
const hay = [c.name, c.appellation, c.subProfessionName, t(PROF_NAME[c.profession]), ...(c.bonds || []).map((b) => getBond(b)?.name)]
|
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
|
/** 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). */
|
* retired / hidden chess is never sent nor applied) — with `ops` (0.2.2), also those whose operator's 潜能 / 练度 differ. */
|
||||||
export function changedCount(entries, getChess) {
|
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;
|
let n = 0;
|
||||||
for (const id of Object.keys(entries || {})) {
|
for (const id of ids) {
|
||||||
const { base, golden } = recordsOf(id, getChess);
|
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;
|
return n;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-18
@@ -7,9 +7,9 @@
|
|||||||
// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after
|
// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after
|
||||||
// every `welcome` (new or resumed
|
// 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
|
// 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
|
// (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
|
// 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
|
// 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
|
// 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 { createStore, loadPref, savePref } from '../store.js';
|
||||||
import { data } from '../data.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 { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
|
||||||
import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
|
import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
|
||||||
import { toast } from './toasts.js';
|
import { toast } from './toasts.js';
|
||||||
@@ -30,6 +31,9 @@ export const RETRY_MS = 1500;
|
|||||||
function readStored() {
|
function readStored() {
|
||||||
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
||||||
}
|
}
|
||||||
|
function readStoredOps() {
|
||||||
|
try { return parseStoredOps(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
||||||
|
}
|
||||||
function readStoredOwnership() {
|
function readStoredOwnership() {
|
||||||
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
|
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). */
|
/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
|
||||||
export const loadoutStore = createStore({
|
export const loadoutStore = createStore({
|
||||||
entries: readStored(),
|
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)
|
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned)
|
||||||
diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty)
|
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)
|
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',
|
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) {
|
export function setEntries(entries) {
|
||||||
const next = entries && typeof entries === 'object' ? 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 });
|
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
|
* 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
|
* 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
|
* 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.
|
* 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<string, any>} entries `parseImport(...).entries`
|
* @param {Record<string, any>} entries `parseImport(...).entries`
|
||||||
* @param {(id: string) => any} lookup chess lookup
|
* @param {(id: string) => any} lookup chess lookup
|
||||||
* @returns {{ applied: number, dropped: number }} entries kept / entries that were not imported
|
* @param {{ ops?: Record<string, any>|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 asked = Object.keys(entries || {}).length;
|
||||||
const clean = sanitizeEntries(entries, lookup);
|
const clean = sanitizeEntries(entries, lookup);
|
||||||
const applied = Object.keys(clean).length;
|
const applied = Object.keys(clean).length;
|
||||||
if (applied) setEntries(clean);
|
const cleanOps = ops && isOperator ? sanitizeOps(ops, isOperator) : null;
|
||||||
return { applied, dropped: Math.max(0, asked - applied) };
|
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). */
|
/** 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('assets');
|
||||||
data.load('local');
|
data.load('local');
|
||||||
data.load('backups');
|
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 } : {}) });
|
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
|
||||||
}
|
}
|
||||||
export const closeLoadout = () => loadoutStore.set({ open: false });
|
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,
|
* One setting kept in sync with the server (see the header): `key` = the store field (or a list of them: the loadout's
|
||||||
* `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing:
|
* `entries` and `ops`), `stateKey` = its sync state field, `msgType` / `field` = the C2S message (`field` null: the payload
|
||||||
* nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player.
|
* 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 }) {
|
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) };
|
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;
|
pendingJson = json;
|
||||||
setState('sending');
|
setState('sending');
|
||||||
try {
|
try {
|
||||||
await net.request(msgType, { [field]: payload });
|
await net.request(msgType, field ? { [field]: payload } : payload);
|
||||||
if (my !== seq) return;
|
if (my !== seq) return;
|
||||||
pendingJson = null;
|
pendingJson = null;
|
||||||
lastSent = json;
|
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 offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
const offStore = target.subscribe((s, prev) => {
|
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
|
// 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
|
// 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.
|
// 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<typeof createStore> }} deps
|
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
|
||||||
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
|
* @returns {{ flush: () => Promise<void>, 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 ready = getChessReady || (() => data.load('chess'));
|
||||||
|
const readyBackups = getBackupsReady || (() => data.load('backups'));
|
||||||
const lookup = lookupChess || ((id) => data.lookup('chess', id));
|
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({
|
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_('本局的干员调配已锁定,修改将在下一局生效'),
|
lockedText: N_('本局的干员调配已锁定,修改将在下一局生效'),
|
||||||
async prepare() {
|
async prepare() {
|
||||||
const current = target.get().entries;
|
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
|
// 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
|
// 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();
|
const loaded = await ready();
|
||||||
if (loaded == null) return null;
|
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)) };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
|||||||
const willMerge = !!hint;
|
const willMerge = !!hint;
|
||||||
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
|
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
|
||||||
const disabled = !!reason;
|
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 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 name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`;
|
||||||
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed', si && 'is-standin')}
|
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed', si && 'is-standin')}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ test('parseStored: tolerant of junk, keeps structurally valid entries; toStored
|
|||||||
test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
|
test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
|
||||||
const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
|
const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
|
||||||
const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
|
const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
|
||||||
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'v'], 'exactly the envelope, no unused field');
|
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'ops', 'v'], 'exactly the envelope (0.2.2: + the 潜能 / 练度 ops), no unused field');
|
||||||
|
assert.deepEqual(p.ops, {}, 'no settings: an empty map (an import then resets the settings to the defaults)');
|
||||||
assert.equal(p.kind, LOADOUT_EXPORT_KIND);
|
assert.equal(p.kind, LOADOUT_EXPORT_KIND);
|
||||||
assert.equal(p.v, LOADOUT_VERSION);
|
assert.equal(p.v, LOADOUT_VERSION);
|
||||||
assert.equal(p.count, 2);
|
assert.equal(p.count, 2);
|
||||||
@@ -262,7 +263,7 @@ test('sync: welcome sends the sanitised loadout; edits are debounced; identical
|
|||||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||||||
net.emit('welcome', {});
|
net.emit('welcome', {});
|
||||||
await T.advance(100);
|
await T.advance(100);
|
||||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }], 'stale entry dropped');
|
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: {} }], 'stale entry dropped (no 潜能 / 练度 set: ops {})');
|
||||||
assert.equal(target.get().sync, 'synced');
|
assert.equal(target.get().sync, 'synced');
|
||||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
||||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
||||||
@@ -480,7 +481,7 @@ test('sync: an empty loadout is sent without loading chess.json (no 1.6 MB downl
|
|||||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
||||||
net.emit('welcome', {});
|
net.emit('welcome', {});
|
||||||
await T.advance(100);
|
await T.advance(100);
|
||||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {} }]);
|
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: {} }]);
|
||||||
assert.equal(loads, 0);
|
assert.equal(loads, 0);
|
||||||
await net.reply();
|
await net.reply();
|
||||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// test/ui/potential-ui.test.js — the client side of the per-operator 潜能 / 练度 (0.2.2, the owner's decision of
|
||||||
|
// 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」): the stored settings (`sp.pref.loadout` { v, entries, ops } — an
|
||||||
|
// older stored loadout has none = the defaults), their helpers (set / reset / sanitise against the operators the server
|
||||||
|
// knows), 导出 / 导入 with them, the sync (`room.loadout { entries, ops }`), and the cards: chessLoadout composes the record
|
||||||
|
// at the operator's potential with its 练度 multiplier (the player's settings, a teammate's unit's own, never a stand-in's),
|
||||||
|
// a 自选 card at its pick's potential.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { cultivationCharIds } from '../../shared/protocol.js';
|
||||||
|
import {
|
||||||
|
parseStored, parseStoredOps, toStored, opsOf, setOps, resetOps, sanitizeOps, exportPayload, parseImport, changedCount,
|
||||||
|
rosterOf, filterRoster,
|
||||||
|
} from '../../public/js/ui/loadoutModel.js';
|
||||||
|
import { installLoadoutSync, SYNC_DEBOUNCE_MS, applyLoadoutEntries, loadoutStore, setEntries, setOpsMap } from '../../public/js/ui/loadoutSync.js';
|
||||||
|
import { createStore } from '../../public/js/store.js';
|
||||||
|
import { chessLoadout, unitCultivation, standInLoadout, standInOf, ownDiyPick, ownDiyRecord, cardDiy } from '../../public/js/ui/gameLogic.js';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const load = (n) => JSON.parse(readFileSync(path.join(ROOT, `data/${n}.json`), 'utf8'));
|
||||||
|
const CHESS = load('chess');
|
||||||
|
const BACKUPS = load('backups');
|
||||||
|
const EFFECTS = load('effects');
|
||||||
|
const get = (id) => (Object.hasOwn(CHESS, id) ? CHESS[id] : null);
|
||||||
|
const VENDLA = 'chess_char_1_06_a';
|
||||||
|
const V = CHESS[VENDLA].charId;
|
||||||
|
const KALTS = 'char_003_kalts';
|
||||||
|
const IDS = cultivationCharIds(CHESS, BACKUPS);
|
||||||
|
const isOp = (id) => IDS.has(id);
|
||||||
|
|
||||||
|
test('storage: ops beside the entries; junk / defaults dropped; an older stored loadout has none (the defaults)', () => {
|
||||||
|
assert.deepEqual(parseStoredOps({ v: 1, entries: { [VENDLA]: { skill: 0 } } }), {}, 'a 0.2.1 stored loadout');
|
||||||
|
assert.deepEqual(parseStoredOps(null), {});
|
||||||
|
const raw = { v: 1, entries: {}, ops: { [V]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 3 }, x: 'junk', 'a b': { potential: 1 }, char_y: { potential: 9 }, __proto__: { potential: 1 } } };
|
||||||
|
assert.deepEqual(parseStoredOps(raw), { [V]: { potential: 1, cultivate: 0 } });
|
||||||
|
assert.deepEqual(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }), { v: 1, entries: { [VENDLA]: { skill: 0 } }, ops: { [V]: { potential: 2 } } });
|
||||||
|
assert.deepEqual(parseStored(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } })), { [VENDLA]: { skill: 0 } }, 'the entries read as before');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('helpers: opsOf / setOps / resetOps keep only non-default values; sanitizeOps keeps what the server would', () => {
|
||||||
|
assert.deepEqual(opsOf({}, V), { potential: 6, cultivate: 3, changed: false });
|
||||||
|
let o = setOps({}, V, { potential: 1 });
|
||||||
|
assert.deepEqual(o, { [V]: { potential: 1 } });
|
||||||
|
o = setOps(o, V, { cultivate: 0 });
|
||||||
|
assert.deepEqual(opsOf(o, V), { potential: 1, cultivate: 0, changed: true });
|
||||||
|
o = setOps(o, V, { potential: 6, cultivate: 3 });
|
||||||
|
assert.deepEqual(o, {}, 'back to the defaults: no entry');
|
||||||
|
o = setOps(o, V, { potential: 0 });
|
||||||
|
assert.deepEqual(o, {}, 'an illegal value is ignored');
|
||||||
|
assert.deepEqual(resetOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, V), { [KALTS]: { cultivate: 1 } });
|
||||||
|
assert.deepEqual(sanitizeOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 }, char_609_acguad: { potential: 1 }, char_999_x: { potential: 1 }, [CHESS.chess_char_1_01_a.charId]: { potential: 6 } }, isOp),
|
||||||
|
{ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, 'a prototype / unknown operator / defaults dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('导出 / 导入: the envelope carries ops; an older export keeps the current settings; settings alone import', () => {
|
||||||
|
const p = exportPayload({ [VENDLA]: { skill: 0 } }, { ops: { [V]: { potential: 2 } } });
|
||||||
|
assert.deepEqual(p.ops, { [V]: { potential: 2 } });
|
||||||
|
const back = parseImport(JSON.stringify(p));
|
||||||
|
assert.deepEqual([back.ok, back.entries, back.ops], [true, { [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }]);
|
||||||
|
const old = parseImport({ kind: 'stronghold.loadout', v: 1, entries: { [VENDLA]: { skill: 0 } } });
|
||||||
|
assert.equal('ops' in old, false, 'an older export: no ops');
|
||||||
|
assert.equal(parseImport({ v: 1, entries: {}, ops: { [V]: { cultivate: 1 } } }).ok, true, 'settings only');
|
||||||
|
assert.equal(parseImport({ v: 1, entries: {}, ops: {} }).ok, false, 'nothing at all');
|
||||||
|
const before = { entries: loadoutStore.get().entries, ops: loadoutStore.get().ops };
|
||||||
|
try {
|
||||||
|
setOpsMap({ [KALTS]: { cultivate: 1 } });
|
||||||
|
const r1 = applyLoadoutEntries({ [VENDLA]: { skill: 0 } }, get);
|
||||||
|
assert.deepEqual(r1, { applied: 1, dropped: 0 });
|
||||||
|
assert.deepEqual(loadoutStore.get().ops, { [KALTS]: { cultivate: 1 } }, 'no ops in the import: the settings stay');
|
||||||
|
const r2 = applyLoadoutEntries({}, get, { ops: { [V]: { potential: 3 }, char_609_acguad: { potential: 1 } }, isOperator: isOp });
|
||||||
|
assert.deepEqual(r2, { applied: 0, dropped: 1, ops: 1 });
|
||||||
|
assert.deepEqual(loadoutStore.get().ops, { [V]: { potential: 3 } }, 'the imported settings replace the current ones');
|
||||||
|
} finally {
|
||||||
|
setEntries(before.entries);
|
||||||
|
setOpsMap(before.ops);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 已调整 count and 仅看已调整 include an operator whose 潜能 / 练度 differ', () => {
|
||||||
|
const roster = rosterOf(Object.values(CHESS));
|
||||||
|
assert.equal(changedCount({}, get, { [V]: { potential: 2 } }, roster), 1);
|
||||||
|
assert.equal(changedCount({ [VENDLA]: { skill: 0 } }, get, { [V]: { potential: 2 } }, roster), 1, 'one operator');
|
||||||
|
assert.equal(changedCount({}, get, {}, roster), 0);
|
||||||
|
const only = filterRoster(roster, { changedOnly: true }, {}, get, () => null, { [V]: { cultivate: 0 } });
|
||||||
|
assert.deepEqual(only.map((c) => c.chessId), [VENDLA]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeNet() {
|
||||||
|
const listeners = new Map();
|
||||||
|
const net = {
|
||||||
|
status: 'online', sent: [], replies: [],
|
||||||
|
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||||
|
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||||
|
request(t, fields) { net.sent.push({ t, ...fields }); return Promise.resolve({ t: 'ok' }); },
|
||||||
|
};
|
||||||
|
return net;
|
||||||
|
}
|
||||||
|
function fakeTimers() {
|
||||||
|
let now = 0; let seq = 0;
|
||||||
|
const q = new Map();
|
||||||
|
return {
|
||||||
|
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||||
|
clearTimeout: (id) => q.delete(id),
|
||||||
|
async advance(ms) {
|
||||||
|
const end = now + ms;
|
||||||
|
for (;;) {
|
||||||
|
const next = [...q.entries()].filter(([, x]) => x.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||||
|
if (!next) break;
|
||||||
|
q.delete(next[0]); now = next[1].at; next[1].fn();
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
now = end;
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sync: room.loadout carries the sanitised ops; an edit of the settings alone is sent; none set ⇒ ops {} without a download', async () => {
|
||||||
|
const net = fakeNet();
|
||||||
|
const T = fakeTimers();
|
||||||
|
let backups = 0;
|
||||||
|
const target = createStore({ entries: {}, ops: { [V]: { potential: 1 }, char_609_acguad: { potential: 1 } }, open: false, sync: 'idle' });
|
||||||
|
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, getBackupsReady: async () => { backups++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
||||||
|
net.emit('welcome', {});
|
||||||
|
await T.advance(100);
|
||||||
|
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1 } } }], 'a prototype dropped');
|
||||||
|
assert.equal(backups, 1, 'the 自选 pool needs backups.json');
|
||||||
|
target.set({ ops: { [V]: { potential: 1, cultivate: 2 } } });
|
||||||
|
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||||
|
assert.deepEqual(net.sent[1], { t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1, cultivate: 2 } } });
|
||||||
|
s.dispose();
|
||||||
|
// nothing set: nothing to sanitise, no data loaded
|
||||||
|
const net2 = fakeNet();
|
||||||
|
let loads = 0;
|
||||||
|
const t2 = createStore({ entries: {}, ops: {}, open: false, sync: 'idle' });
|
||||||
|
const s2 = installLoadoutSync({ net: net2, timers: T, target: t2, getChessReady: async () => { loads++; return CHESS; }, getBackupsReady: async () => { loads++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
||||||
|
net2.emit('welcome', {});
|
||||||
|
await T.advance(100);
|
||||||
|
assert.deepEqual([net2.sent, loads], [[{ t: 'room.loadout', entries: {}, ops: {} }], 0]);
|
||||||
|
s2.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cards: the record at the player\'s 潜能 with its 练度 multiplier; a teammate\'s unit its own; a stand-in neither', () => {
|
||||||
|
const v = CHESS[VENDLA];
|
||||||
|
const fx = { effects: EFFECTS };
|
||||||
|
const at = (opts) => chessLoadout(v, null, get, { ...fx, ...opts }).record.stats;
|
||||||
|
assert.deepEqual([at({}).atk, at({}).cost], [435 * 1.1, 15], 'no settings: 满潜 + 精英2 Lv.60');
|
||||||
|
assert.deepEqual([at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).atk, at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).cost], [413, 17]);
|
||||||
|
assert.deepEqual([at({ ops: { [V]: { cultivate: 1 } } }).atk, at({ ops: { [V]: { cultivate: 1 } } }).def], [435 * 1.05, 77 * 1.05]);
|
||||||
|
assert.equal(chessLoadout(v, null, get, { ops: { [V]: { potential: 4 } }, ...fx }).record.talents.find((t) => t.name === '土壤基肥改良').bb.heal_scale, 1.08);
|
||||||
|
assert.deepEqual(chessLoadout(v, null, get, { ops: { [V]: { potential: 2 } }, ...fx }).cultivation, { potential: 2, cultivate: 3 });
|
||||||
|
// a teammate's unit: its UnitInfo
|
||||||
|
assert.deepEqual(unitCultivation({ potential: 3, cultivate: 1 }), { potential: 3, cultivate: 1 });
|
||||||
|
assert.equal(unitCultivation({}), null, 'neither: a raw / stand-in unit');
|
||||||
|
assert.equal(at({ cultivation: unitCultivation({ cultivate: 0 }) }).atk, 435);
|
||||||
|
assert.equal(at({ cultivation: null }).atk, 435, 'none');
|
||||||
|
// a stand-in (补位): neither, whatever the settings say
|
||||||
|
const normal = Object.values(CHESS).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible);
|
||||||
|
const st = standInOf(normal, BACKUPS);
|
||||||
|
const si = standInLoadout(st, get, BACKUPS);
|
||||||
|
assert.ok(st && st.standInFor === normal.charId);
|
||||||
|
assert.equal(si.cultivation, null);
|
||||||
|
assert.deepEqual(si.record.stats, st.stats, 'the stand-in\'s own numbers');
|
||||||
|
assert.equal(chessLoadout(st, null, get, { ops: { [normal.charId]: { potential: 1, cultivate: 0 }, [st.charId]: { potential: 1 } }, ...fx }).cultivation, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自选 cards: the player\'s own pick at its 潜能 (m.private.ops), another player\'s unit at its UnitInfo potential', () => {
|
||||||
|
const T6 = 'chess_char_6_diy1_a';
|
||||||
|
const data = { chess: CHESS, backups: BACKUPS };
|
||||||
|
const priv = { diy: { [T6]: { charId: KALTS, skillIndex: 0, uniEquipId: null } }, ops: { [KALTS]: { potential: 1 } } };
|
||||||
|
assert.deepEqual(ownDiyPick(priv, CHESS[T6]), { charId: KALTS, skillIndex: 0, uniEquipId: null, potential: 1 });
|
||||||
|
const rec = ownDiyRecord(CHESS[T6], priv, data);
|
||||||
|
assert.deepEqual([rec.stats.atk, rec.stats.cost], [392, 20], '凯尔希 潜能1');
|
||||||
|
const full = ownDiyRecord(CHESS[T6], { ...priv, ops: {} }, data);
|
||||||
|
assert.deepEqual([full.stats.atk, full.stats.cost], [417, 18]);
|
||||||
|
const mate = cardDiy(CHESS[T6], { unit: { diy: { charId: KALTS, skillIndex: 0, uniEquipId: null }, potential: 1, cultivate: 3 }, data });
|
||||||
|
assert.equal(mate.stats.atk, 392);
|
||||||
|
// the card multiplies the 练度 on top (the composed record is already at its potential)
|
||||||
|
assert.equal(chessLoadout(rec, null, get, { ops: priv.ops, effects: EFFECTS }).record.stats.atk, 392 * 1.1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user