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
@@ -0,0 +1,25 @@
-- +goose Up
-- Per-account, per-variant best single move: the highest-scoring play the account has
-- ever made in each game variant. It exists so the statistics screen can show the word
-- itself broken down by variant, not just the dimensionless aggregate
-- account_stats.max_word_points. tiles is the move's main word as an ordered JSON array of
-- {letter, value, blank} objects (value 0 and blank true for a wildcard), so the client
-- renders it as game tiles without needing the variant's alphabet table. score is the
-- play's total points (every word it formed plus the all-tiles bonus), matching
-- max_word_points. A row is replaced only by a strictly higher-scoring play. It is written
-- at game finish alongside account_stats; guest and honest-AI games never record statistics,
-- so they never write here. See docs/ARCHITECTURE.md §9.
SET search_path = backend, pg_catalog;
CREATE TABLE account_best_move (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
variant text NOT NULL,
score integer NOT NULL,
tiles jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, variant)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_best_move;