Skip to content

Commit 4577bb2

Browse files
Celantclaude
andcommitted
Gate on reachability only where the list API is needed
The backend-reachability signal is the health of one thing: the server-list API. It is not an "is the network up" light, and it says nothing about whether any given game server is up. State that rule once, at the top of GameModeSelector.ts, and make every call site follow it. Gated (API-dependent): Create, Ranked and Join-by-code. Each has to resolve a server for something nothing has told the client about, so a dead list API really does mean the click cannot work. Not gated (socket-sourced): every public and hosted lobby card, in the homepage selector and in DetailedGameViewModal alike. The card is in front of the player because a game server sent it over a socket that is still open, which is the only liveness the join needs. Those cards were dimming and refusing on the list API's health while Main's funnel -- by its own docblock -- refused to weigh reachability on the very same join. They now call shouldBlockSocketSourcedAction, the same predicate with the reachability input nailed shut, for both the dimming and the click-through, so the two cannot drift. DetailedGameViewModal no longer tracks the signal at all. Web players also get the escape hatch desktop has. Desktop refuses into a status bar with a Retry button; the web has no bar, so a refused click now IS the retry -- reportMultiplayerRefusal probes before raising the toast, which is what makes "Check your connection and try again" true. Without it the only way out was the heartbeat's next beat, up to RETRY_MAX_MS away. Throttled by ServerList.manualRetryAvailable(): nothing while an attempt is in flight, nothing for MANUAL_RETRY_COOLDOWN_MS after the last player-initiated one. That cooldown moves out of DesktopStatusBar so both shells' affordances share one number and one clock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTKyUrxqfwKvf2QxAovR6Z
1 parent 60633fa commit 4577bb2

10 files changed

Lines changed: 665 additions & 141 deletions

‎docs/MultiServer.md‎

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -452,43 +452,64 @@ values.
452452
`backend-reachability` because that one fires only on a **change**: an
453453
attempt that fails exactly like the last one announces nothing, which is
454454
precisely the case the Retry button has to see.
455-
- **Retry:** `retryServerList()` is the player-initiated attempt behind the
456-
desktop status bar's offline Retry. It ignores the heartbeat's backoff (a
457-
person pressing a button is not a timer, and once an outage has run a
458-
while that wait is up to a minute) but has a 1s floor of its own, inside
459-
which a second press hands back the same promise; past that,
460-
`fetchOnce()` still dedupes against an attempt already in flight. A retry
461-
that fails counts towards the outage confirmation like any other attempt.
462-
463-
The floor is the last line of defence rather than the first. The button
464-
itself is disabled under **either** of two conditions, so it comes back
465-
whenever the later of them ends: while any server-list attempt is in
466-
flight (`attemptInFlight()` / `server-list-attempt`), whoever started it —
467-
during an automatic one it reads `desktop_status.retrying` rather than
468-
sitting greyed out for no visible reason — and for a 5s cooldown after a
469-
press (`RETRY_BUTTON_COOLDOWN_MS` in `DesktopStatusBar`), since a stubbed
470-
or fast failure settles in milliseconds and would otherwise hand the
471-
button straight back to a player clicking at an outage.
472-
473-
What consumes the confirmed signal, and what it does: the desktop status
474-
bar's offline state (ranked below a session failure, above any update
475-
state), and the multiplayer _buttons_ in `GameModeSelector` and
476-
`DetailedGameViewModal`, which dim and refuse a press — on the web as well
477-
as on desktop, where the press also raises a
478-
`common.backend_unreachable` toast, since there is no status bar there to
479-
name the reason.
480-
481-
What it deliberately does **not** do: refuse a join that is already under
482-
way. `Main`'s join funnel (`shouldBlockJoin`) weighs only the desktop
483-
update and session states; reachability is not an input (OPE-439). Every
484-
source that dispatches a join has already reached a server to produce it
485-
— `private` after `checkActiveLobby` read `exists` from the game's own
486-
server, `host` after `createLobby` minted the id, `public` from a lobby
487-
list arriving over a live server socket, `matchmaking` after the queue
488-
matched — so the server-list API's health says nothing about the join in
489-
hand. Refusing there would only ever be wrong, and at worst would eject a
490-
player whose reload had just proved their game is live. Single-player is
491-
never gated, and nothing here touches a game already in progress.
455+
- **Retry:** `retryServerList()` is the player-initiated attempt. It
456+
ignores the heartbeat's backoff (a person pressing a button is not a
457+
timer, and once an outage has run a while that wait is up to a minute) but
458+
has a 1s floor of its own, inside which a second press hands back the same
459+
promise; past that, `fetchOnce()` still dedupes against an attempt already
460+
in flight. A retry that fails counts towards the outage confirmation like
461+
any other attempt.
462+
463+
The floor is the last line of defence rather than the first. Above it sits
464+
one policy, `manualRetryAvailable()`, shared by both shells' affordances
465+
and reading one clock: no retry while any server-list attempt is in flight
466+
(`attemptInFlight()` / `server-list-attempt`), whoever started it, and
467+
none for `MANUAL_RETRY_COOLDOWN_MS` (5s) after the last player-initiated
468+
one — a stubbed or fast failure settles in milliseconds and would
469+
otherwise hand the affordance straight back to a player clicking at an
470+
outage.
471+
472+
The two affordances:
473+
- **Desktop:** the status bar's offline Retry, disabled under either
474+
condition above so it comes back whenever the later of them ends. During
475+
an automatic attempt it reads `desktop_status.retrying` rather than
476+
sitting greyed out for no visible reason.
477+
- **Web:** there is no status bar, so the refused click _is_ the retry.
478+
`reportMultiplayerRefusal` probes when `manualRetryAvailable()` says it
479+
would do something, and raises the `common.backend_unreachable` toast
480+
either way — which is what makes that toast's "try again" true. Without
481+
it a web player's only way out would be the heartbeat's next beat, up to
482+
`RETRY_MAX_MS` away.
483+
484+
- **What reachability may gate, and what it may not.** The rule, stated
485+
once at the top of `GameModeSelector.ts` and referenced from every call
486+
site: the signal is the health of **one** thing, the server-list API. It
487+
is not a general "is the network up" light, and it says nothing about
488+
whether any given _game_ server is up. So it gates exactly the actions
489+
that cannot begin until that API answers, because nothing has yet told the
490+
client which server to talk to.
491+
- _Gated (API-dependent):_ Create/host a lobby, Ranked/matchmaking, and
492+
the join-by-code modal, in `GameModeSelector`. These dim and refuse a
493+
press — `shouldBlockMultiplayerAction` with
494+
`backendUnreachableConfirmed()` — on the web as well as on desktop.
495+
- _Not gated (socket-sourced):_ every public or hosted lobby card, in the
496+
homepage selector and in `DetailedGameViewModal` alike, and every join
497+
that reaches `Main`'s funnel. These call
498+
`shouldBlockSocketSourcedAction`, the same predicate with the
499+
reachability input nailed shut, so a card neither dims nor refuses over
500+
a list-API outage; `DetailedGameViewModal` does not subscribe to the
501+
signal at all.
502+
503+
A card is in front of the player because a game server sent it over a
504+
socket that is still open, which is the only liveness that join needs.
505+
Likewise every join source has already reached a server to produce its
506+
event — `private` after `checkActiveLobby` read `exists` from the game's
507+
own server, `host` after `createLobby` minted the id, `public` from that
508+
live lobby feed, `matchmaking` after the queue matched. Refusing on the
509+
list API's health could only ever reject a join that is already under way,
510+
and at worst would eject a player whose reload had just proved their game
511+
is live. Single-player is never gated either way, and nothing here touches
512+
a game already in progress.
492513

493514
- **Which list:** the desktop shell asks for its injected `serverHost`
494515
(its values are exactly the sites); a web page asks for its `siteHost`

‎src/client/GameModeSelector.ts‎

Lines changed: 147 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import { JoinLobbyEvent } from "./Main";
3939
import {
4040
backendUnreachableConfirmed,
4141
isPinnedToAVersion,
42+
manualRetryAvailable,
43+
retryServerList,
4244
type BackendReachabilityDetail,
4345
} from "./ServerList";
4446
import { SinglePlayerModal } from "./SinglePlayerModal";
@@ -67,8 +69,43 @@ const TUTORIAL_ACTION =
6769
const TUTORIAL_CARD_MAX_GAMES = 5;
6870

6971
/**
72+
* THE REACHABILITY RULE (OPE-439). Stated once, here; every other call site
73+
* in this feature points back at this comment rather than restating it.
74+
*
75+
* The backend-reachability signal is the health of ONE thing: the server-list
76+
* API (`/cluster.json`), as observed by ServerList's heartbeat. It is not a
77+
* general "is the internet up" light, and in particular it says nothing about
78+
* whether any given GAME server is up.
79+
*
80+
* So it may gate exactly one category of action: the ones that cannot even
81+
* begin without that API answering first, because nothing has yet told the
82+
* client which server to talk to.
83+
*
84+
* GATED (API-dependent): creating/hosting a lobby, entering matchmaking,
85+
* opening the join-by-code modal. Each has to resolve a server for
86+
* something the client has heard nothing about, so a dead list API really
87+
* does mean the click cannot work. These dim, and refuse with
88+
* reportMultiplayerRefusal.
89+
*
90+
* NOT GATED (socket-sourced): anything whose target arrived over a live
91+
* game-server socket -- every card in the public lobby feed, in both the
92+
* homepage selector and the detailed browser -- and every join that reaches
93+
* Main's funnel (shouldBlockJoin). The card's very existence is proof that
94+
* the game server behind it is up and talking to us, which is the only
95+
* liveness that join needs. Refusing there could only ever reject a join
96+
* that is already under way, over the health of an unrelated API. These
97+
* neither dim nor refuse on reachability: they call
98+
* shouldBlockSocketSourcedAction, which is the same predicate with the
99+
* reachability input nailed shut.
100+
*
101+
* The other two inputs (desktop update state, desktop session state) apply to
102+
* both categories, which is why the two predicates differ only in this one
103+
* argument.
104+
*
105+
* ---
106+
*
70107
* Whether multiplayer should be available given what we know about the
71-
* backend (OPE-439).
108+
* backend.
72109
*
73110
* The parameter is ServerList.backendUnreachableConfirmed(), NOT the raw
74111
* backendReachable(), and the difference is load-bearing. That accessor is
@@ -97,7 +134,11 @@ export function multiplayerAllowedForBackend(backendOutage: boolean): boolean {
97134
* `backendOutage` is the only one of the three that also applies on the web,
98135
* which is why it is a required parameter rather than an optional one: an
99136
* entry point that forgets to pass it would silently stay ungated, and a
100-
* compile error is the cheapest way to notice.
137+
* compile error is the cheapest way to notice. Pass
138+
* backendUnreachableConfirmed() only from an API-dependent entry point; a
139+
* socket-sourced one calls shouldBlockSocketSourcedAction instead, so that
140+
* "reachability does not apply here" is a named decision rather than a
141+
* `false` literal someone has to interpret.
101142
*/
102143
export function shouldBlockMultiplayerAction(
103144
update: DesktopUpdateState | null,
@@ -111,7 +152,30 @@ export function shouldBlockMultiplayerAction(
111152
}
112153

113154
/**
114-
* Tells the player why a multiplayer action was refused.
155+
* The same gate for an action whose target arrived over a live game-server
156+
* socket: a public or hosted lobby card, in either browser, and every join
157+
* that reaches Main's funnel (shouldBlockJoin below wraps this).
158+
*
159+
* Reachability is not an input, by the rule at the top of this file: the card
160+
* is in front of the player because a game server sent it over a socket that
161+
* is still open, so the server-list API's health cannot make joining it
162+
* wrong. The desktop update and session states still apply -- they are
163+
* statements about this client, not about any server.
164+
*
165+
* A function rather than `shouldBlockMultiplayerAction(u, s, false)` at four
166+
* call sites so the dimming and the click-through of a given control cannot
167+
* drift apart, and so grep finds every place the rule is exercised.
168+
*/
169+
export function shouldBlockSocketSourcedAction(
170+
update: DesktopUpdateState | null,
171+
session: DesktopSessionState | null,
172+
): boolean {
173+
return shouldBlockMultiplayerAction(update, session, false);
174+
}
175+
176+
/**
177+
* Tells the player why a multiplayer action was refused -- and, on the web,
178+
* acts as the retry it tells them to make.
115179
*
116180
* On desktop the status bar is already showing the reason and its remedy, so
117181
* the click lands there as a wiggle rather than as a message that would say
@@ -121,6 +185,22 @@ export function shouldBlockMultiplayerAction(
121185
*
122186
* Only reachability needs the web half: every other reason to refuse here is
123187
* desktop-only, and on desktop the bar always carries it.
188+
*
189+
* The refused click also PROBES on the web, and that is the point rather than
190+
* a nicety. Desktop has a Retry button; the web has nothing, so without this
191+
* the only way out of the gated state is the heartbeat's own next beat --
192+
* which backs off to as much as RETRY_MAX_MS once an outage has run a while.
193+
* A message reading "try again" over a button where trying again provably did
194+
* nothing is worse than no message. So the click the player makes IS the
195+
* retry, and the message is true.
196+
*
197+
* Throttled by ServerList.manualRetryAvailable(), the same policy (and the
198+
* same clock) as the desktop button's disabled state: nothing while an
199+
* attempt is already out, nothing for MANUAL_RETRY_COOLDOWN_MS after the last
200+
* one. A player clicking at an outage gets the message every time and a
201+
* request at most every few seconds. Nothing is rendered from the result: a
202+
* successful probe flips the reachability signal, which is what un-dims the
203+
* buttons -- the feedback is the gate going away.
124204
*/
125205
export function reportMultiplayerRefusal(backendOutage: boolean): void {
126206
// Optional-call the method rather than dispatching an event: the bar is a
@@ -136,6 +216,13 @@ export function reportMultiplayerRefusal(backendOutage: boolean): void {
136216
// index.html on every build and simply renders nothing on the web, so its
137217
// presence proves nothing about whether the player can see a reason.
138218
if (!isDesktopShell() && backendOutage) {
219+
if (manualRetryAvailable()) {
220+
retryServerList().catch((err: unknown) => {
221+
// retryServerList never rejects; belt and braces, so a change there
222+
// cannot surface as an unhandled rejection from a click handler.
223+
console.error("server list retry from a refused click failed", err);
224+
});
225+
}
139226
showToast(translateText("common.backend_unreachable"), "red");
140227
}
141228
}
@@ -163,25 +250,32 @@ export function joinIsGateable(lobby: JoinLobbyEvent): boolean {
163250
* feedback around it. Both halves it does weigh -- the update state and the
164251
* session state -- are desktop-only.
165252
*
166-
* Backend reachability is deliberately NOT an input here (OPE-439). Every
167-
* source that dispatches a join has already reached a server to produce it:
168-
* "private" only after checkActiveLobby read `exists` from the game's own
169-
* server, "host" only after createLobby minted the id, "public" from a lobby
170-
* list arriving over a live server socket, and "matchmaking" only after the
171-
* queue matched and checkGame confirmed the game exists. The outage signal
172-
* tracks the separate server-list API, whose health says nothing about those
173-
* servers, so refusing here could only reject a join that is already under
174-
* way. Worst case it ejects a player mid-game: a reload during a list-API
175-
* blip proves the game is live, then the refusal closes the join modal,
176-
* which leaves the lobby and resets the URL.
253+
* Backend reachability is deliberately NOT an input here -- the rule at the
254+
* top of this file, which is why this defers to
255+
* shouldBlockSocketSourcedAction. Every source that dispatches a join has
256+
* already reached a server to produce it: "private" only after
257+
* checkActiveLobby read `exists` from the game's own server, "host" only
258+
* after createLobby minted the id, "public" from a lobby list arriving over a
259+
* live server socket, and "matchmaking" only after the queue matched and
260+
* checkGame confirmed the game exists. The outage signal tracks the separate
261+
* server-list API, whose health says nothing about those servers, so refusing
262+
* here could only reject a join that is already under way. Worst case it
263+
* ejects a player mid-game: a reload during a list-API blip proves the game
264+
* is live, then the refusal closes the join modal, which leaves the lobby and
265+
* resets the URL.
266+
*
267+
* The controls one step earlier in the funnel -- the lobby cards in this
268+
* component and in DetailedGameViewModal, which are where a "public" join
269+
* comes from -- hold to the same rule for the same reason, so a card is
270+
* neither dimmed nor refused over a list-API outage.
177271
*/
178272
export function shouldBlockJoin(
179273
lobby: JoinLobbyEvent,
180274
update: DesktopUpdateState | null,
181275
session: DesktopSessionState | null,
182276
): boolean {
183277
if (!joinIsGateable(lobby)) return false;
184-
return shouldBlockMultiplayerAction(update, session, false);
278+
return shouldBlockSocketSourcedAction(update, session);
185279
}
186280

187281
@customElement("game-mode-selector")
@@ -526,16 +620,21 @@ export class GameModeSelector extends LitElement {
526620
}
527621

528622
/**
529-
* Refuses the action and tells the player why. Returns true when the caller
530-
* should stop.
623+
* Refuses an API-DEPENDENT action (Create, Ranked, Join by code) and tells
624+
* the player why. Returns true when the caller should stop.
625+
*
626+
* The reachability half applies here -- see the rule at the top of this
627+
* file: none of these three can resolve a server without the list API. A
628+
* lobby card goes through blockedFromLobbyJoin below instead.
531629
*
532630
* Deliberately NOT implemented with the `disabled` attribute the way
533631
* renderSmallActionCard handles invalid input: a disabled control (and
534632
* `pointer-events-none` alongside it) swallows the click, leaving nothing to
535-
* trigger the wiggle. The button stays clickable and merely stops being
536-
* actionable.
633+
* trigger the wiggle -- and, on the web, nothing to trigger the retry that
634+
* reportMultiplayerRefusal makes of it. The button stays clickable and
635+
* merely stops being actionable.
537636
*/
538-
private blockedFromMultiplayer(): boolean {
637+
private blockedFromApiAction(): boolean {
539638
if (
540639
!shouldBlockMultiplayerAction(
541640
this.desktopUpdateState,
@@ -548,8 +647,27 @@ export class GameModeSelector extends LitElement {
548647
return true;
549648
}
550649

650+
/**
651+
* The same, for the public-lobby card: the desktop states still refuse, a
652+
* list-API outage never does. Its lobby came over a live game-server socket
653+
* (the rule at the top of this file), so there is no reachability reason to
654+
* refuse and nothing to retry -- hence `false` to the refusal report, which
655+
* leaves the desktop wiggle as the only feedback.
656+
*/
657+
private blockedFromLobbyJoin(): boolean {
658+
if (
659+
!shouldBlockSocketSourcedAction(
660+
this.desktopUpdateState,
661+
this.desktopSessionState,
662+
)
663+
)
664+
return false;
665+
reportMultiplayerRefusal(false);
666+
return true;
667+
}
668+
551669
private openRankedMenu = () => {
552-
if (this.blockedFromMultiplayer()) return;
670+
if (this.blockedFromApiAction()) return;
553671
if (!this.validateUsername()) return;
554672
window.showPage?.("page-ranked");
555673
};
@@ -573,13 +691,13 @@ export class GameModeSelector extends LitElement {
573691
};
574692

575693
private openHostLobby = () => {
576-
if (this.blockedFromMultiplayer()) return;
694+
if (this.blockedFromApiAction()) return;
577695
if (!this.validateUsername()) return;
578696
(document.querySelector("host-lobby-modal") as HostLobbyModal)?.open();
579697
};
580698

581699
private openJoinLobby = () => {
582-
if (this.blockedFromMultiplayer()) return;
700+
if (this.blockedFromApiAction()) return;
583701
if (!this.validateUsername()) return;
584702
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
585703
};
@@ -710,24 +828,27 @@ export class GameModeSelector extends LitElement {
710828
// with pointer-events-none) swallows the click, and the click is what
711829
// makes the update bar wiggle. `blocked` only dims and reports
712830
// aria-disabled; validateAndJoin does the refusing.
831+
//
832+
// Socket-sourced, so a list-API outage neither dims this nor refuses it:
833+
// the same predicate validateAndJoin uses, for the reason in the rule at
834+
// the top of this file.
713835
return lobbyCard({
714836
lobby,
715837
subtitle: titleContent,
716838
timeDisplay,
717839
timeDisplayUppercase,
718840
disabled: !this.inputValid,
719-
blocked: shouldBlockMultiplayerAction(
841+
blocked: shouldBlockSocketSourcedAction(
720842
this.desktopUpdateState,
721843
this.desktopSessionState,
722-
this.backendOutage,
723844
),
724845
viewerTrusted: this.viewerTrusted,
725846
onClick: () => this.validateAndJoin(lobby),
726847
});
727848
}
728849

729850
private validateAndJoin(lobby: PublicGameInfo) {
730-
if (this.blockedFromMultiplayer()) return;
851+
if (this.blockedFromLobbyJoin()) return;
731852
if (!this.validateUsername()) return;
732853
if (!canJoinTrustedLobby(lobby, this.viewerTrusted)) {
733854
this.showTrustRequired = true;

0 commit comments

Comments
 (0)