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
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:
+52
-17
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user