Files
noitu/web/src/lib/stores/room-session.svelte.js
T
tiennm99 eace5940da refactor(web): extract the online screen's request machine into a store
Move the join/resume/quick-match/leave state machine out of
routes/online/+page.svelte into stores/room-session.svelte.js, modelled on
bot-session.svelte.js: no DOM, no runes beyond $state, so the resume time-box
is something Vitest can drive directly. The page keeps layout, timers and
wiring; the store keeps what the player asked for.

fix(web): time-box the resume latch and stop leaking a left room's session

A stale resume token used to get silence from the server, leaving `resuming`
stuck true and every button on the join form disabled with no way out but a
reload. `resuming` now clears five seconds after the socket opens if nothing
has answered by then, same as it already does on an explicit error (which
also covers a server new enough to send `session_not_resumable` instead of
staying quiet).

leave() now calls forgetSession(), matching the page-teardown path: without
it, deliberately leaving a room left the token behind, and the next load of
/online tried to resume into the room the player had just walked out of.

fix(web): retry cancelQueue, leave and lobby actions instead of dropping them

send() returns false while the socket is down, and cancelQueue/leave ignored
that return value while ready/start/kick only reported it as a dead-looking
button. All five now hold the request and resend it once the socket reopens,
the way join/create already do, via room-session's held-action slot. Lobby's
"reconnecting" banner is now driven by that held action instead of a local
flag that never noticed a background retry had succeeded.

Also: the lobby's chat panel now reopens once a game ends (phase 'over')
instead of staying folded for the rest of the room's life after the first
game, since phase never actually revisits 'lobby' on its own.
2026-09-21 16:17:23 +07:00

193 lines
6.1 KiB
JavaScript

/**
* Owns the /online screen's request machine: what the player has asked for
* that the socket has not yet carried, from opening a room through leaving
* one. Modelled on bot-session.svelte.js and for the same reason — a request
* is a thing the player did, not a condition inferred from the board, so it
* is stored as one rather than re-derived from `game.state`.
*
* No DOM and no runes beyond `$state`: every timer (the resume time-box, the
* stall timer, the quick-match elapsed clock) is a plain `setTimeout` or
* `setInterval` owned by the page's own `$effect`s, which call back into the
* plain setters here. That is what makes this testable under Vitest with no
* Svelte runtime involved, exactly as `ws/client.js` already is.
*/
/**
* What the player asked for, held until the socket can carry it.
* @typedef {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' }} RoomRequest
*/
/**
* A request made from inside a room that the socket refused to carry.
* Replacing rather than queuing: a later action is a later expression of the
* same intent (readying and then leaving before reconnecting means the
* leave is what should happen), and every one of these is safe to resend —
* `SetReady`, `StartGame`, `KickPlayer`, `LeaveRoom` and `CancelQuickMatch`
* are all refused rather than misapplied if they no longer make sense by the
* time they land.
* @typedef {{ kind: 'cancelQueue' } | { kind: 'leaveRoom' } | { kind: 'setReady', ready: boolean } | { kind: 'startGame' } | { kind: 'kickPlayer', playerId: string }} RoomAction
*/
export function createRoomSession() {
const state = $state({
/** @type {RoomRequest | null} */
pending: null,
// True while the only reason this screen has a socket is to reclaim a
// game it might no longer be able to reclaim.
resuming: false,
// An invite link arrived before this player had a name.
needName: false,
// A held request that has been waiting on a socket for longer than a
// player will believe.
stalled: false,
// The resume timed out (or was refused) rather than succeeding. Shown
// instead of leaving the join form's own "connecting" label the only
// sign anything happened.
resumeFailed: false,
// Whole seconds spent in the quick-match queue, for the waiting
// panel's elapsed time and the bot nudge. Owned here rather than left
// as a raw interval in the page, alongside everything else this
// screen is waiting on.
queuedForS: 0,
/** @type {RoomAction | null} */
heldAction: null
});
return {
state,
/**
* Queues a request to open or join a room. Nothing is sent until the
* socket is open, which the caller still has to drive with `connect()`
* and `flush()` — this only records the intent.
* @param {RoomRequest} req
*/
request(req) {
state.pending = req;
state.resuming = false;
state.needName = false;
state.stalled = false;
state.resumeFailed = false;
},
/** Drops a queued request without sending it. */
clearPending() {
state.pending = null;
},
/**
* Holds a join behind a resume already in progress, without touching
* the other latches `request()` resets — the resume owns those while
* it runs, and is what the held join is waiting on.
* @param {RoomRequest} req
*/
holdPendingJoin(req) {
state.pending = req;
},
/** @param {boolean} value */
setNeedName(value) {
state.needName = value;
},
/**
* Sends the queued request if there is one and the socket can carry
* it. Nothing goes out while a resume is in flight: the seat this tab
* is reclaiming may be in the very room the held request names.
* @param {boolean} isOpen
* @param {{
* create: () => boolean,
* join: (code: string) => boolean,
* quickMatch: () => boolean
* }} senders
* @returns {boolean} whether a request was sent
*/
flush(isOpen, senders) {
if (!state.pending || !isOpen || state.resuming) return false;
const req = state.pending;
const sent =
req.kind === 'create'
? senders.create()
: req.kind === 'quickMatch'
? senders.quickMatch()
: senders.join(req.code);
if (sent) state.pending = null;
return sent;
},
/** Marks a resume attempt as starting, optionally behind a held join. */
startResume() {
state.resuming = true;
},
/**
* The resume worked, so nothing that happens from here is its fault —
* and an invite code held behind it has been answered by arriving in a
* room.
*/
noteRoom() {
state.resuming = false;
state.pending = null;
state.resumeFailed = false;
},
/**
* A resume that the server refused, or that ran out the client's own
* patience for. Neither is something the player did, so the token is
* dropped and an invite code held behind it is either spent (the
* player already has a name) or turned into asking for one.
* @param {boolean} named
*/
noteResumeFailed(named) {
if (!state.resuming) return;
state.resuming = false;
state.resumeFailed = true;
if (state.pending && !named) {
state.needName = true;
state.pending = null;
}
},
/** @param {boolean} value */
setStalled(value) {
state.stalled = value;
},
resetQueued() {
state.queuedForS = 0;
},
tickQueued() {
state.queuedForS += 1;
},
/**
* Holds a request the socket refused, replacing whatever this screen
* was already waiting to retry: a later action is a later statement of
* intent, and every action here is safe to resend regardless of order.
* @param {RoomAction} action
*/
holdAction(action) {
state.heldAction = action;
},
/** Drops a held action without sending it, for a screen being torn down. */
clearAction() {
state.heldAction = null;
},
/**
* Resends a held action once the socket can carry it.
* @param {boolean} isOpen
* @param {(action: RoomAction) => boolean} dispatch
* @returns {boolean} whether the held action was sent
*/
flushAction(isOpen, dispatch) {
if (!state.heldAction || !isOpen) return false;
const sent = dispatch(state.heldAction);
if (sent) state.heldAction = null;
return sent;
}
};
}