From 6d1d8030e35ebfd10d6506549704a884472d1259 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 23:44:53 +0200 Subject: [PATCH] feat(stats): per-game hints used + lifetime moves and hint share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store the hints a player used in each game, and add two lifetime tiles — Moves and Hint share — to the statistics screen. - per-game: game_players.hints_used now counts EVERY hint (allowance + wallet), not just the free allowance, so it is the seat's true total hints used this game. The allowance decision (used < HintsPerPlayer) and the hint badge values (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does NOT regress; only the admin "hints used" column, which silently under-counted wallet hints, becomes accurate. - account_stats gains two summed counters: moves (the player's plays — passes and exchanges excluded) and hints_used (every hint). Computed at game finish in buildStats over the same non-guest, non-honest-AI games as the rest of the stats. - wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model; regen. - ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate. - docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN. Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats (moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e. --- backend/internal/account/stats.go | 8 ++++- backend/internal/game/service.go | 20 ++++++++---- backend/internal/game/store.go | 14 ++++++-- backend/internal/game/types.go | 2 ++ backend/internal/inttest/game_test.go | 17 ++++++++++ .../jet/backend/model/account_stats.go | 2 ++ .../jet/backend/table/account_stats.go | 12 +++++-- .../postgres/migrations/00001_baseline.sql | 3 +- .../migrations/00010_stats_moves_hints.sql | 16 ++++++++++ backend/internal/server/handlers_account.go | 4 +++ docs/ARCHITECTURE.md | 5 ++- docs/FUNCTIONAL.md | 4 ++- docs/FUNCTIONAL_ru.md | 4 ++- docs/UI_DESIGN.md | 6 ++-- gateway/internal/backendclient/api_social.go | 2 ++ gateway/internal/transcode/encode_social.go | 2 ++ .../transcode/transcode_social_test.go | 4 +++ pkg/fbs/scrabble.fbs | 4 +++ pkg/fbs/scrabblefb/StatsView.go | 32 ++++++++++++++++++- ui/e2e/social.spec.ts | 4 +++ ui/src/gen/fbs/scrabblefb/stats-view.ts | 24 ++++++++++++-- ui/src/lib/codec.test.ts | 6 ++++ ui/src/lib/codec.ts | 2 ++ ui/src/lib/i18n/en.ts | 2 ++ ui/src/lib/i18n/ru.ts | 2 ++ ui/src/lib/mock/data.ts | 2 ++ ui/src/lib/model.ts | 4 +++ ui/src/lib/stats.test.ts | 17 +++++++++- ui/src/lib/stats.ts | 7 ++++ ui/src/screens/Stats.svelte | 23 +++++++++---- 30 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 backend/internal/postgres/migrations/00010_stats_moves_hints.sql diff --git a/backend/internal/account/stats.go b/backend/internal/account/stats.go index 4df748c..8530754 100644 --- a/backend/internal/account/stats.go +++ b/backend/internal/account/stats.go @@ -46,7 +46,11 @@ type Stats struct { Draws int MaxGamePoints int MaxWordPoints int - BestMoves []BestMove + // Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the + // lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves). + Moves int + HintsUsed int + BestMoves []BestMove } // GetStats returns the lifetime statistics for id. An account with no account_stats @@ -74,6 +78,8 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) { Draws: int(row.Draws), MaxGamePoints: int(row.MaxGamePoints), MaxWordPoints: int(row.MaxWordPoints), + Moves: int(row.Moves), + HintsUsed: int(row.HintsUsed), BestMoves: best, }, nil } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 95360d2..310b882 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1027,12 +1027,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint } walletAfter := acc.HintBalance - if fromAllowance { - if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil { - return HintResult{}, err - } - used++ - } else { + if !fromAllowance { spent, err := svc.accounts.SpendHint(ctx, accountID) if err != nil { return HintResult{}, err @@ -1042,6 +1037,13 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint } walletAfter-- } + // hints_used is the per-game total (allowance + wallet): every hint increments it. The first + // HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest + // are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics. + if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil { + return HintResult{}, err + } + used++ return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil } @@ -1429,10 +1431,12 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta { res := g.Result() bestRec := make(map[int]engine.MoveRecord) blanks := make(map[[2]int]bool) + plays := make(map[int]int) // per player: count of plays (tile placements), for the "moves" stat for _, rec := range g.Log() { if rec.Action != engine.ActionPlay { continue } + plays[rec.Player]++ for _, t := range rec.Tiles { if t.Blank { blanks[[2]int{t.Row, t.Col}] = true @@ -1446,7 +1450,9 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta { values := letterValues(g.Variant()) out := make([]statDelta, 0, len(seats)) for _, s := range seats { - d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat)} + // moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are + // summed into account_stats so the screen can show the hint share (hints_used / moves). + d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), moves: plays[s.Seat], hintsUsed: s.HintsUsed} if rec, ok := bestRec[s.Seat]; ok { d.wordPoints = rec.Score if rec.Score > 0 { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index e720d27..d04eb8e 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -66,6 +66,8 @@ type statDelta struct { draws int gamePoints int wordPoints int + moves int // plays this game (tile placements), summed into account_stats.moves + hintsUsed int // hints used this game (allowance + wallet), summed into account_stats.hints_used bestVariant string bestScore int bestTiles []account.BestMoveTile @@ -729,13 +731,17 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er draws := row.Draws + int32(d.draws) maxGame := max(row.MaxGamePoints, int32(d.gamePoints)) maxWord := max(row.MaxWordPoints, int32(d.wordPoints)) + moves := row.Moves + int32(d.moves) + hintsUsed := row.HintsUsed + int32(d.hintsUsed) upd := table.AccountStats.UPDATE( table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws, table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt, + table.AccountStats.Moves, table.AccountStats.HintsUsed, ).SET( postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)), postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now), + postgres.Int(int64(moves)), postgres.Int(int64(hintsUsed)), ).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID))) if _, err := upd.ExecContext(ctx, tx); err != nil { return fmt.Errorf("update stats %s: %w", d.accountID, err) @@ -774,8 +780,10 @@ func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) return nil } -// SpendHintAllowance increments a seat's per-game hint counter by one. -func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error { +// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for +// every hint — both the free per-game allowance and the wallet-charged ones — so the counter +// is the seat's total hints used this game (the first HintsPerPlayer being the allowance). +func (s *Store) IncHintsUsed(ctx context.Context, gameID uuid.UUID, seat int) error { stmt := table.GamePlayers. UPDATE(table.GamePlayers.HintsUsed). SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))). @@ -784,7 +792,7 @@ func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat i AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))), ) if _, err := stmt.ExecContext(ctx, s.db); err != nil { - return fmt.Errorf("game: spend hint allowance: %w", err) + return fmt.Errorf("game: increment hints used: %w", err) } return nil } diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 6c941e5..4e7f733 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -154,6 +154,8 @@ type Seat struct { Seat int AccountID uuid.UUID Score int + // HintsUsed is the total hints the seat used this game — both the free per-game allowance + // and the wallet-charged ones (the first HintsPerPlayer being the allowance). HintsUsed int IsWinner bool // DisplayName is the seat's display-name snapshot, captured when the seat was taken diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index b9f221d..0bab0c4 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -150,6 +150,14 @@ func TestGameLifecycleAndStats(t *testing.T) { 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) } @@ -429,6 +437,15 @@ func TestHintPolicy(t *testing.T) { 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{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, diff --git a/backend/internal/postgres/jet/backend/model/account_stats.go b/backend/internal/postgres/jet/backend/model/account_stats.go index 5199497..47d32b8 100644 --- a/backend/internal/postgres/jet/backend/model/account_stats.go +++ b/backend/internal/postgres/jet/backend/model/account_stats.go @@ -20,4 +20,6 @@ type AccountStats struct { MaxGamePoints int32 MaxWordPoints int32 UpdatedAt time.Time + Moves int32 + HintsUsed int32 } diff --git a/backend/internal/postgres/jet/backend/table/account_stats.go b/backend/internal/postgres/jet/backend/table/account_stats.go index 04fae84..f4c90ad 100644 --- a/backend/internal/postgres/jet/backend/table/account_stats.go +++ b/backend/internal/postgres/jet/backend/table/account_stats.go @@ -24,6 +24,8 @@ type accountStatsTable struct { MaxGamePoints postgres.ColumnInteger MaxWordPoints postgres.ColumnInteger UpdatedAt postgres.ColumnTimestampz + Moves postgres.ColumnInteger + HintsUsed postgres.ColumnInteger AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -72,9 +74,11 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT MaxGamePointsColumn = postgres.IntegerColumn("max_game_points") MaxWordPointsColumn = postgres.IntegerColumn("max_word_points") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn} + MovesColumn = postgres.IntegerColumn("moves") + HintsUsedColumn = postgres.IntegerColumn("hints_used") + allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn} + mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn} + defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn} ) return accountStatsTable{ @@ -88,6 +92,8 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT MaxGamePoints: MaxGamePointsColumn, MaxWordPoints: MaxWordPointsColumn, UpdatedAt: UpdatedAtColumn, + Moves: MovesColumn, + HintsUsed: HintsUsedColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index 290937a..066dab2 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -131,7 +131,8 @@ CREATE INDEX games_open_idx ON games (open_deadline_at) WHERE status = 'open'; -- account, or NULL for the still-empty opponent seat of an auto-match game waiting for an -- opponent (status='open'); it is filled when a human or a robot joins. score is the -- running/final score, is_winner is stamped on finish (false for every seat on a draw), --- hints_used counts the per-game allowance consumed before the profile wallet. +-- hints_used counts the seat's total hints used this game (the free per-game allowance plus +-- the wallet-charged ones; the first HintsPerPlayer are the allowance). CREATE TABLE game_players ( game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, seat smallint NOT NULL, diff --git a/backend/internal/postgres/migrations/00010_stats_moves_hints.sql b/backend/internal/postgres/migrations/00010_stats_moves_hints.sql new file mode 100644 index 0000000..5295235 --- /dev/null +++ b/backend/internal/postgres/migrations/00010_stats_moves_hints.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- account_stats gains two lifetime counters for the player's statistics screen: moves — the +-- player's plays (tile placements; passes and exchanges do not count) — and hints_used — every +-- hint the player took (the free per-game allowance plus the wallet-charged ones). Both are +-- summed at game finish over the same games that feed the rest of account_stats (durable +-- non-guest accounts; honest-AI games are skipped), so the screen can show the "hint share" +-- = hints_used / moves. See docs/ARCHITECTURE.md §9. +SET search_path = backend, pg_catalog; + +ALTER TABLE account_stats ADD COLUMN moves integer NOT NULL DEFAULT 0; +ALTER TABLE account_stats ADD COLUMN hints_used integer NOT NULL DEFAULT 0; + +-- +goose Down +SET search_path = backend, pg_catalog; +ALTER TABLE account_stats DROP COLUMN hints_used; +ALTER TABLE account_stats DROP COLUMN moves; diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index 817af04..7df096c 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -38,6 +38,8 @@ type statsDTO struct { Draws int `json:"draws"` MaxGamePoints int `json:"max_game_points"` MaxWordPoints int `json:"max_word_points"` + Moves int `json:"moves"` + HintsUsed int `json:"hints_used"` BestMoves []bestMoveDTO `json:"best_moves,omitempty"` } @@ -165,6 +167,8 @@ func (s *Server) handleStats(c *gin.Context) { Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints, + Moves: st.Moves, + HintsUsed: st.HintsUsed, BestMoves: best, }) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 37537a5..0050fd9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -603,7 +603,10 @@ disguised robot stays indistinguishable from a person. non-guest accounts only — the finish-time recompute skips any `is_guest` 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 + formed plus the all-tiles bonus); plus two summed counters — `moves` (the player's + plays, i.e. tile placements; passes and exchanges do not count) and `hints_used` + (every hint taken, allowance + wallet) — from which the screen derives the **hint + share** = hints_used / moves. A tie increments draws only; a resignation or 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 868503e..01c160d 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -229,7 +229,9 @@ honest-AI practice game (a live game's export would leak the move journal; an AI game is throwaway). The client shares the `.gcg` file where the platform supports 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). +best play, which already includes every word it formed plus the all-tiles bonus). It +also shows the player's **move count** (their plays — passes and exchanges do not +count) and their **hint share** (the percentage of those plays where a hint was taken). 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. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 0327e0c..18139ff 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -233,7 +233,9 @@ UTC), суточного окна отсутствия (away; сетка по 10 с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший -ход, уже включающий все образованные им слова и бонус за все фишки). +ход, уже включающий все образованные им слова и бонус за все фишки). Также +показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и +**доля подсказок** (процент таких ходов, на которых бралась подсказка). Лучший ход также даётся **с разбивкой по вариантам игры** — **само слово**, нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной строке на каждый сыгранный вариант; варианты без ходов не выводятся. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index fc9db73..8e428cc 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -280,8 +280,10 @@ 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) — pure numbers, no - charts — followed by a **full-width "best move" card** that breaks the best move + cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) — + pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's + notation (e.g. "4.8%" / "4,8%") — 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; diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 7747fc2..c28dadd 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -56,6 +56,8 @@ type StatsResp struct { Draws int `json:"draws"` MaxGamePoints int `json:"max_game_points"` MaxWordPoints int `json:"max_word_points"` + Moves int `json:"moves"` + HintsUsed int `json:"hints_used"` BestMoves []BestMoveResp `json:"best_moves"` } diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index 1218fe4..c183ae5 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -143,6 +143,8 @@ func encodeStats(r backendclient.StatsResp) []byte { fb.StatsViewAddDraws(b, int32(r.Draws)) fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints)) fb.StatsViewAddMaxWordPoints(b, int32(r.MaxWordPoints)) + fb.StatsViewAddMoves(b, int32(r.Moves)) + fb.StatsViewAddHintsUsed(b, int32(r.HintsUsed)) if len(r.BestMoves) > 0 { fb.StatsViewAddBestMoves(b, bestMoves) } diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index 4f6fcab..b0892e1 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -195,6 +195,7 @@ func TestStatsRoundTrip(t *testing.T) { t.Errorf("unexpected path %q", r.URL.Path) } _, _ = w.Write([]byte(`{"wins":5,"losses":3,"draws":1,"max_game_points":420,"max_word_points":90,` + + `"moves":248,"hints_used":12,` + `"best_moves":[{"variant":"scrabble_en","score":90,"word":[` + `{"letter":"c","value":3,"blank":false},` + `{"letter":"a","value":0,"blank":true},` + @@ -212,6 +213,9 @@ func TestStatsRoundTrip(t *testing.T) { if st.Wins() != 5 || st.Losses() != 3 || st.Draws() != 1 || st.MaxGamePoints() != 420 || st.MaxWordPoints() != 90 { t.Fatalf("stats decoded wrong: %+v", st) } + if st.Moves() != 248 || st.HintsUsed() != 12 { + t.Fatalf("moves/hints decoded wrong: moves=%d hints=%d", st.Moves(), st.HintsUsed()) + } if st.BestMovesLength() != 1 { t.Fatalf("best moves length = %d, want 1", st.BestMovesLength()) } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index f40f645..19b0107 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -497,6 +497,10 @@ table StatsView { max_game_points:int; max_word_points:int; best_moves:[BestMoveView]; + // moves is the player's lifetime play count (tile placements); hints_used is their lifetime + // hint count. The screen shows the hint share = hints_used / moves (added trailing). + moves:int; + hints_used:int; } // TargetRequest names a single counterpart account (friend request/cancel/unfriend, diff --git a/pkg/fbs/scrabblefb/StatsView.go b/pkg/fbs/scrabblefb/StatsView.go index 67ba771..3479d36 100644 --- a/pkg/fbs/scrabblefb/StatsView.go +++ b/pkg/fbs/scrabblefb/StatsView.go @@ -121,8 +121,32 @@ func (rcv *StatsView) BestMovesLength() int { return 0 } +func (rcv *StatsView) Moves() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StatsView) MutateMoves(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + +func (rcv *StatsView) HintsUsed() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StatsView) MutateHintsUsed(n int32) bool { + return rcv._tab.MutateInt32Slot(18, n) +} + func StatsViewStart(builder *flatbuffers.Builder) { - builder.StartObject(6) + builder.StartObject(8) } func StatsViewAddWins(builder *flatbuffers.Builder, wins int32) { builder.PrependInt32Slot(0, wins, 0) @@ -145,6 +169,12 @@ func StatsViewAddBestMoves(builder *flatbuffers.Builder, bestMoves flatbuffers.U func StatsViewStartBestMovesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func StatsViewAddMoves(builder *flatbuffers.Builder, moves int32) { + builder.PrependInt32Slot(6, moves, 0) +} +func StatsViewAddHintsUsed(builder *flatbuffers.Builder, hintsUsed int32) { + builder.PrependInt32Slot(7, hintsUsed, 0) +} func StatsViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index b5e475c..db219a2 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -58,6 +58,10 @@ test('stats screen shows the metrics and the per-variant best move', async ({ pa await page.getByRole('button', { name: /Stats/ }).click(); await expect(page.getByText('Win rate')).toBeVisible(); await expect(page.getByText('Best move')).toBeVisible(); + // The Moves count and the derived Hint share (12 hints / 248 plays = 4.8%, one decimal). + await expect(page.getByText('Moves', { exact: true })).toBeVisible(); + await expect(page.getByText('Hint share')).toBeVisible(); + await expect(page.getByText('4.8%')).toBeVisible(); // The best move breaks down per played variant, each row labelled by the variant and // ending in the play's score (the word itself renders as game tiles). await expect(page.getByText('Scrabble', { exact: true })).toBeVisible(); diff --git a/ui/src/gen/fbs/scrabblefb/stats-view.ts b/ui/src/gen/fbs/scrabblefb/stats-view.ts index fd87e26..56a22b9 100644 --- a/ui/src/gen/fbs/scrabblefb/stats-view.ts +++ b/ui/src/gen/fbs/scrabblefb/stats-view.ts @@ -58,8 +58,18 @@ bestMovesLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +moves():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +hintsUsed():number { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startStatsView(builder:flatbuffers.Builder) { - builder.startObject(6); + builder.startObject(8); } static addWins(builder:flatbuffers.Builder, wins:number) { @@ -98,12 +108,20 @@ static startBestMovesVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addMoves(builder:flatbuffers.Builder, moves:number) { + builder.addFieldInt32(6, moves, 0); +} + +static addHintsUsed(builder:flatbuffers.Builder, hintsUsed:number) { + builder.addFieldInt32(7, hintsUsed, 0); +} + 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, bestMovesOffset:flatbuffers.Offset):flatbuffers.Offset { +static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number, bestMovesOffset:flatbuffers.Offset, moves:number, hintsUsed:number):flatbuffers.Offset { StatsView.startStatsView(builder); StatsView.addWins(builder, wins); StatsView.addLosses(builder, losses); @@ -111,6 +129,8 @@ static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, StatsView.addMaxGamePoints(builder, maxGamePoints); StatsView.addMaxWordPoints(builder, maxWordPoints); StatsView.addBestMoves(builder, bestMovesOffset); + StatsView.addMoves(builder, moves); + StatsView.addHintsUsed(builder, hintsUsed); return StatsView.endStatsView(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index dae78e9..089a465 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -284,6 +284,8 @@ describe('codec', () => { fb.StatsView.addDraws(b, 1); fb.StatsView.addMaxGamePoints(b, 420); fb.StatsView.addMaxWordPoints(b, 90); + fb.StatsView.addMoves(b, 248); + fb.StatsView.addHintsUsed(b, 12); b.finish(fb.StatsView.endStatsView(b)); expect(decodeStats(b.asUint8Array())).toEqual({ wins: 7, @@ -291,6 +293,8 @@ describe('codec', () => { draws: 1, maxGamePoints: 420, maxWordPoints: 90, + moves: 248, + hintsUsed: 12, bestMoves: [], }); }); @@ -328,6 +332,8 @@ describe('codec', () => { draws: 0, maxGamePoints: 0, maxWordPoints: 0, + moves: 0, + hintsUsed: 0, bestMoves: [ { variant: 'scrabble_en', diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 069ebd2..f155747 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -762,6 +762,8 @@ export function decodeStats(buf: Uint8Array): Stats { draws: v.draws(), maxGamePoints: v.maxGamePoints(), maxWordPoints: v.maxWordPoints(), + moves: v.moves(), + hintsUsed: v.hintsUsed(), bestMoves, }; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index e764fd2..a8dbf42 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -274,6 +274,8 @@ export const en = { 'stats.losses': 'Losses', 'stats.draws': 'Draws', 'stats.played': 'Games', + 'stats.moves': 'Moves', + 'stats.hintShare': 'Hint share', 'stats.winRate': 'Win rate', 'stats.maxGame': 'Best game', 'stats.maxWord': 'Best move', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b3bf555..6f3d884 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -275,6 +275,8 @@ export const ru: Record = { 'stats.losses': 'Поражения', 'stats.draws': 'Ничьи', 'stats.played': 'Игр', + 'stats.moves': 'Ходы', + 'stats.hintShare': 'Доля подсказок', 'stats.winRate': 'Доля побед', 'stats.maxGame': 'Лучшая игра', 'stats.maxWord': 'Лучший ход', diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 981543c..c4e7c20 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -56,6 +56,8 @@ export const MOCK_STATS: Stats = { draws: 1, maxGamePoints: 421, maxWordPoints: 134, + moves: 248, // plays across all games + hintsUsed: 12, // -> hint share 12/248 = 4.8% // 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. diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 50d60d9..5581fbf 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -237,6 +237,10 @@ export interface Stats { draws: number; maxGamePoints: number; maxWordPoints: number; + /** Lifetime count of the player's plays (tile placements). */ + moves: number; + /** Lifetime count of hints the player took (allowance + wallet). */ + hintsUsed: number; bestMoves: BestMove[]; } diff --git a/ui/src/lib/stats.test.ts b/ui/src/lib/stats.test.ts index 6448d76..bc60480 100644 --- a/ui/src/lib/stats.test.ts +++ b/ui/src/lib/stats.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { gamesPlayed, winRate } from './stats'; +import { gamesPlayed, hintSharePercent, winRate } from './stats'; import type { Stats } from './model'; const s = (wins: number, losses: number, draws: number): Stats => ({ @@ -8,9 +8,14 @@ const s = (wins: number, losses: number, draws: number): Stats => ({ draws, maxGamePoints: 0, maxWordPoints: 0, + moves: 0, + hintsUsed: 0, bestMoves: [], }); +// withCounts overrides moves/hintsUsed on the zero fixture for the hint-share cases. +const withCounts = (moves: number, hintsUsed: number): Stats => ({ ...s(0, 0, 0), moves, hintsUsed }); + describe('stats', () => { it('sums games played', () => { expect(gamesPlayed(s(7, 4, 1))).toBe(12); @@ -24,4 +29,14 @@ describe('stats', () => { it('win rate is 0 with no games', () => { expect(winRate(s(0, 0, 0))).toBe(0); }); + + it('computes the hint share (hints / plays)', () => { + expect(hintSharePercent(withCounts(200, 10))).toBe(5); // 10/200 = 5% + expect(hintSharePercent(withCounts(248, 12))).toBeCloseTo(4.8387, 3); + }); + + it('hint share is 0 with no plays (no division by zero)', () => { + expect(hintSharePercent(withCounts(0, 0))).toBe(0); + expect(hintSharePercent(withCounts(0, 5))).toBe(0); + }); }); diff --git a/ui/src/lib/stats.ts b/ui/src/lib/stats.ts index 5c7e753..095ffeb 100644 --- a/ui/src/lib/stats.ts +++ b/ui/src/lib/stats.ts @@ -12,3 +12,10 @@ export function winRate(s: Stats): number { const n = gamesPlayed(s); return n > 0 ? Math.round((s.wins / n) * 100) : 0; } + +/** hintSharePercent is the share of the player's plays that drew on a hint + * (hints used / plays × 100), unrounded; 0 when no plays. The screen formats it to one + * decimal in the active locale. */ +export function hintSharePercent(s: Stats): number { + return s.moves > 0 ? (s.hintsUsed / s.moves) * 100 : 0; +} diff --git a/ui/src/screens/Stats.svelte b/ui/src/screens/Stats.svelte index edefffe..5cc6805 100644 --- a/ui/src/screens/Stats.svelte +++ b/ui/src/screens/Stats.svelte @@ -4,11 +4,20 @@ 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 { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; + import { gamesPlayed, hintSharePercent, winRate } from '../lib/stats'; import { ALL_VARIANTS, variantNameKey } from '../lib/variants'; import type { BestMove, Stats } from '../lib/model'; + // hintShare is shown to one decimal in the active locale's notation ("4.8%" / "4,8%"). + function hintShare(s: Stats): string { + const pct = hintSharePercent(s).toLocaleString(i18n.locale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + return `${pct}%`; + } + let stats = $state(null); onMount(async () => { @@ -23,12 +32,14 @@ const cards = $derived<{ key: MessageKey; value: string | number }[]>( stats ? [ - { key: 'stats.wins', value: stats.wins }, - { key: 'stats.losses', value: stats.losses }, - { key: 'stats.draws', value: stats.draws }, { key: 'stats.played', value: gamesPlayed(stats) }, - { key: 'stats.winRate', value: `${winRate(stats)}%` }, + { key: 'stats.wins', value: stats.wins }, + { key: 'stats.draws', value: stats.draws }, + { key: 'stats.losses', value: stats.losses }, + { key: 'stats.moves', value: stats.moves }, + { key: 'stats.hintShare', value: hintShare(stats) }, { key: 'stats.maxGame', value: stats.maxGamePoints }, + { key: 'stats.winRate', value: `${winRate(stats)}%` }, ] : [], );