feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
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:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
@@ -120,8 +121,8 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
||||
t.Fatalf("final game not finished: %+v", last.Game)
|
||||
}
|
||||
|
||||
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
|
||||
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
|
||||
w0, l0, d0, mg0, mw0, ok0 := readStats(t, seats[0])
|
||||
w1, l1, d1, mg1, mw1, ok1 := readStats(t, seats[1])
|
||||
if !ok0 || !ok1 {
|
||||
t.Fatal("both players must have a stats row")
|
||||
}
|
||||
@@ -133,6 +134,52 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
||||
if !decisive && !draw {
|
||||
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
|
||||
}
|
||||
|
||||
// Each player who made a scoring play gets exactly one per-variant best move (only
|
||||
// scrabble_en was played here); its score equals the aggregate max_word_points and it
|
||||
// carries the decoded word as tiles.
|
||||
accounts := account.NewStore(testDB)
|
||||
for _, p := range []struct {
|
||||
id uuid.UUID
|
||||
maxWord int
|
||||
}{{seats[0], mw0}, {seats[1], mw1}} {
|
||||
if p.maxWord == 0 {
|
||||
continue // a player who only passed has no best move
|
||||
}
|
||||
st, err := accounts.GetStats(ctx, p.id)
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
// The player made at least one scoring play (maxWord > 0), so the moves aggregate is
|
||||
// positive; no hints were taken in this greedy game, so the hints aggregate stays 0.
|
||||
if st.Moves <= 0 {
|
||||
t.Errorf("moves = %d, want > 0", st.Moves)
|
||||
}
|
||||
if st.HintsUsed != 0 {
|
||||
t.Errorf("hints used = %d, want 0 (no hints taken)", st.HintsUsed)
|
||||
}
|
||||
if len(st.BestMoves) != 1 {
|
||||
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
|
||||
}
|
||||
bm := st.BestMoves[0]
|
||||
if bm.Variant != engine.VariantEnglish.String() {
|
||||
t.Errorf("best move variant = %q, want %q", bm.Variant, engine.VariantEnglish.String())
|
||||
}
|
||||
if bm.Score != p.maxWord {
|
||||
t.Errorf("best move score %d != max_word_points %d", bm.Score, p.maxWord)
|
||||
}
|
||||
if len(bm.Tiles) == 0 {
|
||||
t.Error("best move word is empty")
|
||||
}
|
||||
for _, tl := range bm.Tiles {
|
||||
if tl.Letter == "" {
|
||||
t.Error("best move tile has empty letter")
|
||||
}
|
||||
if tl.Blank && tl.Value != 0 {
|
||||
t.Errorf("blank tile must score 0, got %d", tl.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplayEquivalence plays a few moves through one service, then proves a
|
||||
@@ -369,6 +416,15 @@ func TestHintPolicy(t *testing.T) {
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
|
||||
t.Fatalf("first hint: %v", err)
|
||||
}
|
||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
||||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||
if err != nil {
|
||||
t.Fatalf("state: %v", err)
|
||||
}
|
||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
||||
}
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||
}
|
||||
@@ -377,8 +433,18 @@ func TestHintPolicy(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("wallet hint: %v", err)
|
||||
}
|
||||
if res.HintsRemaining != 1 {
|
||||
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
|
||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
||||
}
|
||||
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
||||
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||||
st2, err := svc.GameState(ctx, g.ID, seats[0])
|
||||
if err != nil {
|
||||
t.Fatalf("state after wallet hint: %v", err)
|
||||
}
|
||||
if got := st2.Game.Seats[0].HintsUsed; got != 2 {
|
||||
t.Errorf("hints_used = %d after allowance + wallet hint, want 2", got)
|
||||
}
|
||||
|
||||
off, err := svc.Create(ctx, game.CreateParams{
|
||||
|
||||
Reference in New Issue
Block a user