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
+53
-18
@@ -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<string, any>} 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<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 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<typeof createStore> }} deps
|
||||
* @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 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)) };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user