diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index 164e84c..6cc9d51 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -81,6 +81,13 @@ public sealed partial class GameManager } } + /// Client safety net: re-send the current game state to a player who + /// may have missed the initial broadcast (green-felt freeze guard). + public void Resync(string userId) + { + if (RoomOf(userId) is { } room) room.ResendStateTo(userId); + } + /// Player asked to start right now — match any humans waiting and /// fill the rest with bots, instead of waiting out the queue. public void PlayNow(string userId) diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs index 943327b..668894e 100644 --- a/server/src/Hokm.Server/Game/GameRoom.cs +++ b/server/src/Hokm.Server/Game/GameRoom.cs @@ -387,6 +387,19 @@ public sealed class GameRoom : IDisposable _ = _hub.Clients.User(slot.UserId!).SendAsync("state", ToDto(slot.Seat)); } + /// Re-send the current state to one player on demand — the client's + /// safety net when the initial broadcast was dropped/raced and the table + /// would otherwise freeze waiting for a state that never arrived. + public void ResendStateTo(string userId) + { + lock (_lock) + { + var slot = Seats.FirstOrDefault(s => !s.IsBot && s.UserId == userId); + if (slot != null) + _ = _hub.Clients.User(userId).SendAsync("state", ToDto(slot.Seat)); + } + } + private void Broadcast(string method, object payload) { foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null && s.Connected)) diff --git a/server/src/Hokm.Server/Hubs/GameHub.cs b/server/src/Hokm.Server/Hubs/GameHub.cs index 1ca81ae..4849978 100644 --- a/server/src/Hokm.Server/Hubs/GameHub.cs +++ b/server/src/Hokm.Server/Hubs/GameHub.cs @@ -38,6 +38,7 @@ public sealed class GameHub : Hub public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid); public void PlayNow() => _manager.PlayNow(Uid); + public void Resync() => _manager.Resync(Uid); public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId); public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit); public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction); diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index 7e29451..2198227 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -70,6 +70,11 @@ export class SignalrService implements OnlineService { private mmStake = 0; private mmCbs = new Set<(s: MatchmakingState) => void>(); private stateCbs = new Set<(s: ServerGameState) => void>(); + // Last server game state, cached so a late subscriber gets the current state. + // enterServerMatch subscribes via a React effect that runs AFTER the ordered + // "matchFound"→"state" messages have already been dispatched, so without this + // the initial broadcast is dropped and the table freezes on the green felt. + private lastState: ServerGameState | null = null; private reactionCbs = new Set<(seat: number, reaction: string) => void>(); private notifCbs = new Set<(n: AppNotification) => void>(); private profileCbs = new Set<(p: UserProfile) => void>(); @@ -156,8 +161,19 @@ export class SignalrService implements OnlineService { conn.on("matchmaking", (s: { phase: string; players: number; queuePosition: number | null }) => this.emitMM(s.phase, s.queuePosition ?? undefined)); - conn.on("matchFound", () => this.emitMM("ready")); - conn.on("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s))); + conn.on("matchFound", () => { + this.emitMM("ready"); + // Safety net: if the initial state never lands (dropped/raced), ask the + // server to resend it so the table can't freeze on the green felt. + const before = this.lastState; + setTimeout(() => { + if (this.lastState === before) this.conn?.invoke("Resync").catch(() => {}); + }, 2500); + }); + conn.on("state", (s: ServerGameState) => { + this.lastState = s; + this.stateCbs.forEach((cb) => cb(s)); + }); conn.on("reaction", (r: { seat: number; reaction: string }) => this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction))); conn.on("typing", (m: { from: string }) => @@ -297,6 +313,7 @@ export class SignalrService implements OnlineService { async startMatchmaking(opts: MatchmakingOptions) { this.mmRanked = opts.ranked; this.mmStake = opts.stake; + this.lastState = null; // fresh match — don't replay a prior game's final state await this.connect(); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); this.emitMM("searching"); @@ -337,6 +354,11 @@ export class SignalrService implements OnlineService { onState(cb: (s: ServerGameState) => void): Unsubscribe { this.stateCbs.add(cb); + // Replay the current state to this late subscriber on a microtask — after the + // caller (enterServerMatch) finishes resetting its store — so a match entered + // just after the initial broadcast renders instead of freezing on empty felt. + const snapshot = this.lastState; + if (snapshot) queueMicrotask(() => { if (this.stateCbs.has(cb)) cb(snapshot); }); return () => this.stateCbs.delete(cb); } @@ -471,6 +493,7 @@ export class SignalrService implements OnlineService { }); } async createRoom(o: CreateRoomOptions) { + this.lastState = null; // fresh match — don't replay a prior game's final state await this.connect(); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); await this.conn?.invoke("CreatePrivateRoom", @@ -500,7 +523,7 @@ export class SignalrService implements OnlineService { } async leaveRoom(_roomId: string) { await this.conn?.invoke("LeavePrivate"); } onRoom(cb: (r: Room) => void) { this.roomCbs.add(cb); return () => this.roomCbs.delete(cb); } - async acceptInvite() { await this.connect(); await this.conn?.invoke("AcceptPrivate"); } + async acceptInvite() { this.lastState = null; await this.connect(); await this.conn?.invoke("AcceptPrivate"); } async declineInvite() { await this.conn?.invoke("DeclinePrivate"); } onRoomInvite(cb: (i: RoomInvite | null) => void) { this.roomInviteCbs.add(cb); return () => this.roomInviteCbs.delete(cb); }