CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
981 lines
40 KiB
JavaScript
981 lines
40 KiB
JavaScript
// WebSocket client for the game server (DESIGN §8).
|
||
//
|
||
// - One socket at ws(s)://<host>/ws, JSON text frames `{ t, ...payload }`.
|
||
// - Auto-reconnect with exponential backoff + jitter; a heartbeat (`ping`) measures latency and
|
||
// detects dead sockets (a ping left unanswered — no inbound frame at all — for DEAD_AFTER_MS ⇒
|
||
// close ⇒ reconnect). Measured from the oldest unanswered ping, not from the last inbound frame,
|
||
// so a background tab whose timers the browser throttles to ~1/min is not mistaken for dead.
|
||
// - On every (re)connect, once a player name is known, sends `hello {name, token, version}`;
|
||
// the session is "online" after `welcome`.
|
||
// - `request(t, fields)` adds a `rid` and resolves on the matching `ok` (or any reply carrying the
|
||
// rid), rejects with a NetError on `error` or after REQUEST_TIMEOUT_MS. Requests made while
|
||
// reconnecting are queued and flushed after `welcome` (still bound by their timeout).
|
||
// - Every server push is emitted by its `t` (see shared/protocol.js S2C) and as '*'.
|
||
// Extra events: 'status', 'ping', 'clock', 'helloError', 'unhandledError', 'replaced'.
|
||
// - Server close code 4001 ("session replaced": the same token connected from another tab) stops
|
||
// auto-reconnect (status 'closed', lastError REPLACED) so two tabs never fight over one session.
|
||
// - Before a name is known (title screen) the socket only pings; the server closes such sockets
|
||
// with 4002 (hello timeout). That close is answered with a quiet swap: a fresh socket is opened
|
||
// while the status stays 'connected', so the title's server indicator doesn't flicker.
|
||
// - Server clock offset (serverNow ≈ Date.now() + offset) from pong samples (lowest RTT wins),
|
||
// bootstrapped from `welcome.serverNow` / `m.public.serverNow`.
|
||
//
|
||
// Identity persistence (`identity`, see the section at the end): the name lives in localStorage;
|
||
// the reconnect token lives in sessionStorage (survives reloads of this tab) plus a short list of
|
||
// recent tokens in localStorage (resume after closing/reopening the tab). `identity.init()` asks
|
||
// the other live tabs over a BroadcastChannel which tokens they hold, so a second or duplicated tab
|
||
// of the same browser becomes a separate player instead of hijacking another tab's session.
|
||
//
|
||
// Shared modules are imported relatively: in the browser '../../shared/x.js' from /js/ resolves
|
||
// to /shared/x.js (URL resolution clamps at the root); under Node it resolves to <repo>/shared.
|
||
|
||
import { PROTOCOL_VERSION, ERR_TEXT } from '../../shared/constants.js';
|
||
import { validateC2S } from '../../shared/protocol.js';
|
||
import { N_ } from '../../shared/i18n.js';
|
||
|
||
export const REQUEST_TIMEOUT_MS = 8000;
|
||
export const HELLO_TIMEOUT_MS = 8000;
|
||
export const PING_INTERVAL_MS = 4000;
|
||
export const DEAD_AFTER_MS = 15000;
|
||
export const BACKOFF = Object.freeze({ base: 500, factor: 2, max: 10000, jitter: 0.2 });
|
||
|
||
/** Client-side error codes (in addition to shared ERR codes). */
|
||
export const CLIENT_ERR_TEXT = Object.freeze({
|
||
TIMEOUT: N_('请求超时,请重试'),
|
||
OFFLINE: N_('未连接到服务器'),
|
||
DISCONNECTED: N_('连接已断开,请重试'),
|
||
CLOSED: N_('连接已关闭'),
|
||
REPLACED: N_('该身份已在其他页面登录'),
|
||
VERSION: N_('客户端版本与服务器不一致,请刷新页面'),
|
||
});
|
||
|
||
/** Server close code: the session was taken over by another socket (server/net.js CLOSE.REPLACED). */
|
||
export const CLOSE_REPLACED = 4001;
|
||
/** Server close code: the socket never sent `hello` (server/net.js CLOSE.HELLO_TIMEOUT, ~30–60 s). */
|
||
export const CLOSE_HELLO_TIMEOUT = 4002;
|
||
/** A pre-hello socket must have lived this long before a 4002 close is swapped quietly (no loop). */
|
||
const QUIET_SWAP_MIN_AGE_MS = 5000;
|
||
|
||
/**
|
||
* Human readable (Chinese) text for an error code.
|
||
* @param {string} code
|
||
* @param {string} [msg] server-provided message (fallback)
|
||
* @returns {string}
|
||
*/
|
||
export function errorText(code, msg) {
|
||
return ERR_TEXT[code] || CLIENT_ERR_TEXT[code] || (typeof msg === 'string' && msg) || String(code || N_('未知错误'));
|
||
}
|
||
|
||
/** Error thrown/rejected by requests. `code` is an ERR code or a CLIENT_ERR_TEXT key. */
|
||
export class NetError extends Error {
|
||
/** @param {string} code @param {string} [msg] server text @param {string} [detail] server developer detail */
|
||
constructor(code, msg, detail) {
|
||
const versionMismatch = typeof detail === 'string' && /version/i.test(detail);
|
||
super(versionMismatch ? CLIENT_ERR_TEXT.VERSION : errorText(code, msg));
|
||
this.name = 'NetError';
|
||
this.code = String(code || 'INTERNAL');
|
||
this.serverMsg = msg ?? null;
|
||
this.detail = typeof detail === 'string' ? detail : null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reconnect delay for the n-th consecutive failed attempt (0-based), with ±jitter.
|
||
* @param {number} attempt
|
||
* @param {() => number} [rand]
|
||
* @returns {number} milliseconds
|
||
*/
|
||
export function backoffDelay(attempt, rand = Math.random) {
|
||
const n = Math.max(0, Math.min(30, Math.floor(Number(attempt) || 0)));
|
||
const raw = Math.min(BACKOFF.max, BACKOFF.base * BACKOFF.factor ** n);
|
||
const jitter = raw * BACKOFF.jitter * (rand() * 2 - 1);
|
||
return Math.max(100, Math.round(raw + jitter));
|
||
}
|
||
|
||
/**
|
||
* WebSocket URL for the current page (`ws(s)://host/ws`).
|
||
* @param {{protocol: string, host: string}} [loc]
|
||
* @returns {string}
|
||
*/
|
||
export function defaultWsUrl(loc = globalThis.location) {
|
||
if (!loc || !loc.host) return 'ws://localhost:3000/ws';
|
||
return `${loc.protocol === 'https:' ? 'wss' : 'ws'}://${loc.host}/ws`;
|
||
}
|
||
|
||
const WS_OPEN = 1;
|
||
const WS_CONNECTING = 0;
|
||
|
||
/**
|
||
* Game server connection. Construct with injectable dependencies for tests.
|
||
*/
|
||
export class Net {
|
||
/**
|
||
* @param {object} [opts]
|
||
* @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]
|
||
*/
|
||
constructor(opts = {}) {
|
||
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 || {
|
||
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
||
clearTimeout: (id) => globalThis.clearTimeout(id),
|
||
setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
|
||
clearInterval: (id) => globalThis.clearInterval(id),
|
||
};
|
||
|
||
/** @type {'idle'|'connecting'|'connected'|'handshaking'|'online'|'reconnecting'|'closed'} */
|
||
this.status = 'idle';
|
||
this.ws = null;
|
||
this.name = null; // desired player name (hello is sent when set)
|
||
this.helloName = null; // name we sent in the hello that got the last welcome
|
||
this.serverName = null; // name as normalised by the server
|
||
this.playerId = null;
|
||
this.attempt = 0; // consecutive failed connection attempts
|
||
this.retryAt = 0; // epoch ms of the next reconnect attempt (0 = none)
|
||
this.ping = null; // last RTT in ms
|
||
this.lastError = null; // last NetError relevant to the connection (e.g. hello rejected)
|
||
this.clockOffset = 0;
|
||
this.clockSynced = false;
|
||
|
||
this._rid = 0;
|
||
this._pending = new Map(); // rid -> { msg, resolve, reject, timer, sent }
|
||
this._expired = new Set(); // rids that timed out locally (their late error was already reported)
|
||
this._listeners = new Map();
|
||
this._manualClose = false;
|
||
this._quietSwap = false; // next connect() keeps the current status (see CLOSE_HELLO_TIMEOUT)
|
||
this._openedAt = 0;
|
||
this._reconnectTimer = null;
|
||
this._pingTimer = null;
|
||
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 }]
|
||
}
|
||
|
||
// ---- events ----------------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Subscribe to a message type (any S2C `t`), '*' (all messages) or a client event
|
||
* ('status' | 'ping' | 'clock' | 'helloError' | 'unhandledError').
|
||
* @param {string} type
|
||
* @param {(payload: any) => void} fn
|
||
* @returns {() => void} unsubscribe
|
||
*/
|
||
on(type, fn) {
|
||
if (typeof fn !== 'function') return () => {};
|
||
let set = this._listeners.get(type);
|
||
if (!set) this._listeners.set(type, (set = new Set()));
|
||
set.add(fn);
|
||
return () => this.off(type, fn);
|
||
}
|
||
|
||
/** @param {string} type @param {Function} fn */
|
||
off(type, fn) {
|
||
this._listeners.get(type)?.delete(fn);
|
||
}
|
||
|
||
_emit(type, payload) {
|
||
const set = this._listeners.get(type);
|
||
if (!set || set.size === 0) return;
|
||
for (const fn of [...set]) {
|
||
try { fn(payload); } catch (err) { console.error(`[net] listener for "${type}" failed`, err); }
|
||
}
|
||
}
|
||
|
||
/** Snapshot of the connection state (what the 'status' event carries). */
|
||
snapshot() {
|
||
return {
|
||
status: this.status, attempt: this.attempt, retryAt: this.retryAt, ping: this.ping,
|
||
lastError: this.lastError ? { code: this.lastError.code, text: this.lastError.message } : null,
|
||
playerId: this.playerId,
|
||
};
|
||
}
|
||
|
||
_setStatus(status) {
|
||
this.status = status;
|
||
this._emit('status', this.snapshot());
|
||
}
|
||
|
||
// ---- lifecycle -------------------------------------------------------------------------------
|
||
|
||
/** Open the socket (no-op when already open/connecting). Also resumes after close()/replacement. */
|
||
connect() {
|
||
const quiet = this._quietSwap;
|
||
this._quietSwap = false;
|
||
if (this.ws && (this.ws.readyState === WS_OPEN || this.ws.readyState === WS_CONNECTING)) return;
|
||
this._manualClose = false;
|
||
this._clearTimer('_reconnectTimer', 'clearTimeout');
|
||
this.retryAt = 0;
|
||
const WS = this.WS || globalThis.WebSocket;
|
||
const url = this.url || defaultWsUrl();
|
||
let ws;
|
||
try {
|
||
ws = new WS(url);
|
||
} catch (err) {
|
||
console.warn('[net] cannot create WebSocket', err);
|
||
this._scheduleReconnect();
|
||
return;
|
||
}
|
||
this.ws = ws;
|
||
if (!quiet) this._setStatus(this.attempt > 0 ? 'reconnecting' : 'connecting');
|
||
ws.onopen = () => { if (ws === this.ws) this._onOpen(); };
|
||
ws.onmessage = (ev) => { if (ws === this.ws) this._onMessage(ev?.data); };
|
||
ws.onerror = () => { /* followed by close */ };
|
||
ws.onclose = (ev) => { if (ws === this.ws) this._onClose(ev); };
|
||
}
|
||
|
||
/** Close for good (no reconnect). Pending requests reject with CLOSED. */
|
||
close() {
|
||
this._manualClose = true;
|
||
this._clearTimer('_reconnectTimer', 'clearTimeout');
|
||
this.retryAt = 0;
|
||
const ws = this.ws;
|
||
this._teardownSocket();
|
||
try { ws?.close(1000, 'client close'); } catch { /* ignore */ }
|
||
this._failPending('CLOSED', true);
|
||
this._setStatus('closed');
|
||
}
|
||
|
||
/** Skip the backoff wait and reconnect right now (used on `online` / tab focus / retry button). */
|
||
retryNow() {
|
||
if (this._manualClose) return;
|
||
if (this.ws && (this.ws.readyState === WS_OPEN || this.ws.readyState === WS_CONNECTING)) return;
|
||
this.connect();
|
||
}
|
||
|
||
/** Drop the current socket and reconnect immediately (fresh hello). */
|
||
reconnectNow(name = this.name) {
|
||
if (this._manualClose) return;
|
||
this.name = name;
|
||
const ws = this.ws;
|
||
this._teardownSocket();
|
||
try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ }
|
||
this._failPending('DISCONNECTED', false);
|
||
this.attempt = 0;
|
||
this.connect();
|
||
}
|
||
|
||
/**
|
||
* Set the player name used in `hello`. Sends hello right away when the socket is open (the server
|
||
* accepts a repeated hello on a live socket: it renames the session and resyncs room/match
|
||
* state), otherwise as soon as it opens. No-op when that name is already accepted.
|
||
* @param {string} name
|
||
*/
|
||
setName(name) {
|
||
const n = typeof name === 'string' ? name.trim() : '';
|
||
if (!n) return;
|
||
const changed = n !== this.name;
|
||
this.name = n;
|
||
if (!this.ws || this.ws.readyState !== WS_OPEN) {
|
||
if (!this.ws && !this._reconnectTimer && !this._manualClose) this.connect();
|
||
return; // hello goes out on open
|
||
}
|
||
if (this.status === 'online' && this.helloName === n) return;
|
||
if (this.status === 'handshaking' && !changed && this._helloSentName === n) return;
|
||
// The server accepts a repeated hello on a live socket (it renames the session and resyncs).
|
||
this._sendHello();
|
||
}
|
||
|
||
_onOpen() {
|
||
this._openedAt = this.now();
|
||
this._lastRx = this._openedAt;
|
||
this._unansweredSince = null;
|
||
this.retryAt = 0;
|
||
this._startHeartbeat();
|
||
if (this.name) {
|
||
this._setStatus('handshaking');
|
||
this._sendHello();
|
||
} else {
|
||
this.attempt = 0;
|
||
this._setStatus('connected');
|
||
this._sendPing();
|
||
}
|
||
}
|
||
|
||
_onClose(ev) {
|
||
this._teardownSocket();
|
||
this._failPending('DISCONNECTED', false);
|
||
if (this._manualClose) { this._setStatus('closed'); return; }
|
||
if (ev && ev.code === CLOSE_REPLACED) {
|
||
// Another tab/socket took this session over: don't fight it; the user may reconnect manually.
|
||
this._manualClose = true;
|
||
this._failPending('REPLACED', true);
|
||
this.lastError = new NetError('REPLACED');
|
||
this._setStatus('closed');
|
||
this._emit('replaced', this.lastError);
|
||
return;
|
||
}
|
||
if (ev && ev.code === CLOSE_HELLO_TIMEOUT && !this.name && this.status === 'connected'
|
||
&& this.now() - this._openedAt >= QUIET_SWAP_MIN_AGE_MS) {
|
||
// Idle pre-hello socket dropped by the server: replace it without a visible status change.
|
||
this._quietSwap = true;
|
||
this.connect();
|
||
return;
|
||
}
|
||
this._scheduleReconnect();
|
||
}
|
||
|
||
_scheduleReconnect() {
|
||
if (this._manualClose) return;
|
||
const delay = backoffDelay(this.attempt, this.random);
|
||
this.attempt += 1;
|
||
this.retryAt = this.now() + delay;
|
||
this._clearTimer('_reconnectTimer', 'clearTimeout');
|
||
this._reconnectTimer = this.timers.setTimeout(() => {
|
||
this._reconnectTimer = null;
|
||
this.connect();
|
||
}, delay);
|
||
this._setStatus('reconnecting');
|
||
}
|
||
|
||
_teardownSocket() {
|
||
const ws = this.ws;
|
||
if (ws) {
|
||
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
|
||
}
|
||
this.ws = null;
|
||
this._unansweredSince = null;
|
||
this._clearTimer('_pingTimer', 'clearInterval');
|
||
this._clearTimer('_helloTimer', 'clearTimeout');
|
||
this._helloRid = null;
|
||
this._helloSentName = null;
|
||
}
|
||
|
||
_clearTimer(field, fn) {
|
||
if (this[field] != null) {
|
||
try { this.timers[fn](this[field]); } catch { /* ignore */ }
|
||
this[field] = null;
|
||
}
|
||
}
|
||
|
||
// ---- handshake -------------------------------------------------------------------------------
|
||
|
||
_sendHello() {
|
||
if (!this.name) return;
|
||
const rid = this._nextRid();
|
||
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;
|
||
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');
|
||
if (!this._sendRaw(msg)) return;
|
||
this._clearTimer('_helloTimer', 'clearTimeout');
|
||
this._helloTimer = this.timers.setTimeout(() => {
|
||
this._helloTimer = null;
|
||
if (this.status !== 'handshaking') return;
|
||
console.warn('[net] hello timed out; reconnecting');
|
||
const ws = this.ws;
|
||
this._teardownSocket();
|
||
try { ws?.close(4000, 'hello timeout'); } catch { /* ignore */ }
|
||
this._failPending('DISCONNECTED', false);
|
||
this._scheduleReconnect();
|
||
}, HELLO_TIMEOUT_MS);
|
||
}
|
||
|
||
_onWelcome(msg) {
|
||
this._clearTimer('_helloTimer', 'clearTimeout');
|
||
this._helloRid = null;
|
||
// Compare future setName() calls against what we sent (the server may normalise the name).
|
||
this.helloName = this._helloSentName;
|
||
this.serverName = typeof msg.name === 'string' && msg.name ? msg.name : this._helloSentName;
|
||
this.playerId = msg.playerId ?? null;
|
||
this.attempt = 0;
|
||
this.lastError = null;
|
||
if (Number.isFinite(msg.serverNow)) this._addClockSample(msg.serverNow + (this.ping ?? 0) / 2 - this.now(), Infinity);
|
||
this._setStatus('online');
|
||
this._flushQueue();
|
||
this._sendPing();
|
||
}
|
||
|
||
_onHelloError(msg) {
|
||
this._clearTimer('_helloTimer', 'clearTimeout');
|
||
this._helloRid = null;
|
||
this.lastError = new NetError(msg.code, msg.msg, msg.detail);
|
||
this._setStatus('connected');
|
||
// Queued requests can't be sent without a session.
|
||
this._failPending('OFFLINE', true);
|
||
this._emit('helloError', this.lastError);
|
||
}
|
||
|
||
// ---- requests --------------------------------------------------------------------------------
|
||
|
||
_nextRid() {
|
||
this._rid = (this._rid % 2147483000) + 1;
|
||
return this._rid;
|
||
}
|
||
|
||
/**
|
||
* Send a request and wait for its `ok` (resolves with the reply message) or `error`
|
||
* (rejects with NetError). Rejects with TIMEOUT after `timeout` ms, OFFLINE when there is no
|
||
* session to send it on, BAD_MSG when it fails shared/protocol.js validation locally.
|
||
* @param {string} t message type (C2S)
|
||
* @param {object} [fields]
|
||
* @param {{timeout?: number}} [opts]
|
||
* @returns {Promise<any>}
|
||
*/
|
||
request(t, fields = {}, opts = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
if (t === 'hello') { reject(new NetError('BAD_MSG', 'use setName() for hello')); return; }
|
||
const rid = this._nextRid();
|
||
const msg = { ...(fields && typeof fields === 'object' ? fields : {}), t, rid };
|
||
const bad = validateC2S(msg);
|
||
if (bad) {
|
||
console.warn(`[net] refusing invalid ${t}: ${bad}`);
|
||
reject(new NetError('BAD_MSG', bad));
|
||
return;
|
||
}
|
||
// Queue while a session is being (re)established; fail fast when none can come.
|
||
const establishing = this.status === 'connecting' || this.status === 'handshaking' || this.status === 'reconnecting';
|
||
if (this.status !== 'online' && !(establishing && this.name && !this._manualClose)) {
|
||
reject(new NetError(this._manualClose ? 'CLOSED' : 'OFFLINE'));
|
||
return;
|
||
}
|
||
const timeout = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : REQUEST_TIMEOUT_MS;
|
||
const entry = { msg, resolve, reject, sent: false, timer: null };
|
||
entry.timer = this.timers.setTimeout(() => {
|
||
if (this._pending.get(rid) !== entry) return;
|
||
this._pending.delete(rid);
|
||
if (entry.sent) {
|
||
this._expired.add(rid);
|
||
if (this._expired.size > 64) this._expired.delete(this._expired.values().next().value);
|
||
}
|
||
reject(new NetError('TIMEOUT'));
|
||
}, timeout);
|
||
this._pending.set(rid, entry);
|
||
if (this.status === 'online') entry.sent = this._sendRaw(msg);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Fire-and-forget message (no rid). Returns false when it could not be sent.
|
||
* @param {string} t
|
||
* @param {object} [fields]
|
||
* @returns {boolean}
|
||
*/
|
||
send(t, fields = {}) {
|
||
if (t === 'hello') return false;
|
||
const msg = { ...(fields && typeof fields === 'object' ? fields : {}), t };
|
||
const bad = validateC2S(msg);
|
||
if (bad) { console.warn(`[net] refusing invalid ${t}: ${bad}`); return false; }
|
||
if (this.status !== 'online') return false;
|
||
return this._sendRaw(msg);
|
||
}
|
||
|
||
_sendRaw(obj) {
|
||
const ws = this.ws;
|
||
if (!ws || ws.readyState !== WS_OPEN) return false;
|
||
try {
|
||
ws.send(JSON.stringify(obj));
|
||
return true;
|
||
} catch (err) {
|
||
console.warn('[net] send failed', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
_flushQueue() {
|
||
for (const entry of this._pending.values()) {
|
||
if (!entry.sent) entry.sent = this._sendRaw(entry.msg);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reject pending requests. Sent ones always fail (their reply is lost with the socket);
|
||
* unsent (queued) ones fail only when `includeQueued` is true.
|
||
*/
|
||
_failPending(code, includeQueued) {
|
||
for (const [rid, entry] of [...this._pending]) {
|
||
if (!entry.sent && !includeQueued) continue;
|
||
this._pending.delete(rid);
|
||
this._clearEntryTimer(entry);
|
||
try { entry.reject(new NetError(code)); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
_clearEntryTimer(entry) {
|
||
if (entry.timer != null) {
|
||
try { this.timers.clearTimeout(entry.timer); } catch { /* ignore */ }
|
||
entry.timer = null;
|
||
}
|
||
}
|
||
|
||
/** Number of requests waiting for a reply (sent or queued). */
|
||
get pendingCount() {
|
||
return this._pending.size;
|
||
}
|
||
|
||
// ---- incoming --------------------------------------------------------------------------------
|
||
|
||
_onMessage(data) {
|
||
this._lastRx = this.now();
|
||
this._unansweredSince = null;
|
||
let msg;
|
||
try {
|
||
msg = JSON.parse(typeof data === 'string' ? data : String(data));
|
||
} catch {
|
||
console.warn('[net] dropped non-JSON frame');
|
||
return;
|
||
}
|
||
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || typeof msg.t !== 'string') return;
|
||
const { t } = msg;
|
||
const rid = msg.rid;
|
||
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);
|
||
} else if (isHelloError) {
|
||
this._onHelloError(msg);
|
||
} else if (t === 'm.public' && Number.isFinite(msg.serverNow) && !this.clockSynced) {
|
||
this._addClockSample(msg.serverNow + (this.ping ?? 0) / 2 - this.now(), Infinity);
|
||
}
|
||
|
||
let handled = false;
|
||
if (rid != null && t !== 'welcome' && this._pending.has(rid)) {
|
||
const entry = this._pending.get(rid);
|
||
this._pending.delete(rid);
|
||
this._clearEntryTimer(entry);
|
||
handled = true;
|
||
try {
|
||
if (t === 'error') entry.reject(new NetError(msg.code, msg.msg, msg.detail));
|
||
else entry.resolve(msg);
|
||
} catch (err) { console.error('[net] request callback failed', err); }
|
||
}
|
||
const late = rid != null && !handled && this._expired.delete(rid); // reply to a request that already timed out
|
||
if (t === 'error' && !handled && !isHelloError && !late) {
|
||
this._emit('unhandledError', new NetError(msg.code, msg.msg, msg.detail));
|
||
}
|
||
|
||
this._emit(t, msg);
|
||
this._emit('*', msg);
|
||
}
|
||
|
||
// ---- heartbeat & clock -----------------------------------------------------------------------
|
||
|
||
_startHeartbeat() {
|
||
this._clearTimer('_pingTimer', 'clearInterval');
|
||
this._pingTimer = this.timers.setInterval(() => this._heartbeat(), PING_INTERVAL_MS);
|
||
}
|
||
|
||
_heartbeat() {
|
||
const ws = this.ws;
|
||
if (!ws || ws.readyState !== WS_OPEN) return;
|
||
const live = this.status === 'online' || this.status === 'connected';
|
||
if (live && this._unansweredSince != null && this.now() - this._unansweredSince > DEAD_AFTER_MS) {
|
||
console.warn('[net] connection silent; reconnecting');
|
||
this._teardownSocket();
|
||
try { ws.close(4000, 'heartbeat timeout'); } catch { /* ignore */ }
|
||
this._failPending('DISCONNECTED', false);
|
||
this._scheduleReconnect();
|
||
return;
|
||
}
|
||
this._sendPing();
|
||
}
|
||
|
||
_sendPing() {
|
||
if (this.status !== 'online' && this.status !== 'connected') return;
|
||
const now = this.now();
|
||
if (this._sendRaw({ t: 'ping', c: now }) && this._unansweredSince == null) this._unansweredSince = now;
|
||
}
|
||
|
||
/** Force an immediate latency probe (e.g. when the tab regains focus). */
|
||
probe() {
|
||
this._heartbeat();
|
||
}
|
||
|
||
_onPong(msg) {
|
||
const now = this.now();
|
||
const c = Number(msg.c);
|
||
if (!Number.isFinite(c)) return;
|
||
const rtt = now - c;
|
||
if (!(rtt >= 0 && rtt < 60000)) return;
|
||
this.ping = Math.round(rtt);
|
||
if (Number.isFinite(msg.s)) this._addClockSample(msg.s + rtt / 2 - now, rtt);
|
||
this._emit('ping', this.ping);
|
||
this._emit('status', this.snapshot());
|
||
}
|
||
|
||
_addClockSample(offset, rtt) {
|
||
if (!Number.isFinite(offset)) return;
|
||
this._clockSamples.push({ offset, rtt });
|
||
if (this._clockSamples.length > 8) this._clockSamples.shift();
|
||
let best = this._clockSamples[0];
|
||
for (const s of this._clockSamples) if (s.rtt < best.rtt) best = s;
|
||
const prev = this.clockOffset;
|
||
this.clockOffset = Math.round(best.offset);
|
||
const wasSynced = this.clockSynced;
|
||
this.clockSynced = this.clockSynced || Number.isFinite(rtt);
|
||
if (Math.abs(prev - this.clockOffset) >= 1 || wasSynced !== this.clockSynced || this._clockSamples.length === 1) {
|
||
this._emit('clock', { offset: this.clockOffset, rtt: Number.isFinite(best.rtt) ? best.rtt : null, synced: this.clockSynced });
|
||
}
|
||
}
|
||
|
||
/** Current server time estimate (ms epoch). */
|
||
serverNow() {
|
||
return this.now() + this.clockOffset;
|
||
}
|
||
|
||
/**
|
||
* Hook browser signals: reconnect immediately when the network comes back or the tab becomes
|
||
* visible, and probe latency on focus. Safe to call in non-browser environments (no-op).
|
||
*/
|
||
attachBrowserHooks() {
|
||
if (typeof window === 'undefined' || this._hooked) return;
|
||
this._hooked = true;
|
||
window.addEventListener('online', () => this.retryNow());
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (this.status === 'reconnecting') this.retryNow();
|
||
else if (this.status === 'online' || this.status === 'connected') this.probe();
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---- identity persistence -------------------------------------------------------------------------
|
||
//
|
||
// Which reconnect token a tab uses is decided once per page load by `identity.init()`:
|
||
// 1. this tab's own token (sessionStorage: survives reloads of this tab),
|
||
// 2. else the most recent token of this browser (localStorage, so closing the tab and reopening
|
||
// the game within the server's reconnect window resumes the seat),
|
||
// skipping every token that another *live* tab of this browser is using. Liveness is asked over a
|
||
// BroadcastChannel ('who holds these token hashes?' → 'mine'), not inferred from timer heartbeats:
|
||
// 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 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
|
||
const RECENT_MAX = 4;
|
||
const TOKEN_MAX_LEN = 64; // protocol limit for hello.token
|
||
const CHANNEL_NAME = 'sp.identity';
|
||
/** How long init() waits for other tabs to claim a candidate token (ms). */
|
||
export const CLAIM_QUERY_MS = 150;
|
||
|
||
function safeStorage(kind) {
|
||
try {
|
||
const s = globalThis[kind];
|
||
if (!s) return null;
|
||
const probe = '__sp_probe__';
|
||
s.setItem(probe, '1');
|
||
s.removeItem(probe);
|
||
return s;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function sget(s, k) { try { return s ? s.getItem(k) : null; } catch { return null; } }
|
||
function sset(s, k, v) { try { if (s) s.setItem(k, v); } catch { /* quota / privacy mode */ } }
|
||
function sdel(s, k) { try { if (s) s.removeItem(k); } catch { /* ignore */ } }
|
||
|
||
const isToken = (t) => typeof t === 'string' && t.length > 0 && t.length <= TOKEN_MAX_LEN;
|
||
|
||
/**
|
||
* Short, stable hash of a token (FNV-1a, hex) so tabs never broadcast tokens in clear.
|
||
* @param {string} token
|
||
* @returns {string}
|
||
*/
|
||
export function tokenHash(token) {
|
||
let h = 2166136261;
|
||
const s = String(token);
|
||
for (let i = 0; i < s.length; i++) {
|
||
h ^= s.charCodeAt(i);
|
||
h = Math.imul(h, 16777619) >>> 0;
|
||
}
|
||
return h.toString(16).padStart(8, '0');
|
||
}
|
||
|
||
function defaultChannel() {
|
||
try {
|
||
return typeof globalThis.BroadcastChannel === 'function' ? new globalThis.BroadcastChannel(CHANNEL_NAME) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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, 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;
|
||
let initPromise = null;
|
||
let initialized = false;
|
||
let resumeBusy = false;
|
||
let explicitClaim = false; // account entry POST supplied the token; do not persist until welcome
|
||
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 {
|
||
const v = JSON.parse(sget(local, K_RECENT) || '[]');
|
||
return Array.isArray(v) ? v.filter(isToken) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
const writeRecent = (list) => sset(local, K_RECENT, JSON.stringify([...new Set(list)].slice(0, RECENT_MAX)));
|
||
|
||
const readMatches = () => {
|
||
try {
|
||
const v = JSON.parse(sget(local, K_MATCHES) || '{}');
|
||
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
||
} catch { return {}; }
|
||
};
|
||
const rememberMatch = (info) => {
|
||
if (!current) return;
|
||
const old = readMatches(), next = {};
|
||
for (const token of readRecent()) {
|
||
const hash = tokenHash(token);
|
||
const v = token === current ? info : old[hash];
|
||
if (v && typeof v.name === 'string' && typeof v.code === 'string') {
|
||
next[hash] = { name: v.name.slice(0, 64), code: v.code.slice(0, 32) };
|
||
}
|
||
}
|
||
sset(local, K_MATCHES, JSON.stringify(next));
|
||
};
|
||
|
||
const post = (msg) => {
|
||
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) {
|
||
explicitClaim = false;
|
||
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)) {
|
||
for (const h of m.hashes) {
|
||
if (typeof h !== 'string') continue;
|
||
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' && m.to === tabId && typeof m.hash === 'string') {
|
||
if (peerWins(m.claim, m.from, claimFor(m.hash))) yieldCandidate(m.hash);
|
||
}
|
||
}
|
||
|
||
async function resolveToken() {
|
||
channel = deps.channel !== undefined ? deps.channel : defaultChannel();
|
||
if (channel) {
|
||
if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage);
|
||
else channel.onmessage = onChannelMessage;
|
||
}
|
||
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
|
||
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],
|
||
claims: Object.fromEntries([...resolving.hashes].map((h) => [h, claimFor(h)])) });
|
||
await new Promise((r) => setTimer(r, queryMs));
|
||
const { taken } = resolving;
|
||
resolving = null;
|
||
return candidates.find((t) => !taken.has(tokenHash(t))) || null;
|
||
}
|
||
|
||
return {
|
||
tabId,
|
||
/**
|
||
* Decide which reconnect token this tab uses (once per page load; later calls return the same
|
||
* promise). Call before the first `hello`. Never rejects.
|
||
* @returns {Promise<string|null>}
|
||
*/
|
||
init() {
|
||
if (!initPromise) {
|
||
initPromise = resolveToken().catch(() => null).then((pick) => {
|
||
// A welcome may already have set a token while we were waiting: that one wins.
|
||
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;
|
||
});
|
||
}
|
||
return initPromise;
|
||
},
|
||
/** Record only display metadata; the existing recent-token ring remains the authority. */
|
||
rememberMatch,
|
||
recoverable() {
|
||
const matches = readMatches();
|
||
return readRecent().filter((token) => token !== current).flatMap((token) => {
|
||
const hash = tokenHash(token), v = matches[hash];
|
||
return v && typeof v.name === 'string' && typeof v.code === 'string'
|
||
? [{ id: hash, name: v.name.slice(0, 64), code: v.code.slice(0, 32) }] : [];
|
||
});
|
||
},
|
||
/** Explicit recovery only. Recheck live claims immediately before selecting a saved seat. */
|
||
async resume(id) {
|
||
await this.init();
|
||
if (!channel || resumeBusy) return false;
|
||
const token = readRecent().find((t) => t !== current && tokenHash(t) === id);
|
||
if (!token || !this.recoverable().some((m) => m.id === id)) return false;
|
||
resumeBusy = true;
|
||
try {
|
||
const pick = await queryCandidates([token]);
|
||
if (!pick) return false;
|
||
current = pick;
|
||
return true;
|
||
} catch { return false; }
|
||
finally { resumeBusy = false; }
|
||
},
|
||
/** Select an authenticated account entry token without treating an unacknowledged hello as a saved session. */
|
||
stageToken(token) {
|
||
if (!isToken(token)) return false;
|
||
current = token;
|
||
explicitClaim = true;
|
||
return true;
|
||
},
|
||
/** @returns {string} remembered player name ('' if none) */
|
||
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
|
||
/** @param {string} name */
|
||
saveName: (name) => sset(local, K_NAME, String(name)),
|
||
/** Token for `hello` (null ⇒ new session). Before init() only this tab's own token is used. */
|
||
getToken() {
|
||
if (current) return current;
|
||
if (initialized) return null;
|
||
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() {
|
||
if (explicitClaim) return { noReplace: false };
|
||
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;
|
||
explicitClaim = false;
|
||
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). */
|
||
clearToken() {
|
||
explicitClaim = false;
|
||
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). */
|
||
wasEntered: () => sget(session, K_ENTERED) === '1',
|
||
/** @param {boolean} on */
|
||
setEntered: (on) => (on ? sset(session, K_ENTERED, '1') : sdel(session, K_ENTERED)),
|
||
};
|
||
}
|
||
|
||
/** Browser identity singleton (main.js awaits `identity.init()` before connecting). */
|
||
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(), getTokenClaim: () => identity.getTokenClaim() });
|