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:
+3
-1
@@ -40,7 +40,9 @@ The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||
A single Connect `Execute(message_type, payload)` carries every unary op; the request
|
||||
and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`.
|
||||
The session token rides in `Authorization: Bearer`; a domain failure comes back in
|
||||
`result_code`. `Subscribe` is the live event stream. `lib/transport.ts` is the real
|
||||
`result_code`. `Subscribe` is the live event stream; R4 made its game events carry a state **delta**
|
||||
that `lib/gamedelta.ts` applies to the per-game cache (`lib/gamecache.ts`), so a move renders without
|
||||
a follow-up `game.state` (a gap falls back to a refetch). `lib/transport.ts` is the real
|
||||
client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken
|
||||
out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`.
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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' };
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { MoveRecord, StateView } from './model';
|
||||
|
||||
interface CachedGame {
|
||||
export interface CachedGame {
|
||||
view: StateView;
|
||||
moves: MoveRecord[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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' };
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user