From ba5b998a72b3e1b8b515cbee3f6c8ff81ce8adcf Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 10 Oct 2026 01:58:06 +0800 Subject: [PATCH] 0.2.3: Clementia S2 releases a dead, hidden, uncarriable or pathless rider at once (slot and weight back before the same tick's boarding checks); same-browser match recovery never takes the seat of a window that is reloading or still starting (a tab keeps its own session token; seats are stamped at the server's welcome, the older welcome wins); golden: diy-135 moves (outcome unchanged) --- docs/META.md | 5 + docs/PLAYING.md | 4 +- docs/SIM.md | 5 + docs/design/client.md | 14 ++- docs/design/engine.md | 3 + docs/design/network.md | 7 ++ docs/history/0.2.3.md | 42 ++++++- public/js/main.js | 6 +- public/js/net.js | 128 +++++++++++++++----- public/js/ui/resumeMatch.js | 13 +- server/net.js | 8 ++ server/sim/content/kits/ops/op-clemnt.js | 14 ++- shared/constants.js | 2 + shared/protocol.js | 3 +- test/client-identity-handshake.test.js | 147 +++++++++++++++++++++++ test/client-static.test.js | 77 ++++++++++++ test/content/op_clemnt.test.js | 64 +++++++++- test/golden/diy.json | 34 +++--- test/ui/followup-023.e2e.test.js | 64 +++++++++- 19 files changed, 580 insertions(+), 60 deletions(-) create mode 100644 test/client-identity-handshake.test.js diff --git a/docs/META.md b/docs/META.md index 6fcb2c78..57dc8d58 100644 --- a/docs/META.md +++ b/docs/META.md @@ -174,6 +174,11 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术 A `choice:` registry handler overrides the default application (§2.4). ### 1.3 Disconnects, AI takeover +* Local multi-window recovery (#431, DESIGN §28.27) sends optional `hello.noReplace` for a tentative shared token, + and `hello.claimAt` for a previously welcomed token. Before binding a different socket, the server rejects a + connected seat when no replacement was allowed or the request has a later stamp than the attached holder + (`SESSION_IN_USE`). Equal stamps still allow normal reload/reconnect. These hints do not authenticate players + and do not change the bearer token or expiration rules below. * Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at the deadline (an open 教鞭 choice is picked at random first, §2.5; then the temp pieces due at that prep are sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server (normal / 联防: re-simulated from t = 0) or, on a boss field, to the partner's replica (DESIGN §14). The session stays diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 33742ac0..c779af26 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -201,7 +201,9 @@ 单独选择中文、日语或跟随全局语音,只保存在当前浏览器,日语缺失时沿用中文回退。 同一浏览器的对局窗口意外关闭后,可以从标题或大厅的「恢复本机对局」选择保存的席位。仍被其他窗口使用的席位 -不会被接管;服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。 +(包括正在刷新、初始化的窗口)会保留给原窗口;刷新只尝试本窗口原来的身份,不会自动换成另一个近期身份。 +恢复在当前页面重新连接,服务器确认后才保存新身份;发生竞争时,较早获得服务器确认的窗口优先。 +服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。 浏览器提供安装条件时会显示「添加到桌面」。安装后仍需连接游戏服务器,不会把服务器或整套素材离线保存。 不支持页面内安装按钮的浏览器可使用其自己的菜单(是否可用由浏览器决定)。 diff --git a/docs/SIM.md b/docs/SIM.md index 7417a858..d297a48e 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -535,6 +535,11 @@ flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`); `aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment, §1). +克莱门莎 S2's kit stores its cabin in `unit.mem.clemntCabin`. Before boarding each tick, it removes passengers +that died, became hidden / uncarryable or cannot path to the next cabin position, removes their carry buff and +returns their current `weight` (including modifiers). The same tick's count and weight limits use that cleaned +list and budget (DESIGN §28.27); this is local to the kit, not a change to general displacement. + **Hit areas (`body.js`, user playtest #5 item 10).** A regular enemy is a point: in a grid range when the tile of its position (`round(y)`, `round(x)`) is a range tile, in a radius when its position is (DESIGN §3). A huge enemy (巨型单位: `enemy.hitArea` from data/enemies.json `hitArea` — 假想敌:胄 / 管 / 盐风主教昆图斯 / 阿利斯泰尔 / “萨米的意志”, PRTS diff --git a/docs/design/client.md b/docs/design/client.md index b21c54f8..572ff17e 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -101,8 +101,18 @@ room loadouts and their exports. The title and lobby offer explicit recovery of a previously saved local match seat. `sp.matches` stores bounded name / room-code metadata indexed by token hash; the existing four-entry `sp.tokens` ring remains the authority. Recovery -rechecks BroadcastChannel claims before adopting the selected token, then reloads. It does not identify players by -name or IP or displace a live tab. Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears +rechecks BroadcastChannel claims before selecting the token in memory and reconnecting in the same document. +Only the server's `welcome` (`saveToken`) persists that selection and its first-welcome stamp (`sp.tokenWelcome`, +bound to the token hash). Refreshing or reconnecting the same token preserves that stamp. A tab with a session token +initializes with that token or null, never another recent token, and answers ownership queries during initialization. +An earlier welcome takes precedence; equal stamps prefer an already welcomed live document over a copied session, +then tab id breaks ties. A session token without a stamp still outranks a tentative shared-token selection. A pending +recovery yields when the original owner returns; a later welcomed holder also gives up its local claim and pending +reconnect. `hello.noReplace` prevents tentative recovery from evicting a connected session even if its hello is already +in flight; `hello.claimAt` preserves the earlier-welcome priority for established holders' in-flight reconnects. A stale +welcome never reaches the store or flushes queued game actions. Nickname persistence also waits for welcome, so a +refused recovery can return to the previous identity and nickname. It does not identify players by name or IP. +Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears obsolete metadata on the next welcome. A spectator never registers a recoverable player seat. `manifest.json` enables browser installation; its original fortress favicon is packaged as actual 192/512 px icons in diff --git a/docs/design/engine.md b/docs/design/engine.md index cfe9dc52..104a829f 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -172,6 +172,9 @@ Profession traits (default behaviours implemented once in the engine, keyed by ` Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core). - kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. The 72 non-collaboration 6★ DIY operators include 克莱门莎 (§28.1); her launch-day provisional mechanics are local to her kit. 黍 GUA-Y and 乌尔比安 CRU-Y follow the generated module blackboards and existing module-level rules (§28.2). + 克莱门莎 S2 removes dead, hidden, self-bound / teleport-immune and unreachable passengers before checking new + boarding in the same tick. Each released passenger returns its current weight to the cabin's remaining budget + and frees its slot (§28.27); neither corpses nor failed carries reserve capacity. - bonds: 23 (battle + meta), exact per-layer formulas from research 02. - garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry). 远牙's granted `garrison_108_a/b` additionally gains on both directions of `dollSwap`, sharing its deployment diff --git a/docs/design/network.md b/docs/design/network.md index 8f7beb99..f0b35ee8 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -14,6 +14,13 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt **Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's. +Local recovery arbitration (0.2.3, §28.27): `hello` also accepts optional `noReplace:boolean` and +`claimAt:number` (finite, nonnegative ms). A shared-token candidate sends `noReplace:true`; a tab with a saved +welcome stamp sends `claimAt`. When that session is already connected to a different socket, `noReplace` or a later +stamp than the attached holder causes `SESSION_IN_USE` before the old socket is touched. A same-stamp own reconnect +can still replace its previous socket. Omitted hints retain legacy behavior; they are not authentication. Client +selection/stamp rules live in design/client.md. In-flight protection requires the updated server as well as client. + ### 8.2 Match C→S (all carry `rid`): `g.infoReady {setupRevision?}` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave` S→C: diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index 084f5785..a39d971e 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -284,9 +284,9 @@ installability check runs in Chrome's ordinary temporary profile. Native OS inst Reproduced with an idle window retaining its own session while a second match window closes. The title/lobby now lists local saved match seats using bounded metadata beside the existing recent-token ring. Explicit selection asks -live tabs to claim the chosen token again before adopting it and reloading. An active tab is never deliberately +live tabs to claim the chosen token again before adopting it (same-document reconnect since §28.27). An active tab is never deliberately replaced; names/IP are not authentication. Missing BroadcastChannel refuses recovery. A server-generated replacement -token or leaving clears obsolete metadata. No server API changes; normal automatic startup selection is preserved. +token or leaving clears obsolete metadata. The initial feature changed no server API; §28.27 later adds optional hello guards and keeps startup on the tab's own identity. Tests: live tab protection, closed-tab recovery, simultaneous claim arbitration, no channel/unknown id, malformed storage and expired metadata; a real two-window browser restores the identical server playerId and room after closing @@ -417,3 +417,41 @@ clear time and end reason. All other 284 scenarios, including the five other gol The focused checks cover all four tier/elite forms, selectability/control/cooldown boundaries, actual casts, healing chains and enemy splash. Full CI passes: 5879 tests, 5876 passed, 0 failed, 3 skipped; setup, smoke, lint, imports and typecheck pass. This round changes no production UI and does not rerun the opt-in browser suites. + + +### 28.27 Cabin capacity and local recovery races — #431 + +- **Observed:** 克莱门莎 S2 removed the carry buff from dead, hidden, uncarryable or unreachable passengers but + retained them in the passenger list and weight budget. Two weight-3 riders blocked a replacement after one died; + a dead zero-weight rider still occupied one of the 15 slots; a dead weight-4 rider retained four budget points. +- **Now:** after moving the cabin, release invalid passengers and return each one's current weight before either + boarding limit is checked that tick. Remaining passengers keep their order and movement. This corrects bookkeeping + under the existing S2 rules and official blackboard limits (sources in §28.1); no new gameplay assumption is added. +- **Observed (#431):** a recovery attempt with a smaller tab id could claim a token while its owner was refreshing + or still initializing. The original page then fell back to another recent identity, or erased its own token. +- **Now:** startup with a session token chooses only that token or null and answers ownership queries immediately. + The first server welcome for a token stamps its ownership; reload/reconnect preserves that stamp. Earlier welcome + wins a conflict, a live document wins an equal-stamp duplicate, and tab id resolves remaining ties. Explicit recovery + keeps its choice only in memory and reconnects in the same document; only welcome persists it. An original owner + returning cancels the lower-priority choice, including a later welcomed holder's reconnect. Nicknames and IPs remain + irrelevant to identity; a refused recovery retains the previous nickname until its own welcome. +- **In-flight handshake:** a hello can reach the server after another connection's welcome. Optional `hello.noReplace` + makes shared candidates refuse a currently connected seat; `hello.claimAt` lets the server refuse an established + holder with a later welcome stamp. Refusal is `SESSION_IN_USE`, before rebinding or closing the incumbent socket. + Normal same-stamp reconnects and older clients remain accepted. These are local ownership hints, not authentication; + the bearer token remains the authority. A revoked hello's stale welcome is ignored before going online, flushing + queued actions or notifying listeners, and retries use a new socket. Reconnect-window duration is unchanged. +- **Regression coverage:** `op_clemnt.test.js` adds same-tick replacement for death / hidden / self-bound / + teleport immunity / failed path, the 15-slot and weight-4 probes, and a changed current weight. The eight regressions + fail before the fix and pass after it. `client-static.test.js` covers live / reload / mid-init, no fallback identity, + welcome-only persistence, earlier-welcome priority and a welcome arriving during init. The real-browser recovery + test holds a refreshing owner's initialization open while another page clicks recovery and checks both server + identities and live sockets; closed-window recovery also checks that welcome arrives without a page reload. + `client-identity-handshake.test.js` controls delayed hellos / welcomes through the real client and server handlers, + including a later holder's buffered reconnect, same-stamp reconnect, stale-welcome action suppression and validation. +- **Golden:** all 285 scenarios regenerated. Only `diy-135` moves: releasing S2 capacity permits more later carries + (teleport events 572 → 630), changing movement, attack/heal timing and RNG consumption. Clear time 194.33 → 198.03 s; + outcome remains `cleared`, 150/150 kills, zero leaks, one allied death and zero errors. The other 284 scenarios and + the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged. +- **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md + (local identity recovery and handshake). diff --git a/public/js/main.js b/public/js/main.js index 6f1ce144..4002509e 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -150,6 +150,7 @@ function onWelcome(msg) { const prev = store.get(); const prevId = prev.me.playerId; const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name; + identity.saveName(name); store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } }); welcomeAt = Date.now(); @@ -214,7 +215,10 @@ function wireNet() { }); net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } })); net.on('welcome', onWelcome); - net.on('helloError', (err) => toastError(err)); + net.on('helloError', (err) => { + if (err.code === 'SESSION_IN_USE') identity.rejectToken(); + toastError(err); + }); net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 })); net.on('unhandledError', (err) => toastError(err)); net.on('room.state', onRoomState); diff --git a/public/js/net.js b/public/js/net.js index fc463155..44510f02 100644 --- a/public/js/net.js +++ b/public/js/net.js @@ -114,6 +114,7 @@ export class Net { * @param {string} [opts.url] socket URL (default: derived from location at connect time) * @param {any} [opts.WebSocket] WebSocket constructor (default: globalThis.WebSocket) * @param {() => (string|null)} [opts.getToken] reconnect-token provider for `hello` + * @param {() => ({ noReplace: boolean, claimAt?: number })} [opts.getTokenClaim] local ownership for this hello * @param {() => number} [opts.now] * @param {() => number} [opts.random] * @param {{setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function}} [opts.timers] @@ -122,6 +123,7 @@ export class Net { this.url = opts.url || null; this.WS = opts.WebSocket || null; this.getToken = typeof opts.getToken === 'function' ? opts.getToken : () => null; + this.getTokenClaim = typeof opts.getTokenClaim === 'function' ? opts.getTokenClaim : () => null; this.now = opts.now || (() => Date.now()); this.random = opts.random || Math.random; this.timers = opts.timers || { @@ -157,6 +159,7 @@ export class Net { this._helloTimer = null; this._helloRid = null; this._helloSentName = null; + this._helloToken = null; this._lastRx = 0; this._unansweredSince = null; // time of the oldest ping sent since the last inbound frame this._clockSamples = []; // [{ offset, rtt }] @@ -254,8 +257,9 @@ export class Net { } /** Drop the current socket and reconnect immediately (fresh hello). */ - reconnectNow() { + reconnectNow(name = this.name) { if (this._manualClose) return; + this.name = name; const ws = this.ws; this._teardownSocket(); try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ } @@ -365,7 +369,13 @@ export class Net { const msg = { t: 'hello', rid, name: this.name, version: PROTOCOL_VERSION }; let token = null; try { token = this.getToken(); } catch { token = null; } - if (typeof token === 'string' && token.length > 0 && token.length <= 64) msg.token = token; + if (typeof token === 'string' && token.length > 0 && token.length <= 64) { + msg.token = token; + const claim = this.getTokenClaim(); + if (claim?.noReplace) msg.noReplace = true; + if (Number.isFinite(claim?.claimAt)) msg.claimAt = claim.claimAt; + } + this._helloToken = token; this._helloRid = rid; this._helloSentName = this.name; if (this.status !== 'handshaking') this._setStatus('handshaking'); @@ -533,6 +543,9 @@ export class Net { const isHelloError = t === 'error' && rid != null && rid === this._helloRid; if (t === 'welcome') { + // A local ownership election can revoke a token after its hello went onto the wire. + // Do not publish or persist that stale welcome, even if the server had already accepted it. + if (this._helloToken !== this.getToken()) { this.reconnectNow(); return; } this._onWelcome(msg); } else if (t === 'pong') { this._onPong(msg); @@ -654,11 +667,15 @@ export class Net { // browsers throttle background-tab timers to ~1/min, which would make a busy background tab look // dead. A duplicated tab (sessionStorage is copied) therefore drops the copied token and becomes a // new player instead of kicking the original tab off its session (server close 4001). Two tabs -// resolving at the same moment tie-break on tab id. Without BroadcastChannel a tab only ever uses +// resolving shared candidates at the same moment tie-break on tab id. An existing session token +// never falls back to another recent identity. Its welcome stamp survives reload; an earlier +// welcome outranks a later claimant, then a live holder wins over its duplicate, then tab id. +// Without BroadcastChannel a tab only ever uses // its own token (never another tab's), so it can't steal a live session either. const K_NAME = 'sp.name'; const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token +const K_WELCOME = 'sp.tokenWelcome'; // sessionStorage: { hash, at } from the first welcome for this token const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first const K_MATCHES = 'sp.matches'; // local metadata for explicit recovery of another closed tab's seat const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen @@ -714,13 +731,15 @@ function defaultChannel() { * Create the identity helper (nickname, "entered" flag and reconnect-token selection; see above). * @param {{ local?: Storage|null, session?: Storage|null, tabId?: string, * channel?: { postMessage: Function, addEventListener?: Function, onmessage?: any } | null, - * setTimeout?: (fn: Function, ms: number) => any, queryMs?: number }} [deps] + * setTimeout?: (fn: Function, ms: number) => any, queryMs?: number, now?: () => number, + * onYield?: () => void }} [deps] */ export function createIdentity(deps = {}) { const local = deps.local !== undefined ? deps.local : safeStorage('localStorage'); const session = deps.session !== undefined ? deps.session : safeStorage('sessionStorage'); const tabId = deps.tabId || Math.random().toString(36).slice(2) + Date.now().toString(36); const setTimer = deps.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms)); + const now = deps.now || (() => Date.now()); const queryMs = Number.isFinite(deps.queryMs) && deps.queryMs >= 0 ? deps.queryMs : CLAIM_QUERY_MS; let channel = null; @@ -729,6 +748,14 @@ export function createIdentity(deps = {}) { let resumeBusy = false; let current = null; // token this tab uses (null = let the server create a session) let resolving = null; // { hashes: Set, taken: Set } while init() waits for claims + let owned = sget(session, K_TOKEN); + if (!isToken(owned)) owned = null; + let welcomeAt = null; + try { + const saved = JSON.parse(sget(session, K_WELCOME) || 'null'); + if (owned && saved?.hash === tokenHash(owned) && Number.isFinite(saved.at) && saved.at >= 0) welcomeAt = saved.at; + } catch { /* old / damaged storage: the session token still claims its seat */ } + let welcomed = false; // this document has received welcome (a copied stamp alone is not a live holder) const readRecent = () => { try { @@ -763,23 +790,52 @@ export function createIdentity(deps = {}) { try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ } }; + // Session ownership is already valid while init is waiting. Shared candidates, including an + // explicit resume, remain tentative until saveToken receives the server's welcome. + const claimFor = (hash) => { + if (owned && tokenHash(owned) === hash) return { owned: true, at: welcomeAt, live: welcomed }; + if ((current && tokenHash(current) === hash) || resolving?.hashes.has(hash)) return { owned: false, at: null, live: false }; + return null; + }; + const peerWins = (peer, from, ours) => { + if (!peer || !ours) return true; // an older client answering "mine" is conservatively treated as live + const a = Number.isFinite(peer.at) && peer.at >= 0 ? peer.at : Infinity; + const b = Number.isFinite(ours.at) && ours.at >= 0 ? ours.at : Infinity; + if (a !== b) return a < b; + if (!!peer.owned !== !!ours.owned) return !!peer.owned; + if (!!peer.live !== !!ours.live) return !!peer.live; + return from < tabId; + }; + const yieldCandidate = (hash) => { + resolving?.taken.add(hash); + // The original page can reappear after resume's query but before its hello. Keep our old + // session in storage and give back the tentative selection immediately in that interval. + if (current && tokenHash(current) === hash) { + if (current === owned) { + owned = null; welcomeAt = null; welcomed = false; + sdel(session, K_WELCOME); + } + current = owned; + if (owned) sset(session, K_TOKEN, owned); + else sdel(session, K_TOKEN); + if (initialized) deps.onYield?.(); + } + }; + function onChannelMessage(ev) { const m = ev && ev.data; if (!m || typeof m !== 'object' || m.from === tabId || typeof m.from !== 'string') return; if (m.type === 'who' && Array.isArray(m.hashes)) { - if (current) { - const h = tokenHash(current); - if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from }); - } - // Another tab is choosing at the same time: the smaller tab id has precedence. - // Answer for pending candidates too: a later channel may have missed our initial query. - if (resolving) for (const h of m.hashes) { + for (const h of m.hashes) { if (typeof h !== 'string') continue; - if (m.from < tabId) resolving.taken.add(h); - else if (resolving.hashes.has(h)) post({ type: 'mine', hash: h, to: m.from }); + const ours = claimFor(h); + if (!ours) continue; + const peer = m.claims?.[h] || { owned: false, at: null, live: false }; + if (peerWins(peer, m.from, ours)) yieldCandidate(h); + else post({ type: 'mine', hash: h, claim: ours, to: m.from }); } - } else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') { - resolving.taken.add(m.hash); + } else if (m.type === 'mine' && m.to === tabId && typeof m.hash === 'string') { + if (peerWins(m.claim, m.from, claimFor(m.hash))) yieldCandidate(m.hash); } } @@ -789,17 +845,16 @@ export function createIdentity(deps = {}) { if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage); else channel.onmessage = onChannelMessage; } - const own = sget(session, K_TOKEN); - const ownOk = isToken(own) ? own : null; // Without a channel we cannot tell whether a shared token is in use: never adopt one. - const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))]; - if (!channel || candidates.length === 0) return ownOk; + const candidates = owned ? [owned] : channel ? [...new Set(readRecent())] : []; + if (!channel || candidates.length === 0) return owned; return queryCandidates(candidates); } async function queryCandidates(candidates) { resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() }; - post({ type: 'who', hashes: [...resolving.hashes] }); + post({ type: 'who', hashes: [...resolving.hashes], + claims: Object.fromEntries([...resolving.hashes].map((h) => [h, claimFor(h)])) }); await new Promise((r) => setTimer(r, queryMs)); const { taken } = resolving; resolving = null; @@ -816,11 +871,15 @@ export function createIdentity(deps = {}) { init() { if (!initPromise) { initPromise = resolveToken().catch(() => null).then((pick) => { - const own = sget(session, K_TOKEN); - if (pick) sset(session, K_TOKEN, pick); - else if (own) sdel(session, K_TOKEN); // duplicated tab: the copied token belongs to a live tab // A welcome may already have set a token while we were waiting: that one wins. - if (!current) current = pick; + if (!welcomed) { + current = pick; + if (pick) sset(session, K_TOKEN, pick); + else { + owned = null; welcomeAt = null; + sdel(session, K_TOKEN); sdel(session, K_WELCOME); + } + } initialized = true; return current; }); @@ -848,7 +907,6 @@ export function createIdentity(deps = {}) { const pick = await queryCandidates([token]); if (!pick) return false; current = pick; - sset(session, K_TOKEN, pick); return true; } catch { return false; } finally { resumeBusy = false; } @@ -864,12 +922,26 @@ export function createIdentity(deps = {}) { const own = sget(session, K_TOKEN); return isToken(own) ? own : null; }, + /** A shared candidate must never replace a connected session, including a hello already in flight. */ + getTokenClaim() { + return current === owned && owned + ? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) } + : { noReplace: true }; + }, + /** Only this page gives up the rejected claim; the shared recovery list belongs to all tabs. */ + rejectToken() { + if (current) yieldCandidate(tokenHash(current)); + }, /** @param {string} token from `welcome` */ saveToken(token) { if (!isToken(token)) return; if (current !== token) rememberMatch(null); + if (owned !== token || welcomeAt == null) welcomeAt = now(); + owned = token; + welcomed = true; current = token; sset(session, K_TOKEN, token); + sset(session, K_WELCOME, JSON.stringify({ hash: tokenHash(token), at: welcomeAt })); writeRecent([token, ...readRecent().filter((t) => t !== token)]); }, /** Forget this tab's token (e.g. the server said the session is invalid). */ @@ -877,7 +949,9 @@ export function createIdentity(deps = {}) { rememberMatch(null); const t = current || sget(session, K_TOKEN); current = null; + owned = null; welcomeAt = null; welcomed = false; sdel(session, K_TOKEN); + sdel(session, K_WELCOME); if (t) writeRecent(readRecent().filter((x) => x !== t)); }, /** Whether this tab already passed the title screen (survives reloads, not new tabs). */ @@ -888,7 +962,7 @@ export function createIdentity(deps = {}) { } /** Browser identity singleton (main.js awaits `identity.init()` before connecting). */ -export const identity = createIdentity(); +export const identity = createIdentity({ onYield: () => net.reconnectNow(net.helloName || net.name) }); /** Browser connection singleton (created lazily-safe: nothing touches the network until connect()). */ -export const net = new Net({ getToken: () => identity.getToken() }); +export const net = new Net({ getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() }); diff --git a/public/js/ui/resumeMatch.js b/public/js/ui/resumeMatch.js index 48ccab7b..21eb91fe 100644 --- a/public/js/ui/resumeMatch.js +++ b/public/js/ui/resumeMatch.js @@ -1,7 +1,8 @@ // Recover an explicitly selected local seat; nickname and IP never identify a player. import { useEffect, useState } from '../../vendor/hooks.module.js'; import { html, Button, Modal } from './components.js'; -import { identity } from '../net.js'; +import { identity, net } from '../net.js'; +import { store } from '../store.js'; import { t } from '../../../shared/i18n.js'; import { toast } from './toasts.js'; @@ -21,9 +22,15 @@ export function ResumeMatchButton() { if (busy) return; setBusy(true); if (await identity.resume(item.id)) { - identity.saveName(item.name); identity.setEntered(true); - location.reload(); + store.patch('session', { entered: true }); + // Keep the tentative token in this document until welcome persists it. Reloading here + // would either lose the selection or falsely turn it into an established session claim. + net.close(); + net.setName(item.name); + net.connect(); + setOpen(false); + setBusy(false); } else { setBusy(false); toast(t('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), 'warn'); diff --git a/server/net.js b/server/net.js index ea815a88..38514e2c 100644 --- a/server/net.js +++ b/server/net.js @@ -96,6 +96,8 @@ export class Session { this.ws = null; /** @type {boolean} */ this.connected = false; + /** @type {number | null} local first-welcome stamp of the attached client (optional, not authentication) */ + this.claimAt = null; /** @type {number} ms epoch of the last inbound frame / pong */ this.lastSeen = now; /** @type {number | null} ms epoch when the socket was lost (null while connected) */ @@ -647,6 +649,11 @@ export class Network { if (!session) { session = msg.token ? this.registry.byToken(msg.token) : null; if (session) { + if (session.connected && session.ws && session.ws !== conn.ws + && (msg.noReplace || (Number.isFinite(msg.claimAt) && session.claimAt != null && msg.claimAt > session.claimAt))) { + this.reply(conn, errorMsg(ERR.SESSION_IN_USE, rid)); + return; + } resumed = true; if (session.ws && session.ws !== conn.ws) this.detachReplaced(session.ws); } else { @@ -656,6 +663,7 @@ export class Network { conn.session = session; session.ws = conn.ws; session.connected = true; + session.claimAt = Number.isFinite(msg.claimAt) ? msg.claimAt : null; session.disconnectedAt = null; } session.name = name; diff --git a/server/sim/content/kits/ops/op-clemnt.js b/server/sim/content/kits/ops/op-clemnt.js index 79604341..d8d2dc52 100644 --- a/server/sim/content/kits/ops/op-clemnt.js +++ b/server/sim/content/kits/ops/op-clemnt.js @@ -79,6 +79,17 @@ export default { if (!c.stopped) { c.moved = Math.min(c.distance, c.moved + SPEED * dt); c.x = c.ox + c.dc * c.moved; c.y = c.oy + c.dr * c.moved; + // Release before either boarding limit is checked: invalid riders occupy neither a slot + // nor weight. Return their current weight, including modifiers applied during the ride. + for (let i = c.passengers.length - 1; i >= 0; i--) { + const e = c.passengers[i], [er, ec] = tile(e); + if (!e.alive || e.hidden || cannotCarry(e) + || !battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) { + battle.removeBuff(e, CARRY + unit.id); + c.passengers.splice(i, 1); + c.mass += e.weight; + } + } const cap = num(b2['attack@max_passenger_cnt'], 15); for (const e of battle.enemies) { if (c.passengers.length >= cap) break; @@ -93,10 +104,7 @@ export default { battle.addBuff(e, { key: CARRY + unit.id, flags: { noMove: true, disarm: true, unblockable: true }, source: unit, tags: ['skill'] }); } for (const e of c.passengers) { - if (!e.alive || e.hidden || cannotCarry(e)) { battle.removeBuff(e, CARRY + unit.id); continue; } if (!e.findBuff(CARRY + unit.id)) continue; - const [er, ec] = tile(e); - if (!battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) { battle.removeBuff(e, CARRY + unit.id); continue; } const fromX = e.x, fromY = e.y; e.x = c.x; e.y = c.y; if (e.route) e.route.pts = null; diff --git a/shared/constants.js b/shared/constants.js index 21b123bf..97608887 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -127,6 +127,7 @@ export const ANIM = Object.freeze({ IDLE: 0, MOVE: 1, ATTACK: 2, SKILL: 3, DIE: export const ERR = Object.freeze({ BAD_MSG: 'BAD_MSG', // malformed / unknown message + SESSION_IN_USE: 'SESSION_IN_USE', // a local recovery candidate cannot replace the connected holder RATE: 'RATE', // rate limited NOT_IN_ROOM: 'NOT_IN_ROOM', ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', @@ -151,6 +152,7 @@ export const ERR = Object.freeze({ }); export const ERR_TEXT = { + SESSION_IN_USE: N_('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), BAD_MSG: N_('无效的请求'), RATE: N_('操作过于频繁'), NOT_IN_ROOM: N_('你不在房间中'), ROOM_NOT_FOUND: N_('未找到该同盟密钥对应的房间'), ROOM_FULL: N_('房间已满'), ROOM_STARTED: N_('模拟已开始'), NOT_HOST: N_('只有房主可以操作'), NOT_READY: N_('仍有玩家未就绪'), WRONG_PHASE: N_('当前阶段无法进行该操作'), NO_FUNDS: N_('资金不足'), HAND_FULL: N_('整备区已满'), BOARD_FULL: N_('已达到部署上限'), diff --git a/shared/protocol.js b/shared/protocol.js index 5e256202..8cd99679 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -378,7 +378,8 @@ const target = (v) => { /** @type {Recordboolean> & { $optional?: string[] }>} */ export const C2S = { // session & lobby - hello: { name: (v) => isStr(v, NAME_MAX_LEN) && v.trim().length > 0, token: (v) => v == null || isStr(v, 64), version: (v) => v == null || isInt(v, 0, 1e6), $optional: ['token', 'version'] }, + hello: { name: (v) => isStr(v, NAME_MAX_LEN) && v.trim().length > 0, token: (v) => v == null || isStr(v, 64), version: (v) => v == null || isInt(v, 0, 1e6), + noReplace: isBool, claimAt: (v) => isNum(v, 0, Number.MAX_SAFE_INTEGER), $optional: ['token', 'version', 'noReplace', 'claimAt'] }, ping: { c: (v) => typeof v === 'number' && Number.isFinite(v) }, 'room.create': { mode: (v) => v === 'solo' || v === 'coop', difficulty: (v) => DIFFICULTIES.includes(v) }, 'room.join': { code: (v) => isStr(v, ROOM_CODE_LEN + 2) && /^[A-Za-z0-9]+$/.test(v) }, diff --git a/test/client-identity-handshake.test.js b/test/client-identity-handshake.test.js new file mode 100644 index 00000000..e06dc627 --- /dev/null +++ b/test/client-identity-handshake.test.js @@ -0,0 +1,147 @@ +// Control delivery across two connections while using the real client and server hello handlers. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createIdentity, Net } from '../public/js/net.js'; +import { Network, SessionRegistry } from '../server/net.js'; +import { validateC2S } from '../shared/protocol.js'; + +function storage() { + const m = new Map(); + return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }; +} +function hub() { + const members = new Set(); + return { create() { + const channel = { + listeners: [], + postMessage(data) { + for (const peer of members) if (peer !== channel) queueMicrotask(() => { + if (members.has(peer)) for (const fn of peer.listeners) fn({ data: structuredClone(data) }); + }); + }, + addEventListener(type, fn) { if (type === 'message') this.listeners.push(fn); }, + close() { members.delete(channel); }, + }; + members.add(channel); return channel; + } }; +} +async function harness(t) { + const local = storage(), ownerSession = storage(), guestSession = storage(), channels = hub(); + const registry = new SessionRegistry(), server = new Network({ registry, handler: { onMessage() {} } }); + const ownerSeat = registry.create('Owner'), guestSeat = registry.create('Guest'), clients = []; + t.after(() => { for (const net of clients) net.close(); clearInterval(server.heartbeatTimer); clearInterval(server.sweepTimer); }); + const ownerChannel = channels.create(); + const owner = createIdentity({ local, session: ownerSession, channel: ownerChannel, tabId: 'z-owner', queryMs: 1, now: () => 100 }); + await owner.init(); owner.saveToken(ownerSeat.token); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' }); + const guest = createIdentity({ local, session: guestSession, channel: channels.create(), tabId: 'a-guest', queryMs: 1, now: () => 200 }); + await guest.init(); guest.saveToken(guestSeat.token); + const client = (identity) => { + const sockets = [], welcomes = [], frames = []; + class Socket { + constructor() { this.readyState = 0; this.sent = []; sockets.push(this); } + send(raw) { this.sent.push(JSON.parse(raw)); } + // Client close need not reach the server before an already sent hello on this connection. + close(code) { this.readyState = 3; this.onclose?.({ code }); } + open() { this.readyState = 1; this.onopen?.(); } + } + const net = new Net({ WebSocket: Socket, getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() }); + clients.push(net); + net.on('welcome', (m) => { welcomes.push(m); identity.saveToken(m.token); }); + net.setName('Owner'); + const ws = sockets[0]; ws.open(); + const conn = { session: null, ip: '?', key: null, ws: { + readyState: 1, bufferedAmount: 0, + send(raw) { frames.push(JSON.parse(raw)); ws.onmessage?.({ data: raw }); }, + close(code) { this.readyState = 3; ws.close(code); }, + } }; + return { net, ws, conn, welcomes, frames, sockets, + hello: () => ws.sent.find((m) => m.t === 'hello'), + deliver() { + const msg = ws.sent.find((m) => m.t === 'hello'); + assert.equal(validateC2S(msg), null); + server.onHelloMsg(conn, msg, Date.now()); + }, + }; + }; + return { local, ownerSession, guestSession, channels, ownerSeat, guestSeat, ownerChannel, owner, guest, client, + async reload() { + const id = createIdentity({ local, session: ownerSession, channel: channels.create(), tabId: 'zz-reloaded', queryMs: 1, now: () => 300 }); + assert.equal(await id.init(), ownerSeat.token); return id; + }, + }; +} + +test('a recovery hello already in flight cannot displace the original window after its reload welcome', async (t) => { + const h = await harness(t); + h.ownerChannel.close(); + assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true); + const pending = h.client(h.guest); + assert.equal(pending.hello().noReplace, true); + const owner = h.client(await h.reload()); owner.deliver(); + assert.equal(h.guest.getToken(), h.guestSeat.token); + pending.deliver(); + assert.equal(pending.frames.at(-1).code, 'SESSION_IN_USE'); + assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null); + assert.equal(h.ownerSeat.ws, owner.conn.ws); + assert.equal(pending.welcomes.length, 0); + assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token); +}); + +test('a later welcomed holder also yields; its buffered reconnect cannot override the earlier welcome', async (t) => { + const h = await harness(t); + h.ownerChannel.close(); + assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true); + const recovered = h.client(h.guest); recovered.deliver(); + assert.equal(recovered.net.status, 'online'); + const delayedReconnect = h.client(h.guest); + assert.equal(delayedReconnect.hello().claimAt, 200); + assert.equal(delayedReconnect.hello().noReplace, undefined); + const owner = h.client(await h.reload()); owner.deliver(); + assert.equal(h.guest.getToken(), null); + assert.equal(h.guestSession.getItem('sp.token'), null); + delayedReconnect.deliver(); + assert.equal(delayedReconnect.frames.at(-1).code, 'SESSION_IN_USE'); + assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null); + assert.equal(h.ownerSeat.ws, owner.conn.ws); + assert.equal(h.ownerSeat.claimAt, 100); +}); + +test('shared recovery can bind an offline seat and a same-stamp reconnect can replace its own socket', async (t) => { + const h = await harness(t); + h.ownerChannel.close(); + assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true); + const first = h.client(h.guest); first.deliver(); + assert.equal(first.welcomes[0].playerId, h.ownerSeat.playerId); + const reconnect = h.client(h.guest); reconnect.deliver(); + assert.equal(reconnect.welcomes[0].playerId, h.ownerSeat.playerId); + const again = h.client(h.guest); again.deliver(); + assert.equal(again.net.status, 'online'); + assert.equal(again.welcomes[0].playerId, h.ownerSeat.playerId); + assert.equal(h.ownerSeat.claimAt, 200); +}); + +test('a stale welcome is discarded before online status, queued actions or welcome listeners', async (t) => { + const h = await harness(t); + h.ownerChannel.close(); + assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true); + const pending = h.client(h.guest), published = [], statuses = []; + pending.net.on('*', (m) => published.push(m)); + pending.net.on('status', (s) => statuses.push(s.status)); + const action = pending.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }); + const rejected = assert.rejects(action, (e) => e.code === 'CLOSED'); + h.guest.rejectToken(); + pending.deliver(); + assert.equal(pending.welcomes.length, 0); + assert.equal(published.length, 0); + assert.ok(!statuses.includes('online')); + assert.ok(!pending.ws.sent.some((m) => m.t === 'room.create')); + assert.equal(pending.sockets.length, 2, 'retry uses a new connection, never a repeat hello on the old session'); + assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token); + pending.net.close(); await rejected; +}); + +test('hello ownership fields reject malformed inputs but remain optional', () => { + const hello = { t: 'hello', name: 'Owner', token: 'seat' }; + for (const fields of [{}, { noReplace: true }, { noReplace: false, claimAt: 100 }]) assert.equal(validateC2S({ ...hello, ...fields }), null); + for (const fields of [{ noReplace: 'yes' }, { claimAt: -1 }, { claimAt: Infinity }, { claimAt: '100' }]) assert.equal(typeof validateC2S({ ...hello, ...fields }), 'string'); +}); diff --git a/test/client-static.test.js b/test/client-static.test.js index 920b457f..d37e3f0d 100644 --- a/test/client-static.test.js +++ b/test/client-static.test.js @@ -1310,6 +1310,83 @@ describe('multi-device & browser compatibility (static)', () => { }); describe('explicit local match recovery (#431)', () => { + async function recoveryPair() { + const { createIdentity } = await mod('net.js'); + const local = memStorage(), hub = channelHub(), ownerSession = memStorage(), guestSession = memStorage(); + const ownerChannel = hub.create(); + const owner = createIdentity({ local, session: ownerSession, tabId: 'z-owner', channel: ownerChannel, queryMs: 5, now: () => 100 }); + const guest = createIdentity({ local, session: guestSession, tabId: 'a-guest', channel: hub.create(), queryMs: 5, now: () => 200 }); + await owner.init(); owner.saveToken('seat-owner'); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' }); + await guest.init(); guest.saveToken('seat-guest'); + return { createIdentity, local, hub, ownerSession, guestSession, ownerChannel, owner, guest, id: guest.recoverable()[0].id }; + } + test('live: a smaller tab id cannot reclaim an active seat', async () => { + const { guest, owner, id } = await recoveryPair(); + assert.equal(await guest.resume(id), false); + assert.equal(guest.getToken(), 'seat-guest'); + assert.equal(owner.getToken(), 'seat-owner'); + }); + test('reload: the original session wins over a recovery attempt during navigation', async () => { + const p = await recoveryPair(); + p.ownerChannel.close(); + assert.equal(await p.guest.resume(p.id), true, 'old document is gone'); + const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5 }); + assert.equal(await reloaded.init(), 'seat-owner'); + assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner'); + assert.equal(p.guest.getToken(), 'seat-guest', 'unconfirmed recovery yields before hello'); + }); + test('midInit: the session token answers claims before init finishes', async () => { + const p = await recoveryPair(); + p.ownerChannel.close(); + let finish; + const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), setTimeout: (fn) => { finish = fn; } }); + const pending = reloaded.init(); + assert.equal(await p.guest.resume(p.id), false); + finish(); + assert.equal(await pending, 'seat-owner'); + assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner'); + assert.equal(p.guest.getToken(), 'seat-guest'); + }); + test('an occupied session token never falls back to a different recent identity', async () => { + const p = await recoveryPair(); + p.local.setItem('sp.tokens', JSON.stringify(['seat-free', 'seat-owner'])); + const duplicate = p.createIdentity({ local: p.local, session: p.ownerSession.clone(), tabId: '0-copy', channel: p.hub.create(), queryMs: 5 }); + assert.equal(await duplicate.init(), null); + assert.equal(duplicate.getToken(), null); + }); + test('resume persists neither its token nor a welcome stamp until the server welcomes it', async () => { + const p = await recoveryPair(); + p.ownerChannel.close(); + const stamp = p.guestSession.getItem('sp.tokenWelcome'); + assert.equal(await p.guest.resume(p.id), true); + assert.equal(p.guestSession.getItem('sp.token'), 'seat-guest'); + assert.equal(p.guestSession.getItem('sp.tokenWelcome'), stamp); + p.guest.saveToken('seat-owner'); + assert.equal(p.guestSession.getItem('sp.token'), 'seat-owner'); + assert.ok(p.guestSession.getItem('sp.tokenWelcome')); + }); + test('an earlier welcome stamp survives reload and beats a later live welcome', async () => { + const p = await recoveryPair(); + p.ownerChannel.close(); + assert.equal(await p.guest.resume(p.id), true); + p.guest.saveToken('seat-owner'); // recovery's welcome is later than the original owner's + const originalStamp = p.ownerSession.getItem('sp.tokenWelcome'); + const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5, now: () => 300 }); + assert.equal(await reloaded.init(), 'seat-owner'); + reloaded.saveToken('seat-owner'); + assert.equal(p.ownerSession.getItem('sp.tokenWelcome'), originalStamp, 'reconnect does not reset seniority'); + }); + test('a welcome received during init cannot be overwritten or erased by its pending result', async () => { + const { createIdentity } = await mod('net.js'); + const local = memStorage(), session = memStorage(), hub = channelHub(); + session.setItem('sp.token', 'old'); + let finish; + const id = createIdentity({ local, session, channel: hub.create(), setTimeout: (fn) => { finish = fn; } }); + const pending = id.init(); + id.saveToken('new'); finish(); + assert.equal(await pending, 'new'); + assert.equal(session.getItem('sp.token'), 'new'); + }); test('an idle tab can reclaim a closed match tab without taking a live one', async () => { const { createIdentity } = await mod('net.js'); const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create(); diff --git a/test/content/op_clemnt.test.js b/test/content/op_clemnt.test.js index bc9b61aa..f24740dd 100644 --- a/test/content/op_clemnt.test.js +++ b/test/content/op_clemnt.test.js @@ -9,7 +9,7 @@ const C = 'char_4231_clemnt'; const data = Object.fromEntries(['chess', 'backups'].map((f) => [f, JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url)))])); const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max(1, Math.abs(b)), `${label}: ${a} != ${b}`); const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts }); -const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), heavy: enemy('heavy', { mass: 9 }), +const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }), fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } }; function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3 } = {}) { const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs, @@ -104,6 +104,68 @@ test('风暴潮 caps passengers at fifteen and cleans them on early skill end', assert.equal(u.mem.clemntCabin, null); done(h); }); +for (const reason of ['death', 'hidden', 'self-bound', 'teleport-immune', 'no-path']) { + test(`风暴潮 releases ${reason} passengers before admitting a replacement in the same tick`, () => { + const { h, u } = field({ skill: 1 }); + const victim = h.spawn('light', { pos: [10, 4] }); + const survivor = h.spawn('light', { pos: [10, 4] }); + start(u); h.run(0.7); + const c = u.mem.clemntCabin, carry = `clemnt:carry:${u.id}`; + assert.deepEqual(c.passengers.map((e) => e.id), [victim.id, survivor.id]); + assert.equal(c.mass, 2); + if (reason === 'death') h.b.dealDamage(u, victim, { amount: 1e12, type: 'true' }); + if (reason === 'hidden') victim.hidden = true; + if (reason === 'self-bound') h.b.addBuff(victim, { key: 'test:bound', flags: { selfBound: true } }); + if (reason === 'teleport-immune') victim.def = { ...victim.def, immune: new Set(['teleport']) }; + if (reason === 'no-path') { + victim.x = 7; victim.y = 12; + for (const [r, col] of [[11, 6], [11, 7], [11, 8], [12, 6], [12, 8]]) h.b.grid.setObstacle(r, col, true); + assert.equal(h.b.grid.findPath(12, 7, Math.round(c.y), Math.round(c.x)), null); + } + const replacement = h.spawn('light', { pos: [c.y, c.x] }); + h.step(); + assert.deepEqual(c.passengers.map((e) => e.id), [survivor.id, replacement.id]); + assert.equal(c.mass, 2); + assert.ok(!victim.findBuff(carry)); + assert.ok(replacement.findBuff(carry)); + approx(replacement.x, c.x); approx(survivor.x, c.x); + h.step(); assert.equal(c.mass, 2, 'released weight is returned only once'); + done(h); + }); +} + +for (const [kind, count] of [['zero', 15], ['mid', 1]]) { + test(`风暴潮 frees the ${kind === 'zero' ? 'fifteenth slot' : 'four-weight budget'} after a passenger dies`, () => { + const { h, u } = field({ skill: 1 }); + const riders = Array.from({ length: count }, () => h.spawn(kind, { pos: [10, 4] })); + start(u); h.run(0.7); + const c = u.mem.clemntCabin; + assert.equal(c.passengers.length, count); + h.b.dealDamage(u, riders[0], { amount: 1e12, type: 'true' }); + const replacement = h.spawn(kind, { pos: [c.y, c.x] }); + h.step(); + assert.equal(c.passengers.length, count); + assert.ok(!c.passengers.includes(riders[0])); + assert.ok(c.passengers.includes(replacement)); + assert.equal(c.mass, kind === 'zero' ? 8 : 4); + done(h); + }); +} + +test('风暴潮 returns a released passenger’s current weight after a weight modifier', () => { + const { h, u } = field({ skill: 1 }); + const victim = h.spawn('light', { pos: [10, 4] }); + start(u); h.run(0.7); + const c = u.mem.clemntCabin; + assert.equal(c.mass, 5); + h.b.addBuff(victim, { key: 'test:lighter', mods: { massFlat: -1 }, flags: { selfBound: true } }); + assert.equal(victim.weight, 2); + h.step(); + assert.equal(c.passengers.length, 0); + assert.equal(c.mass, 7, 'return current weight 2, not boarding weight 3'); + done(h); +}); + test('风暴潮 stops before a blocked tile; vortex keeps its continuous stop point, hits air and applies normal erosion resistance', () => { const { h, u } = field({ skill: 1 }); h.b.grid.setObstacle(10, 5, true); const fly = h.spawn('fly', { pos: [10, 5.55] }); diff --git a/test/golden/diy.json b/test/golden/diy.json index ca725fcc..df3c3b6a 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -5850,38 +5850,38 @@ "round": 13, "seed": 3994643046, "waveId": "act1autochess_h06", - "time": 194.33, - "ticks": 5830, + "time": 198.03, + "ticks": 5941, "reason": "cleared", "killed": 150, "total": 150, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 1629, + "rngDraws": 1626, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":55519,"deaths":1,"layerGains":{}}}, + "players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":56685,"deaths":1,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_5_diy1_a",0,null,2244,1,5,1,6436,260,0,9,2], - ["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,28,337078,4202,0,103,5], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,41,324348,22963,0,192,2], - ["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17661,2190,0,32,8], - ["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,56,614536,3652,0,191,6], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",1571.84,1,145,24,361361,9590,0,109,2], + ["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,29,321186,4202,0,103,5], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,42,323043,24114,0,196,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17637,2190,0,32,8], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,54,636268,3391,0,191,6], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1833.1,1,145,24,356851,9329,0,109,2], ["p1",7,"op","chess_char_6_diy1_b",0,"none",1818.3,1,8,0,0,0,0,0,0], ["p1",8,"op","chess_char_6_diy2_b",1,"none",1818.3,1,12,0,0,0,0,0,0], ["p1",9,"op","chess_char_6_diy1_b",2,"none",1818.3,1,14,0,0,0,0,0,0], - ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,23798,64,16], - ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,21767,55,2], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16570,9955,33,0], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,24672,66,16], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,22077,56,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16846,9935,34,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1249_lysdb_2":[60,60,0,22108,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37149,727834]}, - "hooks": [1,166,5830,1043,1043,2055,2562,461,151,151,43,39,24,10080,0,66,150,0,1,151,0,46,0], - "events": {"atk":1542,"deploy":13,"die":151,"dmg":2562,"engage":6,"fx":796,"heal":191,"skill":82,"spawn":165,"status":95}, - "fx": {"anchor":11,"blast":72,"burst":46,"lock":24,"shieldBreak":60,"teleport":572,"tide":11}, - "snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 6b457668 276d43d1 2bfa5ab2 cc45bcda 05a317f3" + "enemies": {"enemy_1249_lysdb_2":[60,60,0,22385,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37777,727834]}, + "hooks": [1,166,5941,1054,1054,2053,2565,473,151,151,43,39,24,10083,0,64,150,0,1,151,0,47,0], + "events": {"atk":1558,"deploy":13,"die":151,"dmg":2565,"engage":6,"fx":855,"heal":194,"skill":82,"spawn":165,"status":97}, + "fx": {"anchor":11,"blast":72,"burst":47,"lock":24,"shieldBreak":60,"teleport":630,"tide":11}, + "snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 216b4871 ad7483be 0e1ae0c5 b71814eb 25d90322" }, "diy-136": { "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_a=clemnt/S1 6_diy2_a=clemnt/S2 6_diy1_a=clemnt/S3 6_diy2_b=clemnt/S1 6_diy1_b=clemnt/S2 6_diy2_b=clemnt/S3 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe", diff --git a/test/ui/followup-023.e2e.test.js b/test/ui/followup-023.e2e.test.js index 9b255089..df4f0f3f 100644 --- a/test/ui/followup-023.e2e.test.js +++ b/test/ui/followup-023.e2e.test.js @@ -14,9 +14,10 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => { browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] }); }); after(async () => { await browser?.close(); await server?.close(); }); - async function pageIn(ctx, phone = false) { + async function pageIn(ctx, phone = false, random = null) { const page = await ctx.newPage(); await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 }); + if (random != null) await page.evaluateOnNewDocument((value) => { Math.random = () => value; }, random); await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); }); await page.goto(base, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen')); @@ -40,10 +41,69 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => { await b.close(); await a.click('[data-testid="resume-local-match"]'); await a.waitForSelector('.modal .btn--block'); - await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]); + await a.evaluate(() => { globalThis.recoveryDocument = true; }); + await a.click('.modal .btn--block'); await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK'); const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; }); assert.deepEqual(after, before); + assert.equal(await a.evaluate(() => globalThis.recoveryDocument), true, 'welcome arrives in the same document'); + } finally { await ctx.close(); } + }); + test('a refreshing match window keeps its seat while its init is waiting and another window clicks recovery', async () => { + const ctx = await browser.createBrowserContext(); + try { + const guest = await pageIn(ctx, false, 0.01), owner = await pageIn(ctx, false, 0.99); + const before = await owner.evaluate(async () => { + const { net, store } = globalThis.__SP__; + await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }); + await net.request('room.start', {}); + return { id: store.get().me.playerId, code: store.get().room.code, token: sessionStorage.getItem('sp.token') }; + }); + const guestId = await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId); + await owner.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK'); + await guest.waitForSelector('[data-testid="resume-local-match"]'); + await owner.evaluateOnNewDocument(() => { + // Hold precisely the timeout scheduled immediately after the real identity query. + // Messages still use Chrome's BroadcastChannel; only init's completion is controlled. + const Channel = globalThis.BroadcastChannel, timer = globalThis.setTimeout; + let hold = false; + globalThis.BroadcastChannel = class extends Channel { + constructor(name) { super(name); this.identityChannel = name === 'sp.identity'; } + postMessage(message) { + super.postMessage(message); + if (this.identityChannel && message.type === 'who') hold = true; + } + }; + globalThis.setTimeout = (fn, ms, ...args) => { + if (hold) { + hold = false; + globalThis.finishIdentityInit = () => fn(...args); + return 0; + } + return timer(fn, ms, ...args); + }; + }); + await owner.reload({ waitUntil: 'domcontentloaded' }); + await owner.waitForFunction(() => typeof globalThis.finishIdentityInit === 'function'); + await guest.bringToFront(); + await guest.click('[data-testid="resume-local-match"]'); + await guest.waitForSelector('.modal .btn--block'); + await guest.click('.modal .btn--block'); + await guest.waitForFunction(() => document.body.textContent.includes('此对局仍在其他窗口中')); + assert.equal(await owner.evaluate(() => sessionStorage.getItem('sp.token')), before.token); + await owner.evaluate(() => globalThis.finishIdentityInit()); + await owner.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK'); + const after = await owner.evaluate(() => { + const s = globalThis.__SP__.store.get(); + return { id: s.me.playerId, code: s.room.code, token: sessionStorage.getItem('sp.token') }; + }); + assert.deepEqual(after, before); + assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId), guestId); + for (const page of [guest, owner]) { + const pong = await page.evaluate(() => globalThis.__SP__.net.request('ping', { c: Date.now() })); + assert.equal(pong.t, 'pong'); + assert.equal(await page.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online'); + } } finally { await ctx.close(); } }); test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {