Files
scrabble-game/ui/src/lib/gamedelta.test.ts
T
Ilia Denisov f9faebfa91
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
fix(hint): stop the hint count going stale across games
The in-game hint badge re-fetched on entry and, for a game where it was the
player's turn, showed a too-high count that "reset" (e.g. back to 11) — the
wallet hint spent in another game was not reflected.

Root cause: the server sends one hints_remaining = per-game allowance + global
wallet, and the client cached that combined number per game. The wallet is
global, so spending a wallet hint in one game left every other game's cached
count stale (a my-turn game holds the stalest value: the opponent-moved delta
preserves the old number, whereas a game you just moved in re-cached a fresh
one). The backend allowance-then-wallet spend order was already correct.

Fix: split the two. StateView/HintResult gain a trailing wallet_balance field
(the global wallet alone); the client derives the per-game allowance as
hints_remaining - wallet_balance (stable, cacheable) and reads the wallet live
from the profile, refreshing it from every state/hint response. The badge is
allowance + live wallet, so a wallet hint anywhere updates every game at once.

- wire: scrabble.fbs StateView/HintResult + pkg/wire.BuildStateView (the single
  encoder for both the gateway transcode and the backend's event StateView),
  gateway encode + resp structs, regen.
- backend: game StateView/HintResult + service (GameState/Hint) + eventwire +
  notify PlayerState/encode + server DTOs.
- ui: lib/hints.ts (pure hintsLeft), Game.svelte (badge + syncWallet on
  load/hint, carry wallet_balance through applyMoveResult), codec/model, mock.
- docs: ARCHITECTURE §Hint.

Tests: hints.ts unit (incl. the staleness case), TestHintPolicy extended
(wallet_balance + allowance-first), gateway state/hint round-trips.
2026-06-17 16:50:16 +02:00

173 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
function gameView(moveCount: number, over = false): GameView {
return {
id: 'g1',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
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, walletBalance: 0 };
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, walletBalance: 0 };
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 (older 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);
});
});
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, opponentName: 'Ann', moveCount: 4 };
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, walletBalance: 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, walletBalance: 0 }, 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();
});
});