feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s

The statistics screen gains real depth, plus a hint-count bug fix found along the way.

- Best move per variant: the screen shows the actual best-move word (drawn as game
  tiles; a wildcard shows its letter but no value), broken down by game variant, empty
  variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
  plays that used a hint — from summed account_stats counters (moves, hints_used).
  Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
  wallet now rides the wire apart from the per-game allowance (wallet_balance on
  StateView/HintResult/StatsView), so the client reads the live wallet rather than a
  per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
  its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
  moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
  changes.

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
This commit was merged in pull request #81.
This commit is contained in:
2026-06-17 22:17:27 +00:00
parent 5a3f0951ae
commit 8793bd34f2
71 changed files with 1789 additions and 132 deletions
+53
View File
@@ -284,6 +284,8 @@ describe('codec', () => {
fb.StatsView.addDraws(b, 1);
fb.StatsView.addMaxGamePoints(b, 420);
fb.StatsView.addMaxWordPoints(b, 90);
fb.StatsView.addMoves(b, 248);
fb.StatsView.addHintsUsed(b, 12);
b.finish(fb.StatsView.endStatsView(b));
expect(decodeStats(b.asUint8Array())).toEqual({
wins: 7,
@@ -291,6 +293,57 @@ describe('codec', () => {
draws: 1,
maxGamePoints: 420,
maxWordPoints: 90,
moves: 248,
hintsUsed: 12,
bestMoves: [],
});
});
it('decodes a StatsView carrying a per-variant best move with a blank tile', () => {
const b = new Builder(256);
// Word "ca" where the 'a' is a blank: it carries its letter but scores 0.
const cLetter = b.createString('c');
fb.BestMoveTile.startBestMoveTile(b);
fb.BestMoveTile.addLetter(b, cLetter);
fb.BestMoveTile.addValue(b, 3);
fb.BestMoveTile.addBlank(b, false);
const tileC = fb.BestMoveTile.endBestMoveTile(b);
const aLetter = b.createString('a');
fb.BestMoveTile.startBestMoveTile(b);
fb.BestMoveTile.addLetter(b, aLetter);
fb.BestMoveTile.addValue(b, 0);
fb.BestMoveTile.addBlank(b, true);
const tileA = fb.BestMoveTile.endBestMoveTile(b);
const word = fb.BestMoveView.createWordVector(b, [tileC, tileA]);
const variant = b.createString('scrabble_en');
fb.BestMoveView.startBestMoveView(b);
fb.BestMoveView.addVariant(b, variant);
fb.BestMoveView.addScore(b, 90);
fb.BestMoveView.addWord(b, word);
const bm = fb.BestMoveView.endBestMoveView(b);
const bestMoves = fb.StatsView.createBestMovesVector(b, [bm]);
fb.StatsView.startStatsView(b);
fb.StatsView.addWins(b, 7);
fb.StatsView.addBestMoves(b, bestMoves);
b.finish(fb.StatsView.endStatsView(b));
expect(decodeStats(b.asUint8Array())).toEqual({
wins: 7,
losses: 0,
draws: 0,
maxGamePoints: 0,
maxWordPoints: 0,
moves: 0,
hintsUsed: 0,
bestMoves: [
{
variant: 'scrabble_en',
score: 90,
word: [
{ letter: 'c', value: 3, blank: false },
{ letter: 'a', value: 0, blank: true },
],
},
],
});
});
+18 -1
View File
@@ -11,6 +11,8 @@ import type {
AccountRef,
Banner,
BannerCampaign,
BestMove,
BestMoveTile,
BlockStatus,
ChatMessage,
EvalResult,
@@ -381,6 +383,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
rack,
bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(),
walletBalance: v.walletBalance(),
};
}
@@ -407,7 +410,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult {
export function decodeHintResult(buf: Uint8Array): HintResult {
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
const m = r.move();
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining() };
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining(), walletBalance: r.walletBalance() };
}
export function decodeEvalResult(buf: Uint8Array): EvalResult {
@@ -742,12 +745,26 @@ export function decodeRedeemResult(buf: Uint8Array): AccountRef {
export function decodeStats(buf: Uint8Array): Stats {
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
const bestMoves: BestMove[] = [];
for (let i = 0; i < v.bestMovesLength(); i++) {
const m = v.bestMoves(i);
if (!m) continue;
const word: BestMoveTile[] = [];
for (let j = 0; j < m.wordLength(); j++) {
const t = m.word(j);
if (t) word.push({ letter: s(t.letter()), value: t.value(), blank: t.blank() });
}
bestMoves.push({ variant: s(m.variant()) as Variant, score: m.score(), word });
}
return {
wins: v.wins(),
losses: v.losses(),
draws: v.draws(),
maxGamePoints: v.maxGamePoints(),
maxWordPoints: v.maxWordPoints(),
moves: v.moves(),
hintsUsed: v.hintsUsed(),
bestMoves,
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ function gameView(id: string): GameView {
}
function view(id: string, rack: string[] = ['A', 'B']): StateView {
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 };
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
}
function move(player: number): MoveRecord {
+4 -4
View File
@@ -27,7 +27,7 @@ function move(player: number): MoveRecord {
}
function cache(moveCount: number, seat = 0, over = false): CachedGame {
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
return { view, moves: [] };
}
@@ -37,7 +37,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
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 };
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 };
expect(seedInitialState(view)).toEqual({ view, moves: [] });
});
});
@@ -152,12 +152,12 @@ describe('applyOpponentJoined', () => {
{ 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 };
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 }, moves: [move(0)] };
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);
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { hintsLeft } from './hints';
// view carries only the two fields hintsLeft reads.
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
describe('hintsLeft', () => {
it('is zero without a view', () => {
expect(hintsLeft(null, 5)).toBe(0);
});
it('adds the per-game allowance to the live wallet (fresh view)', () => {
// hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3.
expect(hintsLeft(view(4, 3), 3)).toBe(4);
});
it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => {
// The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent
// in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4.
expect(hintsLeft(view(4, 3), 2)).toBe(3);
});
it('shows just the wallet when the per-game allowance is used up', () => {
// allowance 0 (hints_remaining 3 == snapshot wallet 3); live wallet 3 → 0 + 3.
expect(hintsLeft(view(3, 3), 3)).toBe(3);
});
it('clamps a non-negative allowance and wallet', () => {
expect(hintsLeft(view(2, 3), 0)).toBe(0);
expect(hintsLeft(view(1, 0), -5)).toBe(1);
});
});
+26
View File
@@ -0,0 +1,26 @@
// Hint-count derivation, kept out of the .svelte component so it is unit-testable.
//
// The badge shows the per-game hint allowance remaining plus the player's global hint
// wallet. The server's hints_remaining folds the two together, but the wallet is global —
// shared across every game — so caching the combined number per game makes it go stale the
// moment a wallet hint is spent in another game. We therefore split it: the per-game
// allowance is hints_remaining - wallet_balance (both from the same fetch, so it is stable
// and cacheable), and the wallet is read live from the global profile, never the per-game
// snapshot.
import type { StateView } from './model';
/**
* hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's
* hints_remaining minus the wallet snapshot baked into that same view) plus the live global
* wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count
* correct when a wallet hint was spent in another game since this view was fetched.
*/
export function hintsLeft(
view: Pick<StateView, 'hintsRemaining' | 'walletBalance'> | null,
walletBalance: number,
): number {
if (!view) return 0;
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
return allowance + Math.max(0, walletBalance);
}
+4 -3
View File
@@ -34,7 +34,7 @@ export const en = {
'lobby.noActive': 'No active games yet.',
'lobby.noFinished': 'No finished games yet.',
'lobby.limitReached': "You've reached the simultaneous games limit.",
'lobby.new': 'New',
'lobby.new': 'Play',
'lobby.stats': 'Stats',
'lobby.profile': 'Profile',
'lobby.settings': 'Settings',
@@ -83,7 +83,6 @@ export const en = {
'game.passNoExchange': 'Pass without exchanging',
'game.confirmResign': 'Resign this game?',
'game.hintShown': 'Best move: {word} for {n}',
'game.over': 'Game over',
'game.won': 'You won',
'game.lost': 'You lost',
'game.tied': 'Draw',
@@ -118,7 +117,7 @@ export const en = {
'chat.nudge': 'Waiting for your move 🤭',
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
'chat.nudgeAction': 'Nudge',
'chat.awaitingReply': "Waiting for the opponent's reply",
'chat.awaitingReply': "Let's be patient",
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
'chat.sentThisTurn': 'You can write again next turn.',
@@ -275,6 +274,8 @@ export const en = {
'stats.losses': 'Losses',
'stats.draws': 'Draws',
'stats.played': 'Games',
'stats.moves': 'Moves',
'stats.hintShare': 'Hint share',
'stats.winRate': 'Win rate',
'stats.maxGame': 'Best game',
'stats.maxWord': 'Best move',
+7 -6
View File
@@ -35,8 +35,8 @@ export const ru: Record<MessageKey, string> = {
'lobby.noActive': 'Пока нет активных игр.',
'lobby.noFinished': 'Пока нет завершённых игр.',
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
'lobby.new': 'Новая',
'lobby.stats': 'Статы',
'lobby.new': 'Играть',
'lobby.stats': 'Цифры',
'lobby.profile': 'Профиль',
'lobby.settings': 'Настройки',
'lobby.about': 'О программе',
@@ -56,7 +56,7 @@ export const ru: Record<MessageKey, string> = {
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
@@ -84,7 +84,6 @@ export const ru: Record<MessageKey, string> = {
'game.passNoExchange': 'Пас без обмена',
'game.confirmResign': 'Сдаться в этой игре?',
'game.hintShown': 'Лучший ход: {word} на {n}',
'game.over': 'Игра окончена',
'game.won': 'Вы выиграли',
'game.lost': 'Вы проиграли',
'game.tied': 'Ничья',
@@ -119,7 +118,7 @@ export const ru: Record<MessageKey, string> = {
'chat.nudge': 'Жду Вашего хода 🤭',
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
'chat.nudgeAction': 'Поторопить',
'chat.awaitingReply': 'Ждём реакцию соперника',
'chat.awaitingReply': 'Немного терпения',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
'chat.sentThisTurn': 'Можно написать снова в следующем ходу.',
@@ -275,7 +274,9 @@ export const ru: Record<MessageKey, string> = {
'stats.wins': 'Победы',
'stats.losses': 'Поражения',
'stats.draws': 'Ничьи',
'stats.played': 'Игр',
'stats.played': 'Игры',
'stats.moves': 'Ходы',
'stats.hintShare': 'Доля подсказок',
'stats.winRate': 'Доля побед',
'stats.maxGame': 'Лучшая игра',
'stats.maxWord': 'Лучший ход',
+11 -4
View File
@@ -295,7 +295,10 @@ export class MockGateway implements GatewayClient {
seat: this.mySeat(g),
rack: [...g.rack],
bagLen: g.bagLen,
hintsRemaining: g.hintsRemaining,
// g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance.
// hints_remaining folds the two together (as the backend does), walletBalance is the wallet.
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
walletBalance: this.profile.hintBalance,
};
}
@@ -395,8 +398,11 @@ export class MockGateway implements GatewayClient {
async hint(gameId: string): Promise<HintResult> {
const g = this.game(gameId);
if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable');
g.hintsRemaining -= 1;
if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable');
// Spend the per-game allowance first, then the shared wallet — mirroring the backend, so a
// wallet hint in one game lowers the count shown in every other game (the bug this fixes).
if (g.hintsRemaining > 0) g.hintsRemaining -= 1;
else this.profile.hintBalance -= 1;
const letter = g.rack.find((l) => l !== '?') ?? 'A';
return {
move: {
@@ -411,7 +417,8 @@ export class MockGateway implements GatewayClient {
score: valueForLetter(g.view.variant, letter),
total: 0,
},
hintsRemaining: g.hintsRemaining,
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
walletBalance: this.profile.hintBalance,
};
}
+41 -1
View File
@@ -50,7 +50,47 @@ export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Ka
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
export const MOCK_STATS: Stats = { wins: 7, losses: 4, draws: 1, maxGamePoints: 421, maxWordPoints: 95 };
export const MOCK_STATS: Stats = {
wins: 7,
losses: 4,
draws: 1,
maxGamePoints: 421,
maxWordPoints: 134,
moves: 248, // plays across all games
hintsUsed: 12, // -> hint share 12/248 = 4.8%
// Letters are lower-cased as the backend emits them; the tile renderer upper-cases for
// display. The 'd' in "wonderful" is a blank (value 0) to exercise wildcard rendering.
// Erudit is absent on purpose, so the screen demonstrates skipping a not-yet-played variant.
bestMoves: [
{
variant: 'scrabble_en',
score: 134,
word: [
{ letter: 'w', value: 4, blank: false },
{ letter: 'o', value: 1, blank: false },
{ letter: 'n', value: 1, blank: false },
{ letter: 'd', value: 0, blank: true },
{ letter: 'e', value: 1, blank: false },
{ letter: 'r', value: 1, blank: false },
{ letter: 'f', value: 4, blank: false },
{ letter: 'u', value: 1, blank: false },
{ letter: 'l', value: 1, blank: false },
],
},
{
variant: 'scrabble_ru',
score: 88,
word: [
{ letter: 'с', value: 1, blank: false },
{ letter: 'ъ', value: 10, blank: false },
{ letter: 'ё', value: 4, blank: false },
{ letter: 'м', value: 2, blank: false },
{ letter: 'к', value: 2, blank: false },
{ letter: 'а', value: 1, blank: false },
],
},
],
};
export function mockInvitations(): Invitation[] {
return [
+31 -2
View File
@@ -67,13 +67,17 @@ export interface MoveRecord {
total: number;
}
/** A seated player's private view of a game. */
/** A seated player's private view of a game. hintsRemaining folds the per-game allowance
* together with the global wallet; walletBalance is the wallet alone, so the client can
* derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live
* across games (see lib/hints). */
export interface StateView {
game: GameView;
seat: number;
rack: string[];
bagLen: number;
hintsRemaining: number;
walletBalance: number;
}
export interface MoveResult {
@@ -87,6 +91,7 @@ export interface MoveResult {
export interface HintResult {
move: MoveRecord;
hintsRemaining: number;
walletBalance: number;
}
export interface EvalResult {
@@ -206,13 +211,37 @@ export interface FriendCode {
expiresAtUnix: number;
}
/** A durable account's lifetime statistics. */
/** One letter cell of a best-move word: its display letter, its tile value (0 for a
* blank) and whether it is a blank — enough to render it as a game tile without the
* variant's alphabet table. */
export interface BestMoveTile {
letter: string;
value: number;
blank: boolean;
}
/** An account's highest-scoring single play within one variant: the variant, the play's
* total score and its main word as ordered tiles. */
export interface BestMove {
variant: Variant;
score: number;
word: BestMoveTile[];
}
/** A durable account's lifetime statistics. bestMoves breaks the best move down per
* variant (with the word itself); it is empty for an account with no recorded play and
* lists only variants the account has played. */
export interface Stats {
wins: number;
losses: number;
draws: number;
maxGamePoints: number;
maxWordPoints: number;
/** Lifetime count of the player's plays (tile placements). */
moves: number;
/** Lifetime count of hints the player took (allowance + wallet). */
hintsUsed: number;
bestMoves: BestMove[];
}
/** Settings the inviter chooses for a friend game. */
+1 -1
View File
@@ -34,7 +34,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
}
function stateView(id: string): StateView {
return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 };
return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
}
beforeEach(() => {
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { gamesPlayed, winRate } from './stats';
import { gamesPlayed, hintSharePercent, winRate } from './stats';
import type { Stats } from './model';
const s = (wins: number, losses: number, draws: number): Stats => ({
@@ -8,8 +8,14 @@ const s = (wins: number, losses: number, draws: number): Stats => ({
draws,
maxGamePoints: 0,
maxWordPoints: 0,
moves: 0,
hintsUsed: 0,
bestMoves: [],
});
// withCounts overrides moves/hintsUsed on the zero fixture for the hint-share cases.
const withCounts = (moves: number, hintsUsed: number): Stats => ({ ...s(0, 0, 0), moves, hintsUsed });
describe('stats', () => {
it('sums games played', () => {
expect(gamesPlayed(s(7, 4, 1))).toBe(12);
@@ -23,4 +29,14 @@ describe('stats', () => {
it('win rate is 0 with no games', () => {
expect(winRate(s(0, 0, 0))).toBe(0);
});
it('computes the hint share (hints / plays)', () => {
expect(hintSharePercent(withCounts(200, 10))).toBe(5); // 10/200 = 5%
expect(hintSharePercent(withCounts(248, 12))).toBeCloseTo(4.8387, 3);
});
it('hint share is 0 with no plays (no division by zero)', () => {
expect(hintSharePercent(withCounts(0, 0))).toBe(0);
expect(hintSharePercent(withCounts(0, 5))).toBe(0);
});
});
+7
View File
@@ -12,3 +12,10 @@ export function winRate(s: Stats): number {
const n = gamesPlayed(s);
return n > 0 ? Math.round((s.wins / n) * 100) : 0;
}
/** hintSharePercent is the share of the player's plays that drew on a hint
* (hints used / plays × 100), unrounded; 0 when no plays. The screen formats it to one
* decimal in the active locale. */
export function hintSharePercent(s: Stats): number {
return s.moves > 0 ? (s.hintsUsed / s.moves) * 100 : 0;
}