Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
7 changed files with 196 additions and 12 deletions
Showing only changes of commit 5312b11f0e - Show all commits
+9 -1
View File
@@ -48,7 +48,15 @@ Login uses `Screen`.
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
on lobby ↔ game navigation.
on lobby ↔ game navigation. The caches are kept fresh **across** screens, so the instant render
is already current and not a stale frame the background refresh then corrects. The single global
stream handler (`lib/app.svelte.ts`) is the one place that runs for every live event regardless of
the mounted screen, so it owns cross-screen freshness: it upserts the affected game's `GameView`
into the lobby snapshot (`patchLobbyGame`) on every game event — opponent move, game-over,
opponent-joined, and a match/started game (which it also adds) — and advances a not-currently-viewed
game's own cache from the move/over delta (`advanceCached`, skipping the game in view, whose mounted
board owns its cache). The game board additionally mirrors the player's **own** move and its own
`load()` into the lobby snapshot — the two updates no live event carries.
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
+5
View File
@@ -20,6 +20,7 @@
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
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 { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
@@ -138,6 +139,9 @@
view = st;
moves = hist.moves;
setCachedGame(id, st, hist.moves);
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
// stale flash before the lobby's own background refresh lands.
patchLobbyGame(st.game);
selected = null;
await applyDraft(st);
recompute();
@@ -559,6 +563,7 @@
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);
patchLobbyGame(r.game);
rackIds = r.rack.map((_, i) => i);
placement = newPlacement(r.rack);
selected = null;
+40 -8
View File
@@ -25,8 +25,9 @@ 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, setCachedGame } from './gamecache';
import { clearLobby } from './lobbycache';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
import { clearLobby, patchLobbyGame } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -144,6 +145,16 @@ export function handleError(err: unknown): void {
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
}
/**
* viewingGame reports whether the game board for the given game id is the current route. That screen
* maintains its own cache from live events, so the global stream handler must not also advance it (a
* double application). The chat / dictionary sub-screens leave the board unmounted, so they do not
* count as viewing — there the global handler keeps the cache warm for the return to the board.
*/
function viewingGame(gameId: string): boolean {
return router.route.name === 'game' && router.route.params.id === gameId;
}
function openStream(): void {
closeStream();
app.streamAlive = true;
@@ -168,15 +179,36 @@ function openStream(): void {
showToast(t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'opponent_moved' || e.kind === 'game_over') {
// Keep both caches fresh from any screen, so neither the lobby nor the game flashes a stale
// frame on the next navigation. The lobby snapshot is patched from the event's own GameView
// (independent of whether this device has the game cached). The per-game cache is advanced
// only for a game not currently in view — the mounted game board owns its own cache (see
// game/Game.svelte), so skipping it avoids a double apply.
if (e.game) patchLobbyGame(e.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 === '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);
} else if (e.kind === 'match_found') {
// Seed the cache from the event's initial state so the game renders instantly on arrival,
// then navigate.
if (e.state) setCachedGame(e.state.game.id, e.state, []);
// 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.
if (e.state) {
setCachedGame(e.state.game.id, e.state, []);
patchLobbyGame(e.state.game);
}
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.
if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []);
// A started invited game seeds its cache so opening it is instant and lands in the lobby
// snapshot from any screen; the lobby badge stays on the authoritative refresh.
if (e.sub === 'game_started' && e.state) {
setCachedGame(e.state.game.id, e.state, []);
patchLobbyGame(e.state.game);
}
void refreshNotifications();
}
},
+45 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
import { advanceCached, applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, StateView } from './model';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
function gameView(moveCount: number, over = false): GameView {
return {
@@ -98,3 +98,46 @@ describe('applyGameOver', () => {
expect(res.cache?.view.game.moveCount).toBe(10);
});
});
function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen };
}
function gameOverEvent(moveCount: number): PushEvent {
return { kind: 'game_over', gameId: 'g1', result: 'win', scoreLine: '10:0', game: gameView(moveCount, true) };
}
describe('advanceCached', () => {
it("advances the cache from an opponent's move for a game not on screen", () => {
const res = advanceCached(cache(3, 0), movedEvent(4, 1, 42));
expect(res?.view.game.moveCount).toBe(4);
expect(res?.view.bagLen).toBe(42);
expect(res?.moves).toHaveLength(1);
});
it('returns undefined on a gap, so the cache is left for the next open to cold-load', () => {
expect(advanceCached(cache(3, 0), movedEvent(6, 1))).toBeUndefined();
});
it('returns undefined for an already-applied move (idempotent re-delivery / own echo)', () => {
expect(advanceCached(cache(4, 0), movedEvent(4, 1))).toBeUndefined();
});
it('settles a game_over event when the cached board is current', () => {
const res = advanceCached(cache(10, 0), gameOverEvent(10));
expect(res?.view.game.status).toBe('finished');
});
it('returns undefined for game_over when the cached board is behind the final count', () => {
expect(advanceCached(cache(9, 0), gameOverEvent(10))).toBeUndefined();
});
it('returns undefined when nothing is cached for the game', () => {
expect(advanceCached(undefined, movedEvent(1, 1))).toBeUndefined();
});
it('ignores event kinds that do not advance a game board', () => {
const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, moveCount: 4 };
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
});
});
+18 -1
View File
@@ -4,7 +4,7 @@
// `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';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
/** The fields an opponent_moved delta carries that advance a cached game. */
export interface MoveDelta {
@@ -67,3 +67,20 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u
const view: StateView = { ...cached.view, game };
return { cache: { view, moves: cached.moves }, refetch: false };
}
/**
* advanceCached applies a live game event to a cached game off-screen, for the global stream handler
* to keep a game the player is not currently viewing warm: opening it from the lobby then renders the
* opponent's move (or the final result) with no stale-board flash. It returns the advanced cache, or
* undefined when nothing changes — no cache, an idempotent re-delivery, an unrelated event kind, or a
* gap / missing payload that the next open cold-loads (off-screen there is no game screen to honour a
* refetch, so a fall-back simply leaves the cache for load() to repair). The mounted game screen owns
* its own cache, so the caller skips the game currently in view to avoid applying a delta twice.
*/
export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined {
if (e.kind === 'opponent_moved') {
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache;
}
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
return undefined;
}
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache';
import type { AccountRef, GameView } from './model';
function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView {
return {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
status,
players: 2,
toMove,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats: [],
};
}
beforeEach(() => clearLobby());
describe('patchLobbyGame', () => {
it('replaces the matching game by id and leaves the others untouched', () => {
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
// The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby();
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
});
it('preserves invitations and incoming when patching a game', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('a')], invitations: [], incoming });
patchLobbyGame(gameView('a', 'finished'));
expect(getLobby()?.incoming).toEqual(incoming);
expect(getLobby()?.games[0].status).toBe('finished');
});
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyGame(gameView('a'));
expect(getLobby()).toBeNull();
});
it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [] });
patchLobbyGame(gameView('a', 'active', 1));
expect(games[0].toMove).toBe(0); // the original array/object is left intact
});
});
+18
View File
@@ -24,6 +24,24 @@ export function setLobby(s: LobbySnapshot): void {
snapshot = s;
}
/**
* patchLobbyGame upserts the given game into the cached lobby — replacing the matching entry, or
* prepending it when absent — so a game-state change seen while the lobby is unmounted (the player's
* own move, or any live game event the global stream handler applies from another screen) is already
* reflected the next time the lobby renders from the snapshot, instead of showing the stale status
* until the background refresh lands. Prepending a freshly started game likewise lets it appear at
* once. It is a no-op only when there is no snapshot yet (the lobby cold-loads on its first mount).
* The lobby re-groups and re-sorts on render, so the insert position carries no meaning.
*/
export function patchLobbyGame(g: GameView): void {
if (!snapshot) return;
const i = snapshot.games.findIndex((x) => x.id === g.id);
const games = snapshot.games.slice();
if (i === -1) games.unshift(g);
else games[i] = g;
snapshot = { ...snapshot, games };
}
/** clearLobby drops the cached lobby (called on logout). */
export function clearLobby(): void {
snapshot = null;