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
+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');
|
||||
|
||||
Reference in new issue
Block a user