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
+52 -17
View File
@@ -13,13 +13,14 @@
import { connection } from '../lib/connection.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { replay } from '../lib/board';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
@@ -154,17 +155,42 @@
void loadFriends();
});
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
function cacheSnapshot(): CachedGame | undefined {
return view ? { view, moves } : undefined;
}
// applyDelta adopts a reducer result: an advanced cache renders the move with no fetch; a
// flagged refetch falls back to a full load() (a gap, our own move's new rack, or a missing
// payload — see lib/gamedelta).
function applyDelta(res: DeltaResult): void {
if (res.cache) {
view = res.cache.view;
moves = res.cache.moves;
setCachedGame(id, view, moves);
recompute();
} else if (res.refetch) {
void load();
}
}
$effect(() => {
const e = app.lastEvent;
if (!e) return;
if (e.kind === 'opponent_moved' && e.gameId === id) {
// Skip the echo of my own move (the backend now notifies the actor too, for the
// player's other devices): this device already reloaded after the submit.
if (e.seat !== view?.seat) void load();
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
// A request the player sent was answered (accepted -> now friends; declined -> stays
// "request sent"): re-derive the in-game friend state.
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
// the move as a delta with no fetch (R4).
if (placement.pending.length > 0) void load();
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
} else if (e.kind === 'your_turn' && e.gameId === id) {
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
// only if we missed the move (our cached count trails the event's).
if (view && e.moveCount > view.game.moveCount) void load();
} else if (e.kind === 'game_over' && e.gameId === id) {
applyDelta(applyGameOver(cacheSnapshot(), e.game));
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends();
}
});
function isCoarse(): boolean {
@@ -446,15 +472,27 @@
}, 250);
}
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
// post-move game and the refilled rack — without a follow-up game.state + game.history (R4).
function applyMoveResult(r: MoveResult) {
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
moves = [...moves, r.move];
setCachedGame(id, view, moves);
rackIds = r.rack.map((_, i) => i);
placement = newPlacement(r.rack);
selected = null;
dirOverride = undefined;
recompute();
}
async function commit() {
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant));
telegramHaptic('success');
zoomed = false;
await load();
} catch (e) {
handleError(e);
} finally {
@@ -472,8 +510,7 @@
async function doPass() {
busy = true;
try {
await gateway.pass(id);
await load();
applyMoveResult(await gateway.pass(id));
} catch (e) {
handleError(e);
} finally {
@@ -484,8 +521,7 @@
resignOpen = false;
busy = true;
try {
await gateway.resign(id);
await load();
applyMoveResult(await gateway.resign(id));
} catch (e) {
handleError(e);
} finally {
@@ -553,8 +589,7 @@
exchangeOpen = false;
busy = true;
try {
await gateway.exchange(id, tiles, variant);
await load();
applyMoveResult(await gateway.exchange(id, tiles, variant));
} catch (e) {
handleError(e);
} finally {