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
+13 -2
View File
@@ -568,7 +568,8 @@ disguised robot stays indistinguishable from a person.
the `kind` admitting `robot`),
`sessions` (revoke-only opaque-token hashes), the game tables
`games` (carrying the `dropout_tiles` disposition column), `game_players`,
`game_moves` (the move journal), `complaints` and `account_stats`, and the
`game_moves` (the move journal), `complaints`, `account_stats` and
`account_best_move`, and the
social/lobby tables `friendships` (the request/accept graph, its status admitting
`declined`), `blocks`
(per-user blocks), `chat_messages` (per-game chat and nudges, carrying the per-message
@@ -597,7 +598,17 @@ disguised robot stays indistinguishable from a person.
seat): wins, losses, **draws**, max points in a game, and
max points for a single **move** (which already folds in every word the move
formed plus the all-tiles bonus). A tie increments draws only; a resignation or
timeout is a loss for the acting player.
timeout is a loss for the acting player. A companion table **`account_best_move`**
(keyed by account **and variant**) keeps the highest-scoring single play **per
variant** with the word itself: its main word as an ordered JSON array of tiles
(letter, tile value, blank flag — value 0 for a blank), so the statistics screen
renders it as game tiles without the variant's alphabet table. Blank flags are
taken from every blank ever placed in the game (equivalent to reading the final
board, since a placed tile never moves). It is replaced only by a strictly
higher-scoring play, written in the same finish transaction, and skipped for
guest/honest-AI games exactly like `account_stats`. It is filled forward only —
plays finished before the table existed are not back-populated (the aggregate
`max_word_points` still covers them numerically).
### 9.1 History invariant (must hold forever)
+3
View File
@@ -230,6 +230,9 @@ game is throwaway). The client shares the `.gcg` file where the platform support
it, otherwise downloads it. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus).
The best move is also broken down **per game variant**, showing the **word itself**
drawn as game tiles (a wildcard shows its letter but no value) — one row per variant
the player has played, omitting variants with no plays.
A game that can no longer be continued — because a rule changed and an earlier
move would now be illegal — is closed as a **draw** the moment someone opens it,
never left stuck on an error: the move history shows an impersonal organizer note
+3
View File
@@ -234,6 +234,9 @@ UTC), суточного окна отсутствия (away; сетка по 10
иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки).
Лучший ход также даётся **с разбивкой по вариантам игры****само слово**,
нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной
строке на каждый сыгранный вариант; варианты без ходов не выводятся.
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
+7 -2
View File
@@ -280,8 +280,13 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's
send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working.
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts.
cards (wins / losses / draws / games / win-rate / best game) — pure numbers, no
charts — followed by a **full-width "best move" card** that breaks the best move
down per variant: one row per played variant (catalogue order, empty variants
omitted) with the variant name on the left, the **word drawn as game tiles**
(`components/WordTiles.svelte`, the board's placed-tile look at a small fixed size;
a wildcard shows its letter but no value) right-aligned to a shared edge, and the
score right-aligned in its own column.
- **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a
**UTC-offset** timezone dropdown (defaulting to the browser's offset), the away
window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an