R4: push enrichment — events carry a state delta, kill the last poll
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback.

- pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS.
- backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size.
- gateway: MoveResult transcode carries rack+bag_len.
- ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false.
- docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
This commit is contained in:
Ilia Denisov
2026-06-10 08:01:50 +02:00
parent e3b08461f0
commit 41a642ef97
47 changed files with 1514 additions and 180 deletions
+15 -7
View File
@@ -25,7 +25,7 @@ import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { isConnectionCode } from './retry';
import { clearGameCache } from './gamecache';
import { clearGameCache, setCachedGame } from './gamecache';
import { clearLobby } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
@@ -36,6 +36,8 @@ export interface Toast {
export const app = $state<{
ready: boolean;
/** Whether the live-event stream is connected; drives the matchmaking poll fallback (R4). */
streamAlive: boolean;
session: Session | null;
profile: Profile | null;
toast: Toast | null;
@@ -53,6 +55,7 @@ export const app = $state<{
chatUnread: Record<string, number>;
}>({
ready: false,
streamAlive: false,
session: null,
profile: null,
toast: null,
@@ -68,7 +71,6 @@ export const app = $state<{
});
let unsubscribeStream: (() => void) | null = null;
let streamAlive = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
@@ -100,7 +102,7 @@ function goForeground(): void {
backgrounded = false;
foregroundedAt = Date.now();
if (!app.session) return;
if (!streamAlive) openStream(); // silently re-establish a stream dropped while away
if (!app.streamAlive) openStream(); // silently re-establish a stream dropped while away
void refreshNotifications();
}
@@ -131,7 +133,7 @@ export function handleError(err: unknown): void {
function openStream(): void {
closeStream();
streamAlive = true;
app.streamAlive = true;
unsubscribeStream = gateway.subscribe(
(e) => {
reportOnline(); // a delivered event proves the gateway is reachable
@@ -151,13 +153,19 @@ function openStream(): void {
} else if (e.kind === 'your_turn') {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'match_found') {
// Seed the cache from the event's initial state so the game renders instantly on arrival,
// then navigate (R4).
if (e.state) setCachedGame(e.state.game.id, e.state, []);
navigate(`/game/${e.gameId}`);
} else if (e.kind === 'notify') {
// A started invited game seeds its cache so opening it is instant; the lobby badge stays
// on the authoritative refresh (R4).
if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []);
void refreshNotifications();
}
},
() => {
streamAlive = false;
app.streamAlive = false;
// A background suspend drops the single-shot stream. Keep the indicator hidden while
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
// reachability watcher and this scheduled retry recover it). Always schedule a retry.
@@ -173,7 +181,7 @@ function scheduleReconnect(): void {
if (reconnectTimer || !app.session) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream();
if (app.session && !app.streamAlive && !backgrounded && !documentHidden()) openStream();
}, 4000);
}
@@ -205,7 +213,7 @@ function closeStream(): void {
}
unsubscribeStream?.();
unsubscribeStream = null;
streamAlive = false;
app.streamAlive = false;
}
async function adoptSession(s: Session): Promise<void> {