feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Builds on the cross-screen cache work: the global stream handler now keeps both caches current no matter which screen is mounted, and invitations become a live delta channel so the lobby's invitations list is fresh from any screen too. Client (boundary already started): - advanceCached now also folds opponent_joined into a not-currently-viewed game's cache via a new pure reducer applyOpponentJoined (extracted and reused by the mounted game board), so opening an open game that filled while you were elsewhere is flash-free. - patchLobbyInvitation upserts a still-pending invitation and removes a terminal one (started/declined/cancelled/expired); the global notify handler calls it on the invitation / invitation_update sub-kinds. Invitations delta channel (no wire/gateway/connector change — the notification already carries the full invitation with id/status/game_id end to end): - notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate constructor (shares encoding with NotificationInvitation). The Telegram connector renders no message for it, so a decline/cancel never becomes an out-of-app push. - lobby: emit the changed invitation to every participant on respond (accept/decline), on the final accept's game start, and on cancel — so each participant's lobby patches its list in place. The authoritative list holds only pending invitations, so the client's pending-vs-terminal rule matches it exactly. Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the NotificationInvitationUpdate encoding unit test, and integration assertions that decline/cancel/accept publish invitation_update to every participant. Full local suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs: ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
|
||||
import { patchLobbyGame } from '../lib/lobbycache';
|
||||
import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta';
|
||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
||||
import {
|
||||
BLANK,
|
||||
@@ -232,9 +232,10 @@
|
||||
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
|
||||
// The opponent took the empty seat: adopt the new participants and status in place,
|
||||
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
|
||||
if (view) {
|
||||
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } };
|
||||
setCachedGame(id, view, moves);
|
||||
const r = applyOpponentJoined(cacheSnapshot(), e.state);
|
||||
if (r) {
|
||||
view = r.view;
|
||||
setCachedGame(id, r.view, r.moves);
|
||||
}
|
||||
} 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.
|
||||
|
||||
@@ -27,7 +27,7 @@ import { connection, reportOffline, reportOnline, resetConnection } from './conn
|
||||
import { isConnectionCode } from './retry';
|
||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||
import { advanceCached } from './gamedelta';
|
||||
import { clearLobby, patchLobbyGame } from './lobbycache';
|
||||
import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache';
|
||||
import type { BoardLabelMode } from './boardlabels';
|
||||
|
||||
export interface Toast {
|
||||
@@ -191,9 +191,16 @@ function openStream(): void {
|
||||
if (c) setCachedGame(e.gameId, c.view, c.moves);
|
||||
}
|
||||
} else if (e.kind === 'opponent_joined') {
|
||||
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot
|
||||
// from any screen. A mounted game board adopts the change in place itself (game/Game.svelte).
|
||||
if (e.state) patchLobbyGame(e.state.game);
|
||||
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot,
|
||||
// and warm a not-currently-viewed game's own cache, so opening it from any screen is flash-free.
|
||||
// The mounted game board owns its cache (game/Game.svelte), so skip the game in view.
|
||||
if (e.state) {
|
||||
patchLobbyGame(e.state.game);
|
||||
if (!viewingGame(e.gameId)) {
|
||||
const c = advanceCached(getCachedGame(e.gameId), e);
|
||||
if (c) setCachedGame(e.gameId, c.view, c.moves);
|
||||
}
|
||||
}
|
||||
} else if (e.kind === 'match_found') {
|
||||
// Seed both caches from the event's initial state so the game renders instantly on arrival
|
||||
// and the new game is already in the lobby on a later return, then navigate.
|
||||
@@ -209,6 +216,12 @@ function openStream(): void {
|
||||
setCachedGame(e.state.game.id, e.state, []);
|
||||
patchLobbyGame(e.state.game);
|
||||
}
|
||||
// An invitation created / updated / withdrawn elsewhere: keep the lobby's invitations list
|
||||
// fresh from any screen — a new pending one is added, a consumed (started), declined,
|
||||
// cancelled or expired one is removed (see patchLobbyInvitation).
|
||||
if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
|
||||
patchLobbyInvitation(e.invitation);
|
||||
}
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { advanceCached, applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
|
||||
import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta';
|
||||
import type { CachedGame } from './gamecache';
|
||||
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
|
||||
|
||||
@@ -141,3 +141,30 @@ describe('advanceCached', () => {
|
||||
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOpponentJoined', () => {
|
||||
// joinedState is the refreshed StateView an opponent_joined event carries: the open game is now
|
||||
// active and both seats are filled.
|
||||
function joinedState(): StateView {
|
||||
const game: GameView = { ...gameView(0), status: 'active', players: 2, seats: [
|
||||
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
|
||||
] };
|
||||
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
|
||||
}
|
||||
|
||||
it("adopts the joined seats and status while preserving the cached rack and moves", () => {
|
||||
// The cached open game is still "searching": empty seats, status open, the starter's own rack.
|
||||
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
|
||||
const res = applyOpponentJoined(cached, joinedState());
|
||||
expect(res?.view.game.status).toBe('active');
|
||||
expect(res?.view.game.seats).toHaveLength(2);
|
||||
expect(res?.view.rack).toEqual(['a', 'b']); // the starter's rack is untouched by the join
|
||||
expect(res?.view.game.moveCount).toBe(2); // the cached count/board is preserved
|
||||
expect(res?.moves).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is cached for the game', () => {
|
||||
expect(applyOpponentJoined(undefined, joinedState())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,5 +82,19 @@ export function advanceCached(cached: CachedGame | undefined, e: PushEvent): Cac
|
||||
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache;
|
||||
}
|
||||
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
|
||||
if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* applyOpponentJoined adopts an opponent_joined event into a cached open game: it overlays the
|
||||
* refreshed seats, status and player count from the event's StateView onto the cached game, leaving
|
||||
* the board, the starter's rack and everything else intact (the join changes who is seated, not the
|
||||
* position). It returns undefined when the game is not cached (the next open cold-loads it). It backs
|
||||
* both the mounted game board's in-place update and the global handler's off-screen cache warming.
|
||||
*/
|
||||
export function applyOpponentJoined(cached: CachedGame | undefined, state: StateView): CachedGame | undefined {
|
||||
if (!cached) return undefined;
|
||||
const game: GameView = { ...cached.view.game, seats: state.game.seats, status: state.game.status, players: state.game.players };
|
||||
return { view: { ...cached.view, game }, moves: cached.moves };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache';
|
||||
import type { AccountRef, GameView } from './model';
|
||||
import { clearLobby, getLobby, patchLobbyGame, patchLobbyInvitation, setLobby } from './lobbycache';
|
||||
import type { AccountRef, GameView, Invitation } from './model';
|
||||
|
||||
function invitation(id: string, status: string): Invitation {
|
||||
return {
|
||||
id,
|
||||
inviter: { accountId: 'inv', displayName: 'Inv' },
|
||||
invitees: [],
|
||||
variant: 'scrabble_en',
|
||||
turnTimeoutSecs: 300,
|
||||
hintsAllowed: true,
|
||||
hintsPerPlayer: 0,
|
||||
multipleWordsPerTurn: true,
|
||||
dropoutTiles: 'remove',
|
||||
status,
|
||||
gameId: '',
|
||||
expiresAtUnix: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView {
|
||||
return {
|
||||
@@ -59,3 +76,46 @@ describe('patchLobbyGame', () => {
|
||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchLobbyInvitation', () => {
|
||||
it('adds a new pending invitation to the cached lobby', () => {
|
||||
setLobby({ games: [], invitations: [], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||
});
|
||||
|
||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||
patchLobbyInvitation(updated);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
});
|
||||
|
||||
it('removes an invitation that reached a terminal status', () => {
|
||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', terminal));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op for a terminal invitation that is not in the list', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
});
|
||||
|
||||
it('is a no-op when there is no cached lobby yet', () => {
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby()).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves games and incoming when patching an invitation', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,31 @@ export function patchLobbyGame(g: GameView): void {
|
||||
snapshot = { ...snapshot, games };
|
||||
}
|
||||
|
||||
/**
|
||||
* patchLobbyInvitation applies a live invitation change to the cached lobby's invitations list, so a
|
||||
* change seen while the lobby is unmounted is reflected on its next render instead of a stale card
|
||||
* lingering until the background refresh. A still-pending invitation is upserted (a new one
|
||||
* prepended newest-first, an updated one replaced in place); an invitation that reached any terminal
|
||||
* status (started, declined, cancelled, expired) is removed — the authoritative list holds only
|
||||
* pending invitations. It is a no-op when there is no snapshot yet, or a terminal invitation is not
|
||||
* in the list. The lobby re-renders from the snapshot, so the in-place upsert needs no re-sort.
|
||||
*/
|
||||
export function patchLobbyInvitation(inv: Invitation): void {
|
||||
if (!snapshot) return;
|
||||
const i = snapshot.invitations.findIndex((x) => x.id === inv.id);
|
||||
let invitations: Invitation[];
|
||||
if (inv.status !== 'pending') {
|
||||
if (i === -1) return; // a terminal invitation already absent — nothing to remove
|
||||
invitations = snapshot.invitations.filter((x) => x.id !== inv.id);
|
||||
} else if (i === -1) {
|
||||
invitations = [inv, ...snapshot.invitations];
|
||||
} else {
|
||||
invitations = snapshot.invitations.slice();
|
||||
invitations[i] = inv;
|
||||
}
|
||||
snapshot = { ...snapshot, invitations };
|
||||
}
|
||||
|
||||
/** clearLobby drops the cached lobby (called on logout). */
|
||||
export function clearLobby(): void {
|
||||
snapshot = null;
|
||||
|
||||
Reference in New Issue
Block a user