fix(online): green-felt freeze — replay initial state to late subscriber
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m55s
CI/CD / CI - Web (tsc + next build) (push) Has been cancelled
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled

Root cause: the server sends matchFound then immediately broadcasts the first
state, but the client only subscribes to state inside enterServerMatch, which
runs a React effect later — so the ordered "state" message is dispatched while
there are no subscribers and is dropped. The server then waits for the human
hakem's trump choice that can never come → permanent freeze on the green felt.

- signalr-service: cache lastState; replay it to a late onState subscriber on a
  microtask (after enterServerMatch resets its store); clear the cache on every
  fresh-match entry (startMatchmaking / createRoom / acceptInvite) so a finished
  game's final state is never replayed into a new match.
- safety net: if no state lands within 2.5s of matchFound, the client invokes
  the new Resync hub method; server re-sends the current state to that player.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 00:33:07 +03:30
parent f97354167d
commit 23b3713b44
4 changed files with 47 additions and 3 deletions
@@ -81,6 +81,13 @@ public sealed partial class GameManager
} }
} }
/// <summary>Client safety net: re-send the current game state to a player who
/// may have missed the initial broadcast (green-felt freeze guard).</summary>
public void Resync(string userId)
{
if (RoomOf(userId) is { } room) room.ResendStateTo(userId);
}
/// <summary>Player asked to start right now — match any humans waiting and /// <summary>Player asked to start right now — match any humans waiting and
/// fill the rest with bots, instead of waiting out the queue.</summary> /// fill the rest with bots, instead of waiting out the queue.</summary>
public void PlayNow(string userId) public void PlayNow(string userId)
+13
View File
@@ -387,6 +387,19 @@ public sealed class GameRoom : IDisposable
_ = _hub.Clients.User(slot.UserId!).SendAsync("state", ToDto(slot.Seat)); _ = _hub.Clients.User(slot.UserId!).SendAsync("state", ToDto(slot.Seat));
} }
/// <summary>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.</summary>
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) private void Broadcast(string method, object payload)
{ {
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null && s.Connected)) foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null && s.Connected))
+1
View File
@@ -38,6 +38,7 @@ public sealed class GameHub : Hub
public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid); public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid);
public void PlayNow() => _manager.PlayNow(Uid); public void PlayNow() => _manager.PlayNow(Uid);
public void Resync() => _manager.Resync(Uid);
public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId); public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId);
public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit); public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit);
public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction); public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction);
+26 -3
View File
@@ -70,6 +70,11 @@ export class SignalrService implements OnlineService {
private mmStake = 0; private mmStake = 0;
private mmCbs = new Set<(s: MatchmakingState) => void>(); private mmCbs = new Set<(s: MatchmakingState) => void>();
private stateCbs = new Set<(s: ServerGameState) => 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 reactionCbs = new Set<(seat: number, reaction: string) => void>();
private notifCbs = new Set<(n: AppNotification) => void>(); private notifCbs = new Set<(n: AppNotification) => void>();
private profileCbs = new Set<(p: UserProfile) => 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 }) => conn.on("matchmaking", (s: { phase: string; players: number; queuePosition: number | null }) =>
this.emitMM(s.phase, s.queuePosition ?? undefined)); this.emitMM(s.phase, s.queuePosition ?? undefined));
conn.on("matchFound", () => this.emitMM("ready")); conn.on("matchFound", () => {
conn.on("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s))); 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 }) => conn.on("reaction", (r: { seat: number; reaction: string }) =>
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction))); this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
conn.on("typing", (m: { from: string }) => conn.on("typing", (m: { from: string }) =>
@@ -297,6 +313,7 @@ export class SignalrService implements OnlineService {
async startMatchmaking(opts: MatchmakingOptions) { async startMatchmaking(opts: MatchmakingOptions) {
this.mmRanked = opts.ranked; this.mmRanked = opts.ranked;
this.mmStake = opts.stake; this.mmStake = opts.stake;
this.lastState = null; // fresh match — don't replay a prior game's final state
await this.connect(); await this.connect();
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
this.emitMM("searching"); this.emitMM("searching");
@@ -337,6 +354,11 @@ export class SignalrService implements OnlineService {
onState(cb: (s: ServerGameState) => void): Unsubscribe { onState(cb: (s: ServerGameState) => void): Unsubscribe {
this.stateCbs.add(cb); 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); return () => this.stateCbs.delete(cb);
} }
@@ -471,6 +493,7 @@ export class SignalrService implements OnlineService {
}); });
} }
async createRoom(o: CreateRoomOptions) { async createRoom(o: CreateRoomOptions) {
this.lastState = null; // fresh match — don't replay a prior game's final state
await this.connect(); await this.connect();
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
await this.conn?.invoke("CreatePrivateRoom", await this.conn?.invoke("CreatePrivateRoom",
@@ -500,7 +523,7 @@ export class SignalrService implements OnlineService {
} }
async leaveRoom(_roomId: string) { await this.conn?.invoke("LeavePrivate"); } async leaveRoom(_roomId: string) { await this.conn?.invoke("LeavePrivate"); }
onRoom(cb: (r: Room) => void) { this.roomCbs.add(cb); return () => this.roomCbs.delete(cb); } 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"); } async declineInvite() { await this.conn?.invoke("DeclinePrivate"); }
onRoomInvite(cb: (i: RoomInvite | null) => void) { this.roomInviteCbs.add(cb); return () => this.roomInviteCbs.delete(cb); } onRoomInvite(cb: (i: RoomInvite | null) => void) { this.roomInviteCbs.add(cb); return () => this.roomInviteCbs.delete(cb); }