feat(stats): show the best move word per game variant
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 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s

Replace the single "best move" number on the statistics screen with a
full-width per-variant breakdown: the highest-scoring play in each variant
the player has played, drawn as game tiles (a wildcard shows its letter but
no value), with the words and scores right-aligned to shared edges.

- backend: new account_best_move table (PK account_id+variant) keeping the
  main word as JSON tiles {letter,value,blank}; captured at game finish in
  buildStats (blank flags taken from every placed blank — equivalent to the
  final board), upserted in the finish transaction and replaced only by a
  strictly higher-scoring play. Guest/honest-AI games still record nothing.
  GetStats + statsDTO expose best_moves.
- wire: StatsView gains best_moves:[BestMoveView{variant,score,word:[BestMoveTile]}]
  (trailing, backward-compatible); gateway encodeStats + UI codec updated.
- ui: new WordTiles component (board's tile look, fixed px size); Stats.svelte
  drops the maxWord card and adds the full-width best-move card (catalogue
  order, empty variants omitted).
- docs: ARCHITECTURE §9 + schema, FUNCTIONAL (+ru), UI_DESIGN.

Tests: mainWordTiles unit + buildStats end-to-end (inttest) + gateway and UI
codec round-trips (incl. a blank tile) + e2e.
This commit is contained in:
Ilia Denisov
2026-06-17 15:29:55 +02:00
parent 5a3f0951ae
commit cbb485ebd6
33 changed files with 1217 additions and 44 deletions
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
// shows its letter but no value, exactly as on the board. Letters are upper-cased for
// display. The tile values ride on each tile, so this renders without the variant's
// alphabet table (which the statistics screen has not cached).
import type { BestMoveTile } from '../lib/model';
let { word }: { word: BestMoveTile[] } = $props();
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
</script>
<span class="word" aria-label={label}>
{#each word as tile, i (i)}
<span class="tile" class:blank={tile.blank} aria-hidden="true">
<span class="letter">{tile.letter.toUpperCase()}</span>
{#if !tile.blank}<span class="val">{tile.value}</span>{/if}
</span>
{/each}
</span>
<style>
.word {
display: inline-flex;
gap: 2px;
}
.tile {
position: relative;
flex: none;
width: 22px;
height: 22px;
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 3px;
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
.letter {
position: absolute;
top: 6%;
left: 11%;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.val {
position: absolute;
right: 8%;
bottom: 3%;
font-size: 7px;
font-weight: 600;
}
</style>
+2
View File
@@ -5,6 +5,8 @@ export { Ack } from './scrabblefb/ack.js';
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
export { BannerCampaign } from './scrabblefb/banner-campaign.js';
export { BannerInfo } from './scrabblefb/banner-info.js';
export { BestMoveTile } from './scrabblefb/best-move-tile.js';
export { BestMoveView } from './scrabblefb/best-move-view.js';
export { BlockList } from './scrabblefb/block-list.js';
export { BlockStatus } from './scrabblefb/block-status.js';
export { ChatList } from './scrabblefb/chat-list.js';
@@ -0,0 +1,68 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class BestMoveTile {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):BestMoveTile {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile {
return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
letter():string|null
letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
letter(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
value():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
blank():boolean {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startBestMoveTile(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, letterOffset, 0);
}
static addValue(builder:flatbuffers.Builder, value:number) {
builder.addFieldInt32(1, value, 0);
}
static addBlank(builder:flatbuffers.Builder, blank:boolean) {
builder.addFieldInt8(2, +blank, +false);
}
static endBestMoveTile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createBestMoveTile(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset, value:number, blank:boolean):flatbuffers.Offset {
BestMoveTile.startBestMoveTile(builder);
BestMoveTile.addLetter(builder, letterOffset);
BestMoveTile.addValue(builder, value);
BestMoveTile.addBlank(builder, blank);
return BestMoveTile.endBestMoveTile(builder);
}
}
@@ -0,0 +1,88 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { BestMoveTile } from '../scrabblefb/best-move-tile.js';
export class BestMoveView {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):BestMoveView {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView {
return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
score():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
word(index: number, obj?:BestMoveTile):BestMoveTile|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new BestMoveTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
wordLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startBestMoveView(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, variantOffset, 0);
}
static addScore(builder:flatbuffers.Builder, score:number) {
builder.addFieldInt32(1, score, 0);
}
static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, wordOffset, 0);
}
static createWordVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startWordVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endBestMoveView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createBestMoveView(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, score:number, wordOffset:flatbuffers.Offset):flatbuffers.Offset {
BestMoveView.startBestMoveView(builder);
BestMoveView.addVariant(builder, variantOffset);
BestMoveView.addScore(builder, score);
BestMoveView.addWord(builder, wordOffset);
return BestMoveView.endBestMoveView(builder);
}
}
+32 -2
View File
@@ -2,6 +2,9 @@
import * as flatbuffers from 'flatbuffers';
import { BestMoveView } from '../scrabblefb/best-move-view.js';
export class StatsView {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
@@ -45,8 +48,18 @@ maxWordPoints():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
bestMoves(index: number, obj?:BestMoveView):BestMoveView|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? (obj || new BestMoveView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
bestMovesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startStatsView(builder:flatbuffers.Builder) {
builder.startObject(5);
builder.startObject(6);
}
static addWins(builder:flatbuffers.Builder, wins:number) {
@@ -69,18 +82,35 @@ static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) {
builder.addFieldInt32(4, maxWordPoints, 0);
}
static addBestMoves(builder:flatbuffers.Builder, bestMovesOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, bestMovesOffset, 0);
}
static createBestMovesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startBestMovesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endStatsView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number):flatbuffers.Offset {
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number, bestMovesOffset:flatbuffers.Offset):flatbuffers.Offset {
StatsView.startStatsView(builder);
StatsView.addWins(builder, wins);
StatsView.addLosses(builder, losses);
StatsView.addDraws(builder, draws);
StatsView.addMaxGamePoints(builder, maxGamePoints);
StatsView.addMaxWordPoints(builder, maxWordPoints);
StatsView.addBestMoves(builder, bestMovesOffset);
return StatsView.endStatsView(builder);
}
}
+47
View File
@@ -291,6 +291,53 @@ describe('codec', () => {
draws: 1,
maxGamePoints: 420,
maxWordPoints: 90,
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,
bestMoves: [
{
variant: 'scrabble_en',
score: 90,
word: [
{ letter: 'c', value: 3, blank: false },
{ letter: 'a', value: 0, blank: true },
],
},
],
});
});
+14
View File
@@ -11,6 +11,8 @@ import type {
AccountRef,
Banner,
BannerCampaign,
BestMove,
BestMoveTile,
BlockStatus,
ChatMessage,
EvalResult,
@@ -742,12 +744,24 @@ 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(),
bestMoves,
};
}
+39 -1
View File
@@ -50,7 +50,45 @@ 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,
// 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 [
+21 -1
View File
@@ -206,13 +206,33 @@ 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;
bestMoves: BestMove[];
}
/** Settings the inviter chooses for a friend game. */
+1
View File
@@ -8,6 +8,7 @@ const s = (wins: number, losses: number, draws: number): Stats => ({
draws,
maxGamePoints: 0,
maxWordPoints: 0,
bestMoves: [],
});
describe('stats', () => {
+52 -2
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import WordTiles from '../components/WordTiles.svelte';
import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { gamesPlayed, winRate } from '../lib/stats';
import type { Stats } from '../lib/model';
import { ALL_VARIANTS, variantNameKey } from '../lib/variants';
import type { BestMove, Stats } from '../lib/model';
let stats = $state<Stats | null>(null);
@@ -27,10 +29,17 @@
{ key: 'stats.played', value: gamesPlayed(stats) },
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
{ key: 'stats.maxGame', value: stats.maxGamePoints },
{ key: 'stats.maxWord', value: stats.maxWordPoints },
]
: [],
);
// The best move is shown as a full-width breakdown with the word itself, one row per
// variant the player has played, in catalogue order (the backend lists only non-empty
// variants). It replaces the former single "best move" number card.
const ORDER = ALL_VARIANTS.map((v) => v.id);
const bestMoves = $derived<BestMove[]>(
stats ? [...stats.bestMoves].sort((a, b) => ORDER.indexOf(a.variant) - ORDER.indexOf(b.variant)) : [],
);
</script>
<Screen title={t('stats.title')} back="/">
@@ -46,6 +55,18 @@
</div>
{/each}
</div>
{#if bestMoves.length > 0}
<div class="card bestmove">
<span class="lbl">{t('stats.maxWord')}</span>
<div class="rows">
{#each bestMoves as bm (bm.variant)}
<span class="variant">{t(variantNameKey(bm.variant))}</span>
<span class="wordcell"><WordTiles word={bm.word} /></span>
<span class="score">{bm.score}</span>
{/each}
</div>
</div>
{/if}
{/if}
</div>
</Screen>
@@ -79,4 +100,33 @@
color: var(--text-muted);
font-size: 0.85rem;
}
.bestmove {
margin-top: 12px;
gap: 12px;
}
/* One grid for all rows so columns align across them: variant on the left, the word
tiles right-aligned to a shared edge, the score right-aligned in its own column. */
.rows {
display: grid;
/* minmax(0, 1fr) lets the word column shrink below its tiles' intrinsic width on a
narrow screen (the cell then scrolls) instead of overlapping the variant label. */
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
row-gap: 12px;
column-gap: 8px;
}
.variant {
color: var(--text-muted);
font-size: 0.95rem;
}
.wordcell {
justify-self: end;
min-width: 0;
overflow-x: auto;
}
.score {
justify-self: end;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
</style>