fix: 避免恢复默认被尚未完成的旧调配同步覆盖

This commit is contained in:
星闌 committed 2026-10-11 16:11:00 +08:00
1 parent bc50cac01f
commit 19c854c1f7
5 files changed
+204 -3

No files matched your search

+5
View File
@@ -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`
+4
View File
@@ -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).
+14
View File
@@ -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.
+7 -3
View File
@@ -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.
+174
View File
@@ -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');
});