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:
sganggs committed 2026-10-10 01:58:06 +08:00
1 parent 821ab827bd
commit ba5b998a72
19 files changed
+580 -60

No files matched your search

+5 -1
View File
@@ -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
View File
@@ -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() });
+10 -3
View File
@@ -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');