diff --git a/docs/META.md b/docs/META.md index 4622a402..6ed0ed11 100644 --- a/docs/META.md +++ b/docs/META.md @@ -768,6 +768,11 @@ leaker's own capsule / row (`ui/hud.js uniteRemaining`) and the leakers' team ro ## 5. Views (DESIGN §8.2 / §8.3) and deviations +The client's stored loadout and `m.private.loadout` / `ops` must agree after a successful sync. In +`public/js/ui/loadoutSync.js`, edits and `welcome` invalidate preparation still awaiting game data, so an older +payload cannot overwrite a newer reset on the session/seat or enter the next match (§16, §29.15). Request reply +ordering, debounce, close flush and retry/lock handling remain unchanged. + `m.public` (throttled ≤ 10/s, only sent when it changed) carries the DESIGN fields plus: `drawnDisabledBonds` (the 3+4 drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`, `combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused` diff --git a/docs/design/match.md b/docs/design/match.md index b5a5729d..0c074a40 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -106,6 +106,10 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno **Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module. +Client sync preparation may await chess/backups data. A later edit (including restoring defaults) or `welcome` +invalidates the earlier preparation: its payload, missing-data result or failure cannot send or change sync status. +The current edit keeps the existing debounce/close flush, ordered requests and retry/lock handling. + **潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」):** official — an operator fights at the player's own potential (调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; an unowned 特许 operator at potential 1) and an owned operator of the same name gets the 自持有 练度 bonus (activity_table `cultivateEffectList`: 未精英化 +0 %, 精英1 ATK / DEF +5 %, 精英2 ATK +10 % DEF / HP +5 %, 精英2 Lv.60 +10 % each — a multiplier of its own, never summed with the 直接乘算 bonuses). The 干员调配 screen sets both per operator (by charId: initial, elite and 自选 forms share them; default 潜能 6 and 精英2 Lv.60); they travel as `room.loadout.ops` (`{ [charId]: { potential, cultivate } }`, defaults dropped; shared/protocol.js `checkLoadoutOps`, strict like the entries), `seats[].ops` → `PlayerState.ops` (re-checked; bots none = the defaults), and every operator the player owns states them in its `PlayerBattleInput` entry (`potential`, `cultivate`; shared/potential.js `cultivationOf`) — never a 补位 stand-in (a not-owned NORMAL chess) or a prototype 自选 pick. The sim composes the def at the potential (data/*.json carry every potential, DATA.md §2.3; the summons' talents at the owner's) and multiplies ATK / DEF / max HP by the tier's `char_attribute_mul` (effects.json `aceffect_char_1…4`, SIM.md §12). The cards (局内数值, shop and detail cards, a teammate's unit from UnitInfo `potential` / `cultivate`) show the numbers at the settings. **Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test). diff --git a/docs/history/0.2.4.md b/docs/history/0.2.4.md index 9f5c8917..b309d4a8 100644 --- a/docs/history/0.2.4.md +++ b/docs/history/0.2.4.md @@ -274,3 +274,17 @@ enemy: picking emitted a unit click with no definition, instead of the tile clic engine-canvas regression retains the card/100 HP assertions and also covers another field refresh, live HP loss and closing the card when the crate is destroyed. Device metadata supplied by the new entry still wins; changing fields does not carry old devices across. Gameplay and allied targeting rules are unchanged. + +### 29.15 Loadout sync preparation after a reset + +`ui/loadoutSync.js` previously captured sanitised entries before awaiting backups, but assigned the request sequence +after that wait. With a saved non-default skill and potential, the loadout header's restore-all button remains +available while backups downloads (chess already ready, detail still hidden). A confirmed reset sends empty entries +and ops; the old preparation can then send the old skill with empty ops, leaving the browser showing zero changes +and synced while the session and next match use the old skill. Reproduced in production v0.2.4 using normal screen +controls in headless Chrome with a controlled backups HTTP download delay; no game-state or message injection. + +Edits and welcomes now invalidate waiting preparation before it can send or report missing/failed data. Current +preparation, debounce/close flush, in-flight request ordering, deduplication and retry/lock semantics are retained. +`test/ui/loadout-sync-stale.test.js` covers reset, newer nonempty edits, debounce, stale failures, welcome and RATE +retry, with current missing-data and unrelated-screen-change controls. No gameplay rule or golden result changes. diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js index b7cd0aa3..51202e5c 100644 --- a/public/js/ui/loadoutSync.js +++ b/public/js/ui/loadoutSync.js @@ -169,6 +169,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, const tell = notify || ((text) => toast(t(text), 'warn')); let timer = null; let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state) + let revision = 0; // edits / welcomes invalidate preparation still waiting for game data let pendingJson = null; // JSON of the newest request still awaiting its reply let lastSent = null; // JSON of the last payload the server accepted (on this session) let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player) @@ -189,9 +190,11 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, async function flush() { if (disposed) return; if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends + const preparing = revision; try { const payload = await prepare(); - if (disposed) return; + // A reset/new edit may already have sent a newer payload while this one awaited chess/backups. + if (disposed || preparing !== revision) return; // never sanitise against missing data: every entry would be dropped and the server's copy cleared if (payload == null) { setState('error'); return; } const json = JSON.stringify(payload); @@ -221,15 +224,16 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); } } } catch (e) { + if (disposed || preparing !== revision) return; console.warn(`[${tag}] sync failed`, e); setState('error'); } } - const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); }); + const offWelcome = net.on('welcome', () => { revision++; lastSent = null; pendingJson = null; seq++; schedule(50); }); const keys = Array.isArray(key) ? key : [key]; const offStore = target.subscribe((s, prev) => { - if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); } + if (keys.some((k) => s[k] !== prev[k])) { revision++; 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. diff --git a/test/ui/loadout-sync-stale.test.js b/test/ui/loadout-sync-stale.test.js new file mode 100644 index 00000000..2ffd0966 --- /dev/null +++ b/test/ui/loadout-sync-stale.test.js @@ -0,0 +1,174 @@ +// Awaiting game data must never let an obsolete loadout overwrite a later edit/reset. +// These are controlled readiness inputs; the real screen/HTTP route is covered by the browser evidence. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createStore } from '../../public/js/store.js'; +import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js'; +import { cultivationCharIds } from '../../shared/protocol.js'; + +const CHESS = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url))); +const BACKUPS = JSON.parse(readFileSync(new URL('../../data/backups.json', import.meta.url))); +const INSIDE = 'chess_char_1_01_a'; +const CHAR = CHESS[INSIDE].charId; +const ENTRIES = { [INSIDE]: { skill: 0 } }; +const OPS = { [CHAR]: { potential: 1 } }; +const IDS = cultivationCharIds(CHESS, BACKUPS); +const ticks = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function waitingData() { + const started = deferred(), result = deferred(); + return { started: started.promise, ...result, ready() { started.resolve(); return result.promise; } }; +} +function harness({ chess = async () => CHESS, backups = async () => BACKUPS } = {}) { + const listeners = new Map(), pending = new Map(); + let time = 0, timerId = 0; + const timers = { + setTimeout(fn, ms) { const id = ++timerId; pending.set(id, { fn, at: time + ms }); return id; }, + clearTimeout(id) { pending.delete(id); }, + async advance(ms) { + time += ms; + for (const [id, task] of [...pending]) if (task.at <= time) { pending.delete(id); task.fn(); await ticks(); } + await ticks(); + }, + }; + const net = { + status: 'online', sent: [], failures: [], + on(name, fn) { listeners.set(name, fn); return () => listeners.delete(name); }, + emit(name, msg = {}) { listeners.get(name)?.(msg); }, + async request(t, fields) { + net.sent.push({ t, ...fields }); + const code = net.failures.shift(); + if (code) throw Object.assign(new Error(code), { code }); + return { t: 'ok' }; + }, + }; + const target = createStore({ entries: ENTRIES, ops: OPS, open: true, sync: 'idle' }); + const sync = installLoadoutSync({ net, timers, target, getChessReady: chess, getBackupsReady: backups, + lookupChess: (id) => CHESS[id], operatorIds: () => IDS }); + return { net, timers, target, ...sync }; +} +const empty = { t: 'room.loadout', entries: {}, ops: {} }; + +test('sync: a reset supersedes the old loadout awaiting backups, even after the reset was accepted', async (t) => { + const wait = waitingData(), h = harness({ backups: wait.ready }); + t.after(h.dispose); + const old = h.flush(); + await wait.started; + // The screen resets ops and entries separately, then the debounced flush sends the empty configuration. + h.target.set({ ops: {} }); + h.target.set({ entries: {} }); + await h.timers.advance(SYNC_DEBOUNCE_MS); + assert.deepEqual(h.net.sent, [empty]); + assert.equal(h.target.get().sync, 'synced'); + wait.resolve(BACKUPS); + await old; + assert.deepEqual(h.net.sent, [empty], 'the late old skill must never go back to the server'); + assert.deepEqual(h.target.get().entries, {}); + assert.deepEqual(h.target.get().ops, {}); + assert.equal(h.target.get().sync, 'synced'); +}); + +test('sync: a later nonempty edit wins while the old backups preparation is waiting', async (t) => { + const wait = waitingData(); let calls = 0; + const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) }); + t.after(h.dispose); + const old = h.flush(); + await wait.started; + h.target.set({ entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } }); + await h.flush(); + wait.resolve(BACKUPS); + await old; + assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } }]); + assert.equal(h.target.get().sync, 'synced'); +}); + +test('sync: an edit invalidates preparation before its debounce runs', async (t) => { + const wait = waitingData(), h = harness({ backups: wait.ready }); + t.after(h.dispose); + const old = h.flush(); + await wait.started; + h.target.set({ entries: {}, ops: {} }); + wait.resolve(BACKUPS); + await old; + assert.deepEqual(h.net.sent, [], 'no stale message during the new edit debounce'); + assert.equal(h.target.get().sync, 'pending'); + await h.timers.advance(SYNC_DEBOUNCE_MS); + assert.deepEqual(h.net.sent, [empty]); + assert.equal(h.target.get().sync, 'synced'); +}); + +for (const failure of ['missing', 'rejected']) test(`sync: obsolete ${failure} data does not replace the accepted reset status`, async (t) => { + const wait = waitingData(), h = harness({ backups: wait.ready }); + t.after(h.dispose); + t.mock.method(console, 'warn', () => {}); + const old = h.flush(); + await wait.started; + h.target.set({ entries: {}, ops: {} }); + await h.flush(); + if (failure === 'missing') wait.resolve(null); else wait.reject(new Error('download failed')); + await old; + assert.deepEqual(h.net.sent, [empty]); + assert.equal(h.target.get().sync, 'synced'); +}); + +test('sync: welcome invalidates preparation from the earlier session', async (t) => { + const wait = waitingData(); let calls = 0; + const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) }); + t.after(h.dispose); + const old = h.flush(); + await wait.started; + h.net.emit('welcome'); + await h.timers.advance(50); + assert.equal(h.target.get().sync, 'synced'); + wait.resolve(null); + await old; + assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]); + assert.equal(h.target.get().sync, 'synced'); +}); + +test('sync: an obsolete result does not swallow the newest reset RATE retry', async (t) => { + const wait = waitingData(), h = harness({ backups: wait.ready }); + t.after(h.dispose); + const old = h.flush(); + await wait.started; + h.target.set({ entries: {}, ops: {} }); + h.net.failures.push('RATE'); + await h.flush(); + assert.equal(h.target.get().sync, 'pending'); + wait.resolve(BACKUPS); + await old; + assert.equal(h.target.get().sync, 'pending'); + await h.timers.advance(RETRY_MS); + assert.deepEqual(h.net.sent, [empty, empty]); + assert.equal(h.target.get().sync, 'synced'); +}); + +test('sync: a current missing-data preparation remains an error and can be retried', async (t) => { + let missing = true; + const h = harness({ backups: async () => missing ? null : BACKUPS }); + t.after(h.dispose); + await h.flush(); + assert.deepEqual(h.net.sent, []); + assert.equal(h.target.get().sync, 'error'); + missing = false; + await h.flush(); + assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]); + assert.equal(h.target.get().sync, 'synced'); +}); + +test('sync: unrelated screen state changes do not cancel a current preparation', async (t) => { + const wait = waitingData(), h = harness({ backups: wait.ready }); + t.after(h.dispose); + const current = h.flush(); + await wait.started; + h.target.set({ sel: INSIDE }); + wait.resolve(BACKUPS); + await current; + assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]); + assert.equal(h.target.get().sync, 'synced'); +});