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 {
+13 -8
View File
@@ -2,6 +2,9 @@
import * as flatbuffers from 'flatbuffers';
import { GameView } from '../scrabblefb/game-view.js';
export class GameOverEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
@@ -41,8 +44,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
game(obj?:GameView):GameView|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startGameOverEvent(builder:flatbuffers.Builder) {
builder.startObject(3);
builder.startObject(4);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
@@ -57,16 +65,13 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off
builder.addFieldOffset(2, scoreLineOffset, 0);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, gameOffset, 0);
}
static endGameOverEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameOverEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, resultOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset {
GameOverEvent.startGameOverEvent(builder);
GameOverEvent.addGameId(builder, gameIdOffset);
GameOverEvent.addResult(builder, resultOffset);
GameOverEvent.addScoreLine(builder, scoreLineOffset);
return GameOverEvent.endGameOverEvent(builder);
}
}
+13 -6
View File
@@ -2,6 +2,9 @@
import * as flatbuffers from 'flatbuffers';
import { StateView } from '../scrabblefb/state-view.js';
export class MatchFoundEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
@@ -27,22 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
state(obj?:StateView):StateView|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startMatchFoundEvent(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, stateOffset, 0);
}
static endMatchFoundEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createMatchFoundEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
MatchFoundEvent.startMatchFoundEvent(builder);
MatchFoundEvent.addGameId(builder, gameIdOffset);
return MatchFoundEvent.endMatchFoundEvent(builder);
}
}
+41 -1
View File
@@ -34,8 +34,28 @@ game(obj?:GameView):GameView|null {
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
rack(index: number):number|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
}
rackLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
rackArray():Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
}
bagLen():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startMoveResult(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(4);
}
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
@@ -46,6 +66,26 @@ static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, gameOffset, 0);
}
static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, rackOffset, 0);
}
static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
builder.startVector(1, data.length, 1);
for (let i = data.length - 1; i >= 0; i--) {
builder.addInt8(data[i]!);
}
return builder.endVector();
}
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(1, numElems, 1);
}
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
builder.addFieldInt32(3, bagLen, 0);
}
static endMoveResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
@@ -2,6 +2,11 @@
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
import { Invitation } from '../scrabblefb/invitation.js';
import { StateView } from '../scrabblefb/state-view.js';
export class NotificationEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
@@ -27,22 +32,44 @@ kind(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
account(obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
invitation(obj?:Invitation):Invitation|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new Invitation()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
state(obj?:StateView):StateView|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startNotificationEvent(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(4);
}
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, kindOffset, 0);
}
static addAccount(builder:flatbuffers.Builder, accountOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, accountOffset, 0);
}
static addInvitation(builder:flatbuffers.Builder, invitationOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, invitationOffset, 0);
}
static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, stateOffset, 0);
}
static endNotificationEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createNotificationEvent(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset {
NotificationEvent.startNotificationEvent(builder);
NotificationEvent.addKind(builder, kindOffset);
return NotificationEvent.endNotificationEvent(builder);
}
}
@@ -2,6 +2,10 @@
import * as flatbuffers from 'flatbuffers';
import { GameView } from '../scrabblefb/game-view.js';
import { MoveRecord } from '../scrabblefb/move-record.js';
export class OpponentMovedEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
@@ -49,8 +53,23 @@ total():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
move(obj?:MoveRecord):MoveRecord|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
game(obj?:GameView):GameView|null {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
bagLen():number {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startOpponentMovedEvent(builder:flatbuffers.Builder) {
builder.startObject(5);
builder.startObject(8);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
@@ -73,18 +92,21 @@ static addTotal(builder:flatbuffers.Builder, total:number) {
builder.addFieldInt32(4, total, 0);
}
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, moveOffset, 0);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
builder.addFieldOffset(6, gameOffset, 0);
}
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
builder.addFieldInt32(7, bagLen, 0);
}
static endOpponentMovedEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createOpponentMovedEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, seat:number, actionOffset:flatbuffers.Offset, score:number, total:number):flatbuffers.Offset {
OpponentMovedEvent.startOpponentMovedEvent(builder);
OpponentMovedEvent.addGameId(builder, gameIdOffset);
OpponentMovedEvent.addSeat(builder, seat);
OpponentMovedEvent.addAction(builder, actionOffset);
OpponentMovedEvent.addScore(builder, score);
OpponentMovedEvent.addTotal(builder, total);
return OpponentMovedEvent.endOpponentMovedEvent(builder);
}
}
+12 -2
View File
@@ -60,8 +60,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
moveCount():number {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startYourTurnEvent(builder:flatbuffers.Builder) {
builder.startObject(6);
builder.startObject(7);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
@@ -88,12 +93,16 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off
builder.addFieldOffset(5, scoreLineOffset, 0);
}
static addMoveCount(builder:flatbuffers.Builder, moveCount:number) {
builder.addFieldInt32(6, moveCount, 0);
}
static endYourTurnEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset {
static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset, moveCount:number):flatbuffers.Offset {
YourTurnEvent.startYourTurnEvent(builder);
YourTurnEvent.addGameId(builder, gameIdOffset);
YourTurnEvent.addDeadlineUnix(builder, deadlineUnix);
@@ -101,6 +110,7 @@ static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers
YourTurnEvent.addLastAction(builder, lastActionOffset);
YourTurnEvent.addLastWord(builder, lastWordOffset);
YourTurnEvent.addScoreLine(builder, scoreLineOffset);
YourTurnEvent.addMoveCount(builder, moveCount);
return YourTurnEvent.endYourTurnEvent(builder);
}
}
+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> {
+51 -9
View File
@@ -322,12 +322,12 @@ export function decodeProfile(buf: Uint8Array): Profile {
};
}
export function decodeStateView(buf: Uint8Array): StateView {
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
// rack to display letters with it (Stage 13).
function decodeStateViewTable(v: fb.StateView): StateView {
const g = v.game();
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
// Cache the alphabet table when the server included it (a per-variant cache miss), then
// decode the index rack to display letters with it (Stage 13).
if (v.alphabetLength() > 0) {
const entries: AlphabetEntryWire[] = [];
for (let i = 0; i < v.alphabetLength(); i++) {
@@ -347,11 +347,24 @@ export function decodeStateView(buf: Uint8Array): StateView {
};
}
export function decodeStateView(buf: Uint8Array): StateView {
return decodeStateViewTable(fb.StateView.getRootAsStateView(new ByteBuffer(buf)));
}
export function decodeMoveResult(buf: Uint8Array): MoveResult {
const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
const m = r.move();
const g = r.game();
return { move: m ? decodeMove(m) : emptyMove(), game: g ? decodeGameView(g) : emptyGame() };
// The actor's refilled rack rides back as alphabet indices (R4); decode it with the game's variant.
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
const rack: string[] = [];
for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0));
return {
move: m ? decodeMove(m) : emptyMove(),
game: g ? decodeGameView(g) : emptyGame(),
rack,
bagLen: r.bagLen(),
};
}
export function decodeHintResult(buf: Uint8Array): HintResult {
@@ -424,11 +437,30 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
switch (kind) {
case 'your_turn': {
const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb);
return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()) };
return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), moveCount: e.moveCount() };
}
case 'opponent_moved': {
const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb);
return { kind: 'opponent_moved', gameId: s(e.gameId()), seat: e.seat(), action: s(e.action()), score: e.score(), total: e.total() };
const m = e.move();
const g = e.game();
return {
kind: 'opponent_moved',
gameId: s(e.gameId()),
move: m ? decodeMove(m) : undefined,
game: g ? decodeGameView(g) : undefined,
bagLen: e.bagLen(),
};
}
case 'game_over': {
const e = fb.GameOverEvent.getRootAsGameOverEvent(bb);
const g = e.game();
return {
kind: 'game_over',
gameId: s(e.gameId()),
result: s(e.result()),
scoreLine: s(e.scoreLine()),
game: g ? decodeGameView(g) : undefined,
};
}
case 'chat_message':
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
@@ -438,11 +470,21 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
}
case 'match_found': {
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
return { kind: 'match_found', gameId: s(e.gameId()) };
const st = e.state();
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'notify': {
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
return { kind: 'notify', sub: s(e.kind()) };
const acc = e.account();
const inv = e.invitation();
const st = e.state();
return {
kind: 'notify',
sub: s(e.kind()),
account: acc ? decodeAccountRef(acc) : undefined,
invitation: inv ? decodeInvitationTable(inv) : undefined,
state: st ? decodeStateViewTable(st) : undefined,
};
}
case 'heartbeat':
return { kind: 'heartbeat' };
+1 -1
View File
@@ -7,7 +7,7 @@
import type { MoveRecord, StateView } from './model';
interface CachedGame {
export interface CachedGame {
view: StateView;
moves: MoveRecord[];
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, StateView } from './model';
function gameView(moveCount: number, over = false): GameView {
return {
id: 'g1',
variant: 'scrabble_en',
dictVersion: 'v1',
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
turnTimeoutSecs: 300,
moveCount,
endReason: over ? 'standard' : '',
lastActivityUnix: 0,
seats: [],
};
}
function move(player: number): MoveRecord {
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
}
function cache(moveCount: number, seat = 0, over = false): CachedGame {
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
return { view, moves: [] };
}
function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
return { move: move(player), game: gameView(moveCount), bagLen };
}
describe('seedInitialState', () => {
it('wraps an initial view with an empty journal', () => {
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
expect(seedInitialState(view)).toEqual({ view, moves: [] });
});
});
describe('applyMoveDelta', () => {
it('ignores a delta for a game not in the cache', () => {
expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
});
it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => {
expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
});
it('is a no-op for an already-applied move count (idempotent / own echo)', () => {
expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false });
expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false });
});
it('refetches on a gap (an intermediate move was missed)', () => {
expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true });
});
it("refetches the actor's own move (the new rack is not in the delta)", () => {
// seat 0 is this device; the move's player is 0 -> our own move, our rack changed.
expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true });
});
it("applies an opponent's next move, preserving the rack and appending the move", () => {
const before = cache(3, 0);
const res = applyMoveDelta(before, delta(4, 1, 45));
expect(res.refetch).toBe(false);
expect(res.cache?.view.game.moveCount).toBe(4);
expect(res.cache?.view.bagLen).toBe(45);
expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move
expect(res.cache?.view.seat).toBe(0);
expect(res.cache?.moves).toHaveLength(1);
expect(res.cache?.moves[0].player).toBe(1);
expect(before.moves).toHaveLength(0); // input not mutated
});
});
describe('applyGameOver', () => {
it('ignores a finished game not in the cache', () => {
expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false });
});
it('refetches a missing final summary only when the game is not already finished', () => {
expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true });
expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false });
});
it('refetches when the cached board is behind the final move count', () => {
expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true });
});
it('settles the final summary when the board is current', () => {
const res = applyGameOver(cache(10), gameView(10, true));
expect(res.refetch).toBe(false);
expect(res.cache?.view.game.status).toBe('finished');
expect(res.cache?.view.game.moveCount).toBe(10);
});
});
+69
View File
@@ -0,0 +1,69 @@
// Pure reducers that advance the per-game cache from live events (R4), so the UI renders a move
// from the event without a follow-up game.state + game.history fetch. They never touch the network
// or the cache store — the stream handler applies the returned cache and the game screen acts on
// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation.
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, StateView } from './model';
/** The fields an opponent_moved delta carries that advance a cached game. */
export interface MoveDelta {
move?: MoveRecord;
game?: GameView;
bagLen: number;
}
/**
* DeltaResult is the outcome of applying an event: the advanced cache (set only when it changed)
* and whether the caller must fall back to a full game.state + game.history fetch — a gap, a
* missing payload, or the actor's own move on a device that has not drawn its new rack yet.
*/
export interface DeltaResult {
cache?: CachedGame;
refetch: boolean;
}
/**
* seedInitialState builds a fresh cache entry from a started game's initial view (match_found /
* game_started). A freshly started game has no moves, so the board replays from an empty journal.
*/
export function seedInitialState(state: StateView): CachedGame {
return { view: state, moves: [] };
}
/**
* applyMoveDelta advances cached by one move from an opponent_moved delta, keyed on the post-move
* count so it is idempotent (a re-delivered move, or the echo of one's own move, is a no-op) and
* gap-safe (a missed move asks for a refetch). An opponent's move leaves the recipient's rack
* unchanged, so it is preserved; the actor's own move drew a new rack the delta does not carry, so
* a device still behind on its own move refetches to pick it up.
*/
export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): DeltaResult {
// Nothing cached to advance (the game was never opened on this device): ignore it; the next open
// cold-loads the game.
if (!cached) return { refetch: false };
// A pre-R4 peer, or a dropped payload, carried no delta: the open game must refetch.
if (!d.move || !d.game) return { refetch: true };
const have = cached.view.game.moveCount;
const next = d.game.moveCount;
if (next <= have) return { refetch: false }; // already applied (idempotent / own echo)
if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed
// The actor's own move changed their rack (a draw), which opponent_moved does not carry.
if (d.move.player === cached.view.seat) return { refetch: true };
const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen };
return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false };
}
/**
* applyGameOver settles a finished game from a game_over event's final summary (the adjusted
* scores after rack penalties and the winner). It refetches when the cached board is behind the
* final move count — the closing move was missed — so history is repaired, and is a harmless
* re-settle when the closing opponent_moved already finished the game.
*/
export function applyGameOver(cached: CachedGame | undefined, game: GameView | undefined): DeltaResult {
if (!cached) return { refetch: false };
if (!game) return { refetch: cached.view.game.status !== 'finished' };
if (cached.view.game.moveCount < game.moveCount) return { refetch: true };
const view: StateView = { ...cached.view, game };
return { cache: { view, moves: cached.moves }, refetch: false };
}
+4 -4
View File
@@ -235,7 +235,7 @@ export class MockGateway implements GatewayClient {
g.view.toMove = (seat + 1) % g.view.players;
this.drafts.delete(gameId);
this.scheduleOpponentReply(gameId);
return { move: structuredClone(move), game: structuredClone(g.view) };
return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen };
}
private async simpleAction(
@@ -276,7 +276,7 @@ export class MockGateway implements GatewayClient {
g.view.toMove = (seat + 1) % g.view.players;
this.scheduleOpponentReply(gameId);
}
return { move: structuredClone(move), game: structuredClone(g.view) };
return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen };
}
pass(gameId: string): Promise<MoveResult> {
@@ -546,8 +546,8 @@ export class MockGateway implements GatewayClient {
g.view.seats[opp].score = move.total;
g.view.moveCount += 1;
g.view.toMove = this.mySeat(g);
this.emit({ kind: 'opponent_moved', gameId, seat: opp, action: 'play', score: 6, total: move.total });
this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 });
this.emit({ kind: 'opponent_moved', gameId, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen });
this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, moveCount: g.view.moveCount });
}, 1600);
}
+15 -5
View File
@@ -70,6 +70,9 @@ export interface StateView {
export interface MoveResult {
move: MoveRecord;
game: GameView;
/** The actor's refilled rack after the move (R4), so the mover renders the next state without a refetch. */
rack: string[];
bagLen: number;
}
export interface HintResult {
@@ -223,12 +226,19 @@ export interface GameList {
games: GameView[];
}
/** A live event delivered over the Subscribe stream. */
/**
* A live event delivered over the Subscribe stream. The game events carry the move as a
* delta — move plus the post-move summary (and the bag size) — the client applies to its
* cached game without a refetch; match_found / game_started carry the recipient's initial
* StateView; notify carries the changed lobby payload (R4). The enriched fields are optional
* so a client falls back to a refetch when a payload is absent (a gap, or a pre-R4 peer).
*/
export type PushEvent =
| { kind: 'your_turn'; gameId: string; deadlineUnix: number }
| { kind: 'opponent_moved'; gameId: string; seat: number; action: string; score: number; total: number }
| { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number }
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
| { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string }
| { kind: 'match_found'; gameId: string }
| { kind: 'notify'; sub: string }
| { kind: 'match_found'; gameId: string; state?: StateView }
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
| { kind: 'heartbeat' };
+43 -15
View File
@@ -27,6 +27,9 @@
// --- auto-match ---
let searching = $state(false);
// matched guards the teardown: once a match arrives (immediately, via the match_found push, or
// via the fallback poll) onDestroy must not dequeue the game we just got.
let matched = $state(false);
let poll: ReturnType<typeof setInterval> | null = null;
function stop() {
@@ -35,6 +38,24 @@
poll = null;
}
}
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
// up the match_found push drives navigation (R4). It polls lobby.poll every 2.5s.
function startPoll() {
if (poll) return;
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
matched = true;
searching = false;
stop();
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
}
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
// reaper later substitutes a robot for a game the player abandoned (Stage 17 fix).
@@ -47,31 +68,37 @@
async function find(v: Variant) {
searching = true;
matched = false;
try {
const r = await gateway.lobbyEnqueue(v);
if (r.matched && r.game) {
matched = true;
searching = false;
navigate(`/game/${r.game.id}`);
return;
}
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
stop();
searching = false;
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
} catch (e) {
searching = false;
handleError(e);
}
// No immediate match: wait for the match_found push; the effect below polls only when the
// stream is down.
}
// Poll for the match only while searching and the stream is down (the push cannot reach us);
// stop once the stream is back or the search ends.
$effect(() => {
if (searching && !app.streamAlive) startPoll();
else stop();
});
// match_found is handled globally (app.svelte navigates); end the search here too so onDestroy
// does not cancel the match we just received.
$effect(() => {
if (app.lastEvent?.kind === 'match_found' && searching) {
matched = true;
searching = false;
}
});
// --- friend game ---
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
@@ -120,8 +147,9 @@
onDestroy(() => {
stop();
// Abandoned mid-search (navigated away without Cancel): dequeue so we don't linger.
if (searching) void gateway.lobbyCancel().catch(() => {});
// Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't
// linger. A received match (matched) must not be cancelled.
if (searching && !matched) void gateway.lobbyCancel().catch(() => {});
});
</script>