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
+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,
};
}