fix(game): prevent green-felt freeze — loading spinner + retry resync
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m52s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 57s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-19 20:05:16 +03:30
parent 4fb5a1776f
commit 3875141f46
5 changed files with 37 additions and 0 deletions
+19
View File
@@ -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 (
<main className="persian-pattern relative h-dvh w-full flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
className="size-14 border-4 border-gold-400/30 border-t-gold-400 rounded-full"
/>
<p className="gold-text font-bold text-lg">{t("mm.searching")}</p>
</div>
</main>
);
}
const [askFf, setAskFf] = useState(false);
const sfx = useSoundStore((s) => s.sfx);
+10
View File
@@ -448,6 +448,16 @@ export const useGameStore = create<GameStore>((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) => {
+2
View File
@@ -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!;
+2
View File
@@ -134,6 +134,8 @@ export interface OnlineService {
cancelMatchmaking(): Promise<void>;
/** Stop waiting for online players — start now, filling empty seats with bots. */
playNow(): Promise<void>;
/** 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) ----- */
+4
View File
@@ -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);