fix(online): green-felt freeze — replay initial state to late subscriber
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:
@@ -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); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user