@@ -39,6 +39,8 @@ import { JoinLobbyEvent } from "./Main";
3939import {
4040 backendUnreachableConfirmed ,
4141 isPinnedToAVersion ,
42+ manualRetryAvailable ,
43+ retryServerList ,
4244 type BackendReachabilityDetail ,
4345} from "./ServerList" ;
4446import { SinglePlayerModal } from "./SinglePlayerModal" ;
@@ -67,8 +69,43 @@ const TUTORIAL_ACTION =
6769const 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 */
102143export 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 */
125205export 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 */
178272export 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