From 3875141f46c5ff3182114a3da27c8ef0e9ae1fd3 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Fri, 19 Jun 2026 20:05:16 +0330 Subject: [PATCH] =?UTF-8?q?fix(game):=20prevent=20green-felt=20freeze=20?= =?UTF-8?q?=E2=80=94=20loading=20spinner=20+=20retry=20resync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes: 1. GameTable shows a spinner instead of an empty table when mode=online and seatPlayers is empty (waiting for first state broadcast). 2. enterServerMatch schedules a 3s interval that calls service.resync() until seatPlayers is populated, guaranteeing the state always lands. 3. resync() added to OnlineService interface + both implementations so the game store can call it without casting. Co-Authored-By: Claude Sonnet 4.6 --- src/components/GameTable.tsx | 19 +++++++++++++++++++ src/lib/game-store.ts | 10 ++++++++++ src/lib/online/mock-service.ts | 2 ++ src/lib/online/service.ts | 2 ++ src/lib/online/signalr-service.ts | 4 ++++ 5 files changed, 37 insertions(+) diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index b80b451..6974203 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -55,7 +55,26 @@ export function GameTable({ const game = useGameStore((s) => s.game); const reset = useGameStore((s) => s.reset); const mode = useGameStore((s) => s.mode); + const seatPlayers = useGameStore((s) => s.seatPlayers); const { t } = useI18n(); + + // While waiting for the first server state broadcast, show a spinner instead + // of an empty green felt — prevents the "3 colored dots / stuck table" bug. + const connecting = mode === "online" && seatPlayers.length === 0; + if (connecting) { + return ( +
+
+ +

{t("mm.searching")}

+
+
+ ); + } const [askFf, setAskFf] = useState(false); const sfx = useSoundStore((s) => s.sfx); diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 5daee73..5c2828d 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -448,6 +448,16 @@ export const useGameStore = create((set, get) => { reconnectDeadline: null, seatPlayers: [], }); + // Green-felt guard: if the initial state hasn't arrived after 3 s, ask the + // server to resend it. Repeat every 3 s until it lands (stops automatically + // once seatPlayers is populated, meaning applyServerState ran). + const resyncInterval = setInterval(() => { + if (get().seatPlayers.length > 0 || !get().live) { + clearInterval(resyncInterval); + return; + } + service.resync(); + }, 3000); }, applyServerState: (s) => { diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 26497dd..4274630 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -984,6 +984,8 @@ export class MockOnlineService implements OnlineService { this.matchmaking.phase = "idle"; } + resync() { /* mock: game runs client-side, no server resync needed */ } + async playNow() { if (this.matchmaking.phase !== "searching" && this.matchmaking.phase !== "queued") return; const me = this.profile!; diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index e3886d4..298c4d5 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -134,6 +134,8 @@ export interface OnlineService { cancelMatchmaking(): Promise; /** Stop waiting for online players — start now, filling empty seats with bots. */ playNow(): Promise; + /** Ask the server to re-broadcast the current game state (green-felt guard). */ + resync(): void; onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe; /* ----- match players (for the online game driver) ----- */ diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index 1478a58..678350d 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -338,6 +338,10 @@ export class SignalrService implements OnlineService { await this.conn?.invoke("PlayNow"); } + resync(): void { + void this.conn?.invoke("Resync").catch(() => {}); + } + onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe { this.mmCbs.add(cb); return () => this.mmCbs.delete(cb);