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)
This commit is contained in:
1 parent
821ab827bd
commit
ba5b998a72
19 files changed
+580
-60
No files matched your search
@@ -174,6 +174,11 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术
|
||||
A `choice:<effectId>` 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
|
||||
|
||||
+3
-1
@@ -201,7 +201,9 @@
|
||||
单独选择中文、日语或跟随全局语音,只保存在当前浏览器,日语缺失时沿用中文回退。
|
||||
|
||||
同一浏览器的对局窗口意外关闭后,可以从标题或大厅的「恢复本机对局」选择保存的席位。仍被其他窗口使用的席位
|
||||
不会被接管;服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。
|
||||
(包括正在刷新、初始化的窗口)会保留给原窗口;刷新只尝试本窗口原来的身份,不会自动换成另一个近期身份。
|
||||
恢复在当前页面重新连接,服务器确认后才保存新身份;发生竞争时,较早获得服务器确认的窗口优先。
|
||||
服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。
|
||||
|
||||
浏览器提供安装条件时会显示「添加到桌面」。安装后仍需连接游戏服务器,不会把服务器或整套素材离线保存。
|
||||
不支持页面内安装按钮的浏览器可使用其自己的菜单(是否可用由浏览器决定)。
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+40
-2
@@ -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).
|
||||
+5
-1
@@ -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);
|
||||
|
||||
+101
-27
@@ -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<hash>, taken: Set<hash> } 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() });
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_('已达到部署上限'),
|
||||
|
||||
+2
-1
@@ -378,7 +378,8 @@ const target = (v) => {
|
||||
/** @type {Record<string, Record<string, (v:any)=>boolean> & { $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) },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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] });
|
||||
|
||||
+17
-17
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user