diff --git a/backend/internal/account/stats.go b/backend/internal/account/stats.go
index 707361f..8530754 100644
--- a/backend/internal/account/stats.go
+++ b/backend/internal/account/stats.go
@@ -2,6 +2,7 @@ package account
import (
"context"
+ "encoding/json"
"errors"
"fmt"
@@ -13,16 +14,43 @@ import (
"scrabble/backend/internal/postgres/jet/backend/table"
)
+// BestMoveTile is one letter cell of a best-move word: its concrete letter (the
+// designated letter for a blank), its tile point value (0 for a blank) and whether it
+// is a blank. It is the persisted/served shape: the game domain marshals a slice of
+// these into account_best_move.tiles, and the statistics screen renders them as game
+// tiles without consulting the variant's alphabet.
+type BestMoveTile struct {
+ Letter string `json:"letter"`
+ Value int `json:"value"`
+ Blank bool `json:"blank"`
+}
+
+// BestMove is an account's highest-scoring single play within one game variant: the
+// move's total score (every word it formed plus the all-tiles bonus, matching
+// MaxWordPoints) and its main word as an ordered slice of tiles.
+type BestMove struct {
+ Variant string
+ Score int
+ Tiles []BestMoveTile
+}
+
// Stats is a durable account's lifetime record, written by the game domain on each
// finish and read for the player's statistics screen. MaxGamePoints is the best
// single game's total; MaxWordPoints is the best single move's score (which already
-// includes every word it formed plus the all-tiles bonus).
+// includes every word it formed plus the all-tiles bonus). BestMoves holds the same
+// best move broken down per variant, with the word itself — empty for an account with
+// no recorded play yet, and never carrying a variant the account has not played.
type Stats struct {
Wins int
Losses int
Draws int
MaxGamePoints int
MaxWordPoints int
+ // 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
@@ -40,11 +68,48 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
}
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
}
+ best, err := s.bestMoves(ctx, id)
+ if err != nil {
+ return Stats{}, err
+ }
return Stats{
Wins: int(row.Wins),
Losses: int(row.Losses),
Draws: int(row.Draws),
MaxGamePoints: int(row.MaxGamePoints),
MaxWordPoints: int(row.MaxWordPoints),
+ Moves: int(row.Moves),
+ HintsUsed: int(row.HintsUsed),
+ BestMoves: best,
}, nil
}
+
+// bestMoves reads an account's per-variant best moves, ordered by variant for a stable
+// response. Each row's tiles JSON is decoded into the served BestMoveTile slice. An
+// account with no recorded play yields an empty (nil) slice rather than an error.
+func (s *Store) bestMoves(ctx context.Context, id uuid.UUID) ([]BestMove, error) {
+ stmt := postgres.SELECT(
+ table.AccountBestMove.Variant,
+ table.AccountBestMove.Score,
+ table.AccountBestMove.Tiles,
+ ).
+ FROM(table.AccountBestMove).
+ WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(id))).
+ ORDER_BY(table.AccountBestMove.Variant.ASC())
+ var rows []model.AccountBestMove
+ if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
+ if errors.Is(err, qrm.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("account: best moves %s: %w", id, err)
+ }
+ out := make([]BestMove, 0, len(rows))
+ for _, r := range rows {
+ var tiles []BestMoveTile
+ if err := json.Unmarshal([]byte(r.Tiles), &tiles); err != nil {
+ return nil, fmt.Errorf("account: decode best-move tiles %s/%s: %w", id, r.Variant, err)
+ }
+ out = append(out, BestMove{Variant: r.Variant, Score: int(r.Score), Tiles: tiles})
+ }
+ return out, nil
+}
diff --git a/backend/internal/accountmerge/merge.go b/backend/internal/accountmerge/merge.go
index 21933f2..216e373 100644
--- a/backend/internal/accountmerge/merge.go
+++ b/backend/internal/accountmerge/merge.go
@@ -1,5 +1,6 @@
// Package accountmerge retires a secondary account into a primary one in a single
-// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
+// transaction: it sums statistics (merging the per-variant best moves), sums the hint
+// wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
// (accounts.merged_into). It is the data core of account linking & merge
@@ -68,6 +69,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
return err
}
+ if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
+ return err
+ }
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
return out, nil
}
-// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
-// summed, max points kept) and deletes the secondary row.
+// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
+// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.AccountStats
err := postgres.SELECT(table.AccountStats.AllColumns).
@@ -178,13 +182,16 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
- table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
+ table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
+ table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
).SET(
postgres.Int(int64(pri.Wins+sec.Wins)),
postgres.Int(int64(pri.Losses+sec.Losses)),
postgres.Int(int64(pri.Draws+sec.Draws)),
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
+ postgres.Int(int64(pri.Moves+sec.Moves)),
+ postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
@@ -198,6 +205,41 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
return nil
}
+// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
+// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
+// the secondary's rows — the secondary is only tombstoned, not removed, so without this
+// they would linger on a dead account and never reach the merged statistics screen.
+func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
+ var srows []model.AccountBestMove
+ err := postgres.SELECT(table.AccountBestMove.AllColumns).
+ FROM(table.AccountBestMove).
+ WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
+ QueryContext(ctx, tx, &srows)
+ if err != nil && !errors.Is(err, qrm.ErrNoRows) {
+ return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
+ }
+ for _, s := range srows {
+ ins := table.AccountBestMove.
+ INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
+ table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
+ VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
+ ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
+ DO_UPDATE(postgres.SET(
+ table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
+ table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
+ table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
+ ).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
+ if _, err := ins.ExecContext(ctx, tx); err != nil {
+ return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
+ }
+ }
+ del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
+ if _, err := del.ExecContext(ctx, tx); err != nil {
+ return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
+ }
+ return nil
+}
+
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// all other profile fields stay the primary's.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
index 5d4b14b..b8ca25a 100644
--- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
@@ -32,6 +32,8 @@
Wins {{.Stats.Wins}}
Losses {{.Stats.Losses}}
Draws {{.Stats.Draws}}
+
Moves {{.Stats.Moves}}
+
Hints used {{.Stats.HintsUsed}}
Best game {{.Stats.MaxGamePoints}}
Best move {{.Stats.MaxWordPoints}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 9b822d0..11a3de4 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -203,6 +203,8 @@ type StatsRow struct {
Draws int
MaxGamePoints int
MaxWordPoints int
+ Moves int
+ HintsUsed int
}
// IdentityRow is one platform/email identity of an account.
diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go
index c9d2b31..6b65685 100644
--- a/backend/internal/game/eventwire.go
+++ b/backend/internal/game/eventwire.go
@@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
+ WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index b2c0e89..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,7 +1037,14 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
}
walletAfter--
}
- return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
+ // 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
}
// Candidates returns the to-move player's legal plays for a seated player on
@@ -1130,6 +1132,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
+ WalletBalance: acc.HintBalance,
}, nil
}
@@ -1419,19 +1422,45 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
}
// buildStats derives each seat's statistics contribution from a finished game:
-// win/loss/draw from the (resignation-aware) winner, the final score, and the
-// best single-move score from the log.
+// win/loss/draw from the (resignation-aware) winner, the final score, and the best
+// single play from the log — its score and, for the per-variant breakdown, its main
+// word as rendering tiles. Blank flags are taken from every blank ever placed (so a
+// blank laid by an earlier move and embedded in the best word is honoured), which is
+// equivalent to reading the final board since a placed tile never moves.
func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result()
- best := make(map[int]int)
+ 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 && rec.Score > best[rec.Player] {
- best[rec.Player] = rec.Score
+ 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
+ }
+ }
+ if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score {
+ bestRec[rec.Player] = rec
}
}
+ variant := g.Variant().String()
+ values := letterValues(g.Variant())
out := make([]statDelta, 0, len(seats))
for _, s := range seats {
- d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[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 {
+ d.bestVariant = variant
+ d.bestScore = rec.Score
+ d.bestTiles = mainWordTiles(rec, blanks, values)
+ }
+ }
switch {
case res.Winner < 0:
d.draws = 1
@@ -1445,6 +1474,49 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
return out
}
+// letterValues builds a lower-cased letter -> tile value lookup for a variant from the
+// engine's alphabet table, so a best-move word can be rendered with per-tile values on a
+// screen (statistics) that has not cached the variant's alphabet. It is empty for an
+// unrecognised variant, leaving every value zero.
+func letterValues(v engine.Variant) map[string]int {
+ table, err := engine.AlphabetTable(v)
+ if err != nil {
+ return nil
+ }
+ m := make(map[string]int, len(table))
+ for _, e := range table {
+ m[strings.ToLower(e.Letter)] = e.Value
+ }
+ return m
+}
+
+// mainWordTiles decodes a play's main word into rendering tiles: each letter with its
+// tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a
+// blank was ever placed on; values maps a lower-cased letter to its tile value. It walks
+// the word from its first-letter coordinate along the play's orientation.
+func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile {
+ if len(rec.Words) == 0 {
+ return nil
+ }
+ dr, dc := 0, 1
+ if rec.Dir == engine.Vertical {
+ dr, dc = 1, 0
+ }
+ letters := []rune(rec.Words[0])
+ out := make([]account.BestMoveTile, len(letters))
+ for i, r := range letters {
+ row, col := rec.MainRow+i*dr, rec.MainCol+i*dc
+ blank := blanks[[2]int{row, col}]
+ letter := string(r)
+ value := 0
+ if !blank {
+ value = values[strings.ToLower(letter)]
+ }
+ out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank}
+ }
+ return out
+}
+
// nonGuestSeats filters out guest seats so the finish-time statistics are
// recomputed for durable non-guest accounts only — guests never accrue
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
diff --git a/backend/internal/game/stats_test.go b/backend/internal/game/stats_test.go
new file mode 100644
index 0000000..cff5a1a
--- /dev/null
+++ b/backend/internal/game/stats_test.go
@@ -0,0 +1,50 @@
+package game
+
+import (
+ "reflect"
+ "testing"
+
+ "scrabble/backend/internal/account"
+ "scrabble/backend/internal/engine"
+)
+
+// TestMainWordTilesHorizontalWithBlank checks that a horizontal main word is decoded into
+// tiles along its row, that the per-letter value is looked up from the value table, and that
+// a blank cell (here the middle 'a') is rendered with a zero value regardless of its letter
+// value — the blank flag is taken from the placed-blank set, not from this move's own tiles.
+func TestMainWordTilesHorizontalWithBlank(t *testing.T) {
+ rec := engine.MoveRecord{Dir: engine.Horizontal, MainRow: 7, MainCol: 5, Words: []string{"cat"}}
+ blanks := map[[2]int]bool{{7, 6}: true}
+ values := map[string]int{"c": 3, "a": 1, "t": 1}
+ got := mainWordTiles(rec, blanks, values)
+ want := []account.BestMoveTile{
+ {Letter: "c", Value: 3, Blank: false},
+ {Letter: "a", Value: 0, Blank: true},
+ {Letter: "t", Value: 1, Blank: false},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("mainWordTiles = %+v, want %+v", got, want)
+ }
+}
+
+// TestMainWordTilesVerticalCyrillic checks vertical walk and multi-byte (Cyrillic) letters:
+// the word must be split by rune, not byte, and laid down its column.
+func TestMainWordTilesVerticalCyrillic(t *testing.T) {
+ rec := engine.MoveRecord{Dir: engine.Vertical, MainRow: 3, MainCol: 8, Words: []string{"съёмка"}}
+ values := map[string]int{"с": 1, "ъ": 10, "ё": 4, "м": 2, "к": 2, "а": 1}
+ got := mainWordTiles(rec, map[[2]int]bool{}, values)
+ want := []account.BestMoveTile{
+ {Letter: "с", Value: 1}, {Letter: "ъ", Value: 10}, {Letter: "ё", Value: 4},
+ {Letter: "м", Value: 2}, {Letter: "к", Value: 2}, {Letter: "а", Value: 1},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("mainWordTiles = %+v, want %+v", got, want)
+ }
+}
+
+// TestMainWordTilesNoWord checks the defensive nil for a record carrying no words.
+func TestMainWordTilesNoWord(t *testing.T) {
+ if got := mainWordTiles(engine.MoveRecord{}, nil, nil); got != nil {
+ t.Errorf("mainWordTiles(no words) = %+v, want nil", got)
+ }
+}
diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go
index f524de1..d04eb8e 100644
--- a/backend/internal/game/store.go
+++ b/backend/internal/game/store.go
@@ -3,6 +3,7 @@ package game
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
"fmt"
"hash/fnv"
@@ -12,6 +13,7 @@ import (
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
+ "scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
@@ -53,13 +55,22 @@ type gameInsert struct {
}
// statDelta is one account's contribution to its statistics on a game finish.
+// bestVariant/bestScore/bestTiles describe the game's best play for this account when
+// it scored (bestVariant empty otherwise): the variant label, the play's total score and
+// its main word as rendering tiles. They feed the per-variant account_best_move upsert,
+// which keeps only the account's highest-scoring play per variant.
type statDelta struct {
- accountID uuid.UUID
- wins int
- losses int
- draws int
- gamePoints int
- wordPoints int
+ accountID uuid.UUID
+ wins int
+ losses int
+ 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
}
// commit is everything a single committed transition persists: the journal row,
@@ -633,6 +644,9 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error {
if err := upsertStats(ctx, tx, d, c.now); err != nil {
return err
}
+ if err := upsertBestMove(ctx, tx, d, c.now); err != nil {
+ return err
+ }
}
}
return nil
@@ -662,6 +676,9 @@ func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
if err := upsertStats(ctx, tx, d, v.now); err != nil {
return err
}
+ if err := upsertBestMove(ctx, tx, d, v.now); err != nil {
+ return err
+ }
}
return nil
})
@@ -714,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)
@@ -728,8 +749,41 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
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 {
+// upsertBestMove records the account's best play for a variant, keeping only the
+// highest-scoring one: a first play inserts, a later one replaces it only when it scored
+// strictly higher (the conditional DO UPDATE makes the upsert atomic under concurrent
+// finishes without a separate lock). It is a no-op when the finish carries no scoring play
+// for the account (a draw with no plays, or an exchange/pass-only game).
+func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
+ if d.bestVariant == "" || len(d.bestTiles) == 0 {
+ return nil
+ }
+ tiles, err := json.Marshal(d.bestTiles)
+ if err != nil {
+ return fmt.Errorf("marshal best move %s/%s: %w", d.accountID, d.bestVariant, err)
+ }
+ stmt := table.AccountBestMove.
+ INSERT(
+ table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
+ table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt,
+ ).
+ VALUES(d.accountID, d.bestVariant, d.bestScore, string(tiles), postgres.TimestampzT(now)).
+ ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
+ DO_UPDATE(postgres.SET(
+ table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
+ table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
+ table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
+ ).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
+ if _, err := stmt.ExecContext(ctx, tx); err != nil {
+ return fmt.Errorf("upsert best move %s/%s: %w", d.accountID, d.bestVariant, err)
+ }
+ return nil
+}
+
+// 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))).
@@ -738,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 dbf8c2c..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
@@ -194,10 +196,13 @@ type MoveResult struct {
}
// HintResult is a revealed hint and the requesting player's remaining hint
-// budget (per-seat allowance plus profile wallet) after spending one.
+// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
+// the global wallet alone, so the client can keep its live wallet authoritative and
+// re-derive the per-game allowance (HintsRemaining - WalletBalance).
type HintResult struct {
Move engine.MoveRecord
HintsRemaining int
+ WalletBalance int
}
// EvalResult previews a tentative play without committing it. Dir is the
@@ -220,6 +225,9 @@ type StateView struct {
Rack []string
BagLen int
HintsRemaining int
+ // WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
+ // it in with the per-game allowance), so the client keeps the wallet live across games.
+ WalletBalance int
}
// HistoryMove is one decoded journal row, independent of any dictionary.
diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go
index 86b1607..c7f1815 100644
--- a/backend/internal/inttest/account_test.go
+++ b/backend/internal/inttest/account_test.go
@@ -89,7 +89,8 @@ func TestGetStatsZeroForFreshAccount(t *testing.T) {
if err != nil {
t.Fatalf("get stats: %v", err)
}
- if (st != account.Stats{}) {
+ zero := st.Wins == 0 && st.Losses == 0 && st.Draws == 0 && st.MaxGamePoints == 0 && st.MaxWordPoints == 0
+ if !zero || len(st.BestMoves) != 0 {
t.Fatalf("fresh stats = %+v, want zero", st)
}
}
diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go
index e409443..0bab0c4 100644
--- a/backend/internal/inttest/game_test.go
+++ b/backend/internal/inttest/game_test.go
@@ -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{
diff --git a/backend/internal/inttest/merge_test.go b/backend/internal/inttest/merge_test.go
index 8f1f906..7c537f9 100644
--- a/backend/internal/inttest/merge_test.go
+++ b/backend/internal/inttest/merge_test.go
@@ -30,6 +30,34 @@ func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
}
}
+func setStatsCounts(t *testing.T, id uuid.UUID, moves, hints int) {
+ t.Helper()
+ if _, err := testDB.ExecContext(context.Background(),
+ `UPDATE backend.account_stats SET moves=$2, hints_used=$3 WHERE account_id=$1`, id, moves, hints); err != nil {
+ t.Fatalf("set stats counts: %v", err)
+ }
+}
+
+func setBestMove(t *testing.T, id uuid.UUID, variant string, score int) {
+ t.Helper()
+ tiles := `[{"letter":"c","value":3,"blank":false}]`
+ if _, err := testDB.ExecContext(context.Background(),
+ `INSERT INTO backend.account_best_move (account_id, variant, score, tiles) VALUES ($1,$2,$3,$4)
+ ON CONFLICT (account_id, variant) DO UPDATE SET score=$3, tiles=$4`, id, variant, score, tiles); err != nil {
+ t.Fatalf("set best move: %v", err)
+ }
+}
+
+func bestMoveCount(t *testing.T, id uuid.UUID) int {
+ t.Helper()
+ var n int
+ if err := testDB.QueryRowContext(context.Background(),
+ `SELECT count(*) FROM backend.account_best_move WHERE account_id=$1`, id).Scan(&n); err != nil {
+ t.Fatalf("best move count: %v", err)
+ }
+ return n
+}
+
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
@@ -110,8 +138,15 @@ func TestAccountMergeCore(t *testing.T) {
setStats(t, primary, 1, 0, 0, 100, 90)
setStats(t, secondary, 3, 1, 2, 400, 80)
+ setStatsCounts(t, primary, 100, 5)
+ setStatsCounts(t, secondary, 50, 8)
setWallet(t, primary, 2, false)
setWallet(t, secondary, 5, true)
+ // Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
+ // scrabble_ru (30) is new to primary and carried over.
+ setBestMove(t, primary, "scrabble_en", 50)
+ setBestMove(t, secondary, "scrabble_en", 80)
+ setBestMove(t, secondary, "scrabble_ru", 30)
email := "merge-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, secondary, email)
@@ -153,6 +188,26 @@ func TestAccountMergeCore(t *testing.T) {
if mergedInto(t, secondary) != primary {
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
}
+
+ // The moves/hints counters sum, and the per-variant best moves merge (the higher score
+ // per variant kept), with the secondary's best-move rows cleaned up.
+ st, err := store.GetStats(ctx, primary)
+ if err != nil {
+ t.Fatalf("get merged stats: %v", err)
+ }
+ if st.Moves != 150 || st.HintsUsed != 13 {
+ t.Errorf("primary moves/hints = %d/%d, want 150/13", st.Moves, st.HintsUsed)
+ }
+ best := map[string]int{}
+ for _, b := range st.BestMoves {
+ best[b.Variant] = b.Score
+ }
+ if len(st.BestMoves) != 2 || best["scrabble_en"] != 80 || best["scrabble_ru"] != 30 {
+ t.Errorf("primary best moves = %+v, want scrabble_en:80 + scrabble_ru:30", st.BestMoves)
+ }
+ if bestMoveCount(t, secondary) != 0 {
+ t.Error("secondary best-move rows should be deleted after merge")
+ }
}
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go
index d2b1914..5c03192 100644
--- a/backend/internal/notify/encode.go
+++ b/backend/internal/notify/encode.go
@@ -83,6 +83,7 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
Rack: s.Rack,
BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining,
+ WalletBalance: s.WalletBalance,
Alphabet: alphabet,
})
}
diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go
index abb4c95..18c9e27 100644
--- a/backend/internal/notify/payload.go
+++ b/backend/internal/notify/payload.go
@@ -56,6 +56,7 @@ type PlayerState struct {
Rack []int
BagLen int
HintsRemaining int
+ WalletBalance int
Alphabet []AlphabetLetter
}
diff --git a/backend/internal/postgres/jet/backend/model/account_best_move.go b/backend/internal/postgres/jet/backend/model/account_best_move.go
new file mode 100644
index 0000000..6ec4fda
--- /dev/null
+++ b/backend/internal/postgres/jet/backend/model/account_best_move.go
@@ -0,0 +1,21 @@
+//
+// Code generated by go-jet DO NOT EDIT.
+//
+// WARNING: Changes to this file may cause incorrect behavior
+// and will be lost if the code is regenerated
+//
+
+package model
+
+import (
+ "github.com/google/uuid"
+ "time"
+)
+
+type AccountBestMove struct {
+ AccountID uuid.UUID `sql:"primary_key"`
+ Variant string `sql:"primary_key"`
+ Score int32
+ Tiles string
+ UpdatedAt time.Time
+}
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_best_move.go b/backend/internal/postgres/jet/backend/table/account_best_move.go
new file mode 100644
index 0000000..5901164
--- /dev/null
+++ b/backend/internal/postgres/jet/backend/table/account_best_move.go
@@ -0,0 +1,90 @@
+//
+// Code generated by go-jet DO NOT EDIT.
+//
+// WARNING: Changes to this file may cause incorrect behavior
+// and will be lost if the code is regenerated
+//
+
+package table
+
+import (
+ "github.com/go-jet/jet/v2/postgres"
+)
+
+var AccountBestMove = newAccountBestMoveTable("backend", "account_best_move", "")
+
+type accountBestMoveTable struct {
+ postgres.Table
+
+ // Columns
+ AccountID postgres.ColumnString
+ Variant postgres.ColumnString
+ Score postgres.ColumnInteger
+ Tiles postgres.ColumnString
+ UpdatedAt postgres.ColumnTimestampz
+
+ AllColumns postgres.ColumnList
+ MutableColumns postgres.ColumnList
+ DefaultColumns postgres.ColumnList
+}
+
+type AccountBestMoveTable struct {
+ accountBestMoveTable
+
+ EXCLUDED accountBestMoveTable
+}
+
+// AS creates new AccountBestMoveTable with assigned alias
+func (a AccountBestMoveTable) AS(alias string) *AccountBestMoveTable {
+ return newAccountBestMoveTable(a.SchemaName(), a.TableName(), alias)
+}
+
+// Schema creates new AccountBestMoveTable with assigned schema name
+func (a AccountBestMoveTable) FromSchema(schemaName string) *AccountBestMoveTable {
+ return newAccountBestMoveTable(schemaName, a.TableName(), a.Alias())
+}
+
+// WithPrefix creates new AccountBestMoveTable with assigned table prefix
+func (a AccountBestMoveTable) WithPrefix(prefix string) *AccountBestMoveTable {
+ return newAccountBestMoveTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
+}
+
+// WithSuffix creates new AccountBestMoveTable with assigned table suffix
+func (a AccountBestMoveTable) WithSuffix(suffix string) *AccountBestMoveTable {
+ return newAccountBestMoveTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
+}
+
+func newAccountBestMoveTable(schemaName, tableName, alias string) *AccountBestMoveTable {
+ return &AccountBestMoveTable{
+ accountBestMoveTable: newAccountBestMoveTableImpl(schemaName, tableName, alias),
+ EXCLUDED: newAccountBestMoveTableImpl("", "excluded", ""),
+ }
+}
+
+func newAccountBestMoveTableImpl(schemaName, tableName, alias string) accountBestMoveTable {
+ var (
+ AccountIDColumn = postgres.StringColumn("account_id")
+ VariantColumn = postgres.StringColumn("variant")
+ ScoreColumn = postgres.IntegerColumn("score")
+ TilesColumn = postgres.StringColumn("tiles")
+ UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
+ allColumns = postgres.ColumnList{AccountIDColumn, VariantColumn, ScoreColumn, TilesColumn, UpdatedAtColumn}
+ mutableColumns = postgres.ColumnList{ScoreColumn, TilesColumn, UpdatedAtColumn}
+ defaultColumns = postgres.ColumnList{UpdatedAtColumn}
+ )
+
+ return accountBestMoveTable{
+ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
+
+ //Columns
+ AccountID: AccountIDColumn,
+ Variant: VariantColumn,
+ Score: ScoreColumn,
+ Tiles: TilesColumn,
+ UpdatedAt: UpdatedAtColumn,
+
+ AllColumns: allColumns,
+ MutableColumns: mutableColumns,
+ DefaultColumns: defaultColumns,
+ }
+}
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/00009_best_move.sql b/backend/internal/postgres/migrations/00009_best_move.sql
new file mode 100644
index 0000000..529a863
--- /dev/null
+++ b/backend/internal/postgres/migrations/00009_best_move.sql
@@ -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;
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/dto.go b/backend/internal/server/dto.go
index 22222e7..f02828b 100644
--- a/backend/internal/server/dto.go
+++ b/backend/internal/server/dto.go
@@ -140,6 +140,7 @@ type stateDTO struct {
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
+ WalletBalance int `json:"wallet_balance"`
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
}
@@ -291,6 +292,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
+ WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go
index 55ae838..7df096c 100644
--- a/backend/internal/server/handlers_account.go
+++ b/backend/internal/server/handlers_account.go
@@ -29,13 +29,27 @@ type updateProfileRequest struct {
}
// statsDTO is a durable account's lifetime statistics (the derived games-played and
-// win-rate are computed client-side).
+// win-rate are computed client-side). BestMoves breaks the best move down per variant,
+// carrying the word itself; it is absent for an account with no recorded play and never
+// lists a variant the account has not played.
type statsDTO struct {
- Wins int `json:"wins"`
- Losses int `json:"losses"`
- Draws int `json:"draws"`
- MaxGamePoints int `json:"max_game_points"`
- MaxWordPoints int `json:"max_word_points"`
+ Wins int `json:"wins"`
+ Losses int `json:"losses"`
+ 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"`
+}
+
+// bestMoveDTO is one variant's best play: the variant label, the play's total score and
+// its main word as ordered tiles (letter, value, blank — value 0 for a blank), so the
+// client renders it as game tiles without the variant's alphabet table.
+type bestMoveDTO struct {
+ Variant string `json:"variant"`
+ Score int `json:"score"`
+ Word []account.BestMoveTile `json:"word"`
}
// parseAwayTime parses an "HH:MM" away-window bound.
@@ -140,11 +154,21 @@ func (s *Server) handleStats(c *gin.Context) {
s.abortErr(c, err)
return
}
+ var best []bestMoveDTO
+ if len(st.BestMoves) > 0 {
+ best = make([]bestMoveDTO, len(st.BestMoves))
+ for i, b := range st.BestMoves {
+ best[i] = bestMoveDTO{Variant: b.Variant, Score: b.Score, Word: b.Tiles}
+ }
+ }
c.JSON(http.StatusOK, statsDTO{
Wins: st.Wins,
Losses: st.Losses,
Draws: st.Draws,
MaxGamePoints: st.MaxGamePoints,
MaxWordPoints: st.MaxWordPoints,
+ Moves: st.Moves,
+ HintsUsed: st.HintsUsed,
+ BestMoves: best,
})
}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 46b7e01..80a3146 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -348,7 +348,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
if view.HasStats {
if st, err := s.accounts.GetStats(ctx, id); err == nil {
- view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints}
+ view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints, Moves: st.Moves, HintsUsed: st.HintsUsed}
}
}
if ids, err := s.accounts.Identities(ctx, id); err == nil {
diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go
index f6440de..be5e539 100644
--- a/backend/internal/server/handlers_game.go
+++ b/backend/internal/server/handlers_game.go
@@ -20,6 +20,7 @@ import (
type hintResultDTO struct {
Move moveRecordDTO `json:"move"`
HintsRemaining int `json:"hints_remaining"`
+ WalletBalance int `json:"wallet_balance"`
}
// evalResultDTO is an unlimited move preview: legality, score, the words formed
@@ -185,6 +186,7 @@ func (s *Server) handleHint(c *gin.Context) {
c.JSON(http.StatusOK, hintResultDTO{
Move: moveRecordDTOFrom(h.Move),
HintsRemaining: h.HintsRemaining,
+ WalletBalance: h.WalletBalance,
})
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 001b408..2f30438 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -203,7 +203,8 @@ arrive from a platform rather than completing a mandatory registration).
guest is promoted to durable, clearing `is_guest`).
- **Merge** retires the account that owns the linked identity into the **current**
account, in a single transaction (`internal/accountmerge`): statistics summed
- (max points kept), the hint wallet summed, `paid_account` ORed, identities
+ (counters incl. moves/hints added, max points kept, and the per-variant best moves
+ merged keeping the higher-scoring play), the hint wallet summed, `paid_account` ORed, identities
repointed, games / chat / complaints transferred, friends and blocks
de-duplicated (friendships keep the strongest status accepted>pending>declined),
pending invitations/codes dropped, and the secondary kept as an **audit
@@ -347,7 +348,13 @@ Key points:
placement and leaves the commit to the player. When the rack has no legal move the
service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct
result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say
- "no options" rather than "no hints left".
+ "no options" rather than "no hints left". The hint count shown to the player is the
+ per-game allowance remaining **plus** the global wallet; because the wallet is global,
+ `game.state`/`game.hint` carry it as a separate `wallet_balance` field beside the combined
+ `hints_remaining`, so the client derives the per-game allowance (`hints_remaining -
+ wallet_balance`, which it may cache per game) and reads the wallet **live** from the
+ profile — otherwise a wallet hint spent in one game would leave a stale, too-high count
+ cached on every other game.
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
dictionary; each result offers a **complaint** (complainant, game, variant,
dict_version, word, the disputed result, an optional note) that lands in the admin
@@ -568,7 +575,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
@@ -596,8 +604,21 @@ 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
- timeout is a loss for the acting player.
+ 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
+ (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)
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index e6bd4de..01c160d 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -229,7 +229,12 @@ 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.
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
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index 025ffa9..18139ff 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -233,7 +233,12 @@ UTC), суточного окна отсутствия (away; сетка по 10
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
-ход, уже включающий все образованные им слова и бонус за все фишки).
+ход, уже включающий все образованные им слова и бонус за все фишки). Также
+показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
+**доля подсказок** (процент таких ходов, на которых бралась подсказка).
+Лучший ход также даётся **с разбивкой по вариантам игры** — **само слово**,
+нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной
+строке на каждый сыгранный вариант; варианты без ходов не выводятся.
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index 53b6976..8e428cc 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -279,9 +279,16 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖**
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.
+- **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat
+ 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;
+ 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
diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go
index 7717f20..4bff004 100644
--- a/gateway/internal/backendclient/api.go
+++ b/gateway/internal/backendclient/api.go
@@ -162,6 +162,7 @@ type StateResp struct {
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
+ WalletBalance int `json:"wallet_balance"`
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
}
@@ -331,6 +332,7 @@ func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP st
type HintResultResp struct {
Move MoveRecordResp `json:"move"`
HintsRemaining int `json:"hints_remaining"`
+ WalletBalance int `json:"wallet_balance"`
}
// EvalResultResp is an unlimited move preview. Dir is the orientation the backend
diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go
index 3d989c4..c28dadd 100644
--- a/gateway/internal/backendclient/api_social.go
+++ b/gateway/internal/backendclient/api_social.go
@@ -47,13 +47,33 @@ type BlockListResp struct {
Blocked []AccountRefResp `json:"blocked"`
}
-// StatsResp is a durable account's lifetime statistics.
+// StatsResp is a durable account's lifetime statistics. BestMoves breaks the best move
+// down per variant, carrying the word itself; it is absent for an account with no recorded
+// play and lists only variants the account has played.
type StatsResp struct {
- Wins int `json:"wins"`
- Losses int `json:"losses"`
- Draws int `json:"draws"`
- MaxGamePoints int `json:"max_game_points"`
- MaxWordPoints int `json:"max_word_points"`
+ Wins int `json:"wins"`
+ Losses int `json:"losses"`
+ 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"`
+}
+
+// BestMoveResp is one variant's best play: the variant label, the play's total score and
+// its main word as ordered tiles (letter, value, blank — value 0 for a blank).
+type BestMoveResp struct {
+ Variant string `json:"variant"`
+ Score int `json:"score"`
+ Word []BestMoveTileResp `json:"word"`
+}
+
+// BestMoveTileResp is one letter cell of a best-move word.
+type BestMoveTileResp struct {
+ Letter string `json:"letter"`
+ Value int `json:"value"`
+ Blank bool `json:"blank"`
}
// InvitationInviteeResp is one invitee's seat and response with their name.
diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go
index 3ab7f12..7b84e57 100644
--- a/gateway/internal/transcode/encode.go
+++ b/gateway/internal/transcode/encode.go
@@ -223,6 +223,7 @@ func toWireState(s backendclient.StateResp) wire.StateView {
Rack: s.Rack,
BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining,
+ WalletBalance: s.WalletBalance,
Alphabet: alphabet,
}
}
@@ -278,6 +279,7 @@ func encodeHintResult(r backendclient.HintResultResp) []byte {
fb.HintResultStart(b)
fb.HintResultAddMove(b, move)
fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining))
+ fb.HintResultAddWalletBalance(b, int32(r.WalletBalance))
b.Finish(fb.HintResultEnd(b))
return b.FinishedBytes()
}
diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go
index e9fadcc..c183ae5 100644
--- a/gateway/internal/transcode/encode_social.go
+++ b/gateway/internal/transcode/encode_social.go
@@ -91,15 +91,63 @@ func encodeRedeemResult(r backendclient.RedeemResultResp) []byte {
return b.FinishedBytes()
}
-// encodeStats builds a StatsView payload.
+// buildBestMoveTile builds a BestMoveTile table and returns its offset.
+func buildBestMoveTile(b *flatbuffers.Builder, t backendclient.BestMoveTileResp) flatbuffers.UOffsetT {
+ letter := b.CreateString(t.Letter)
+ fb.BestMoveTileStart(b)
+ fb.BestMoveTileAddLetter(b, letter)
+ fb.BestMoveTileAddValue(b, int32(t.Value))
+ fb.BestMoveTileAddBlank(b, t.Blank)
+ return fb.BestMoveTileEnd(b)
+}
+
+// buildBestMove builds a BestMoveView table — its word tile vector first, per the
+// bottom-up rule — and returns its offset.
+func buildBestMove(b *flatbuffers.Builder, m backendclient.BestMoveResp) flatbuffers.UOffsetT {
+ tiles := make([]flatbuffers.UOffsetT, len(m.Word))
+ for i, t := range m.Word {
+ tiles[i] = buildBestMoveTile(b, t)
+ }
+ fb.BestMoveViewStartWordVector(b, len(tiles))
+ for i := len(tiles) - 1; i >= 0; i-- {
+ b.PrependUOffsetT(tiles[i])
+ }
+ word := b.EndVector(len(tiles))
+ variant := b.CreateString(m.Variant)
+ fb.BestMoveViewStart(b)
+ fb.BestMoveViewAddVariant(b, variant)
+ fb.BestMoveViewAddScore(b, int32(m.Score))
+ fb.BestMoveViewAddWord(b, word)
+ return fb.BestMoveViewEnd(b)
+}
+
+// encodeStats builds a StatsView payload, embedding the per-variant best moves (each a
+// BestMoveView with its word tiles) when present.
func encodeStats(r backendclient.StatsResp) []byte {
- b := flatbuffers.NewBuilder(64)
+ b := flatbuffers.NewBuilder(256)
+ var bestMoves flatbuffers.UOffsetT
+ if len(r.BestMoves) > 0 {
+ offs := make([]flatbuffers.UOffsetT, len(r.BestMoves))
+ for i, m := range r.BestMoves {
+ offs[i] = buildBestMove(b, m)
+ }
+ fb.StatsViewStartBestMovesVector(b, len(offs))
+ for i := len(offs) - 1; i >= 0; i-- {
+ b.PrependUOffsetT(offs[i])
+ }
+ bestMoves = b.EndVector(len(offs))
+ }
fb.StatsViewStart(b)
fb.StatsViewAddWins(b, int32(r.Wins))
fb.StatsViewAddLosses(b, int32(r.Losses))
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)
+ }
b.Finish(fb.StatsViewEnd(b))
return b.FinishedBytes()
}
diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go
index a31e9ba..b0892e1 100644
--- a/gateway/internal/transcode/transcode_social_test.go
+++ b/gateway/internal/transcode/transcode_social_test.go
@@ -194,7 +194,12 @@ func TestStatsRoundTrip(t *testing.T) {
if r.URL.Path != "/api/v1/user/stats" {
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}`))
+ _, _ = 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},` +
+ `{"letter":"t","value":1,"blank":false}]}]}`))
})
defer cleanup()
@@ -208,6 +213,23 @@ 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())
+ }
+ var bm fb.BestMoveView
+ st.BestMoves(&bm, 0)
+ if string(bm.Variant()) != "scrabble_en" || bm.Score() != 90 || bm.WordLength() != 3 {
+ t.Fatalf("best move decoded wrong: variant=%q score=%d wordLen=%d", bm.Variant(), bm.Score(), bm.WordLength())
+ }
+ // The middle tile is a blank: it carries its designated letter but scores 0.
+ var tile fb.BestMoveTile
+ bm.Word(&tile, 1)
+ if string(tile.Letter()) != "a" || tile.Value() != 0 || !tile.Blank() {
+ t.Fatalf("blank tile decoded wrong: letter=%q value=%d blank=%v", tile.Letter(), tile.Value(), tile.Blank())
+ }
}
func TestGcgRoundTrip(t *testing.T) {
diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go
index 9b9e20d..cdd2482 100644
--- a/gateway/internal/transcode/transcode_test.go
+++ b/gateway/internal/transcode/transcode_test.go
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
if r.URL.Path != "/api/v1/user/games/g-1/state" {
t.Errorf("unexpected path %q", r.URL.Path)
}
- _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":1}`))
+ _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`))
})
defer cleanup()
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
t.Fatalf("handler: %v", err)
}
st := fb.GetRootAsStateView(payload, 0)
- if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 1 {
- t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining())
+ if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
+ t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance())
}
game := st.Game(nil)
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
@@ -317,7 +317,7 @@ func TestHintRoundTrip(t *testing.T) {
if r.URL.Path != "/api/v1/user/games/g-3/hint" {
t.Errorf("unexpected path %q", r.URL.Path)
}
- _, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"hints_remaining":2}`))
+ _, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"hints_remaining":2,"wallet_balance":1}`))
})
defer cleanup()
@@ -328,8 +328,8 @@ func TestHintRoundTrip(t *testing.T) {
t.Fatalf("handler: %v", err)
}
hr := fb.GetRootAsHintResult(payload, 0)
- if hr.HintsRemaining() != 2 {
- t.Errorf("hints remaining = %d, want 2", hr.HintsRemaining())
+ if hr.HintsRemaining() != 2 || hr.WalletBalance() != 1 {
+ t.Errorf("hint decoded wrong: hints=%d wallet=%d", hr.HintsRemaining(), hr.WalletBalance())
}
var move fb.MoveRecord
hr.Move(&move)
diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs
index bd9e440..19b0107 100644
--- a/pkg/fbs/scrabble.fbs
+++ b/pkg/fbs/scrabble.fbs
@@ -240,6 +240,12 @@ table StateView {
bag_len:int;
hints_remaining:int;
alphabet:[AlphabetEntry];
+ // wallet_balance is the requesting player's global hint-wallet balance, sent apart from
+ // hints_remaining (which folds the wallet in with the per-game allowance) so the client can
+ // separate the two: the per-game allowance remaining is hints_remaining - wallet_balance, and
+ // the wallet is a single global figure the client keeps live across games (added trailing —
+ // backward-compatible).
+ wallet_balance:int;
}
// GameActionRequest carries just a game id (pass / resign / hint / history).
@@ -290,10 +296,14 @@ table ComplaintRequest {
note:string;
}
-// HintResult is the top-ranked move plus the remaining hint budget.
+// HintResult is the top-ranked move plus the remaining hint budget. wallet_balance is the
+// global hint-wallet balance after spending (see StateView.wallet_balance), so the client
+// refreshes its live wallet and re-derives the per-game allowance (added trailing —
+// backward-compatible).
table HintResult {
move:MoveRecord;
hints_remaining:int;
+ wallet_balance:int;
}
// DraftRequest saves the player's client-side composition for a game: a single
@@ -458,14 +468,39 @@ table LinkResult {
session:Session;
}
+// BestMoveTile is one letter cell of a best-move word: its concrete letter (the designated
+// letter for a blank), its tile point value (0 for a blank) and whether it is a blank. It
+// lets the client render the word as game tiles without the variant's alphabet table.
+table BestMoveTile {
+ letter:string;
+ value:int;
+ blank:bool;
+}
+
+// BestMoveView is an account's highest-scoring single play within one game variant: the
+// variant label, the play's total score (matching max_word_points for that variant) and its
+// main word as ordered tiles.
+table BestMoveView {
+ variant:string;
+ score:int;
+ word:[BestMoveTile];
+}
+
// StatsView is a durable account's lifetime statistics (games-played and win-rate
-// are derived client-side).
+// are derived client-side). best_moves breaks the best move down per variant, carrying the
+// word itself; it is empty for an account with no recorded play and lists only variants the
+// account has played (added trailing — backward-compatible).
table StatsView {
wins:int;
losses:int;
draws:int;
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/BestMoveTile.go b/pkg/fbs/scrabblefb/BestMoveTile.go
new file mode 100644
index 0000000..8cb943c
--- /dev/null
+++ b/pkg/fbs/scrabblefb/BestMoveTile.go
@@ -0,0 +1,90 @@
+// Code generated by the FlatBuffers compiler. DO NOT EDIT.
+
+package scrabblefb
+
+import (
+ flatbuffers "github.com/google/flatbuffers/go"
+)
+
+type BestMoveTile struct {
+ _tab flatbuffers.Table
+}
+
+func GetRootAsBestMoveTile(buf []byte, offset flatbuffers.UOffsetT) *BestMoveTile {
+ n := flatbuffers.GetUOffsetT(buf[offset:])
+ x := &BestMoveTile{}
+ x.Init(buf, n+offset)
+ return x
+}
+
+func FinishBestMoveTileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.Finish(offset)
+}
+
+func GetSizePrefixedRootAsBestMoveTile(buf []byte, offset flatbuffers.UOffsetT) *BestMoveTile {
+ n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
+ x := &BestMoveTile{}
+ x.Init(buf, n+offset+flatbuffers.SizeUint32)
+ return x
+}
+
+func FinishSizePrefixedBestMoveTileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.FinishSizePrefixed(offset)
+}
+
+func (rcv *BestMoveTile) Init(buf []byte, i flatbuffers.UOffsetT) {
+ rcv._tab.Bytes = buf
+ rcv._tab.Pos = i
+}
+
+func (rcv *BestMoveTile) Table() flatbuffers.Table {
+ return rcv._tab
+}
+
+func (rcv *BestMoveTile) Letter() []byte {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
+ if o != 0 {
+ return rcv._tab.ByteVector(o + rcv._tab.Pos)
+ }
+ return nil
+}
+
+func (rcv *BestMoveTile) Value() int32 {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
+ if o != 0 {
+ return rcv._tab.GetInt32(o + rcv._tab.Pos)
+ }
+ return 0
+}
+
+func (rcv *BestMoveTile) MutateValue(n int32) bool {
+ return rcv._tab.MutateInt32Slot(6, n)
+}
+
+func (rcv *BestMoveTile) Blank() bool {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
+ if o != 0 {
+ return rcv._tab.GetBool(o + rcv._tab.Pos)
+ }
+ return false
+}
+
+func (rcv *BestMoveTile) MutateBlank(n bool) bool {
+ return rcv._tab.MutateBoolSlot(8, n)
+}
+
+func BestMoveTileStart(builder *flatbuffers.Builder) {
+ builder.StartObject(3)
+}
+func BestMoveTileAddLetter(builder *flatbuffers.Builder, letter flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(letter), 0)
+}
+func BestMoveTileAddValue(builder *flatbuffers.Builder, value int32) {
+ builder.PrependInt32Slot(1, value, 0)
+}
+func BestMoveTileAddBlank(builder *flatbuffers.Builder, blank bool) {
+ builder.PrependBoolSlot(2, blank, false)
+}
+func BestMoveTileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
+ return builder.EndObject()
+}
diff --git a/pkg/fbs/scrabblefb/BestMoveView.go b/pkg/fbs/scrabblefb/BestMoveView.go
new file mode 100644
index 0000000..fa7b49c
--- /dev/null
+++ b/pkg/fbs/scrabblefb/BestMoveView.go
@@ -0,0 +1,101 @@
+// Code generated by the FlatBuffers compiler. DO NOT EDIT.
+
+package scrabblefb
+
+import (
+ flatbuffers "github.com/google/flatbuffers/go"
+)
+
+type BestMoveView struct {
+ _tab flatbuffers.Table
+}
+
+func GetRootAsBestMoveView(buf []byte, offset flatbuffers.UOffsetT) *BestMoveView {
+ n := flatbuffers.GetUOffsetT(buf[offset:])
+ x := &BestMoveView{}
+ x.Init(buf, n+offset)
+ return x
+}
+
+func FinishBestMoveViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.Finish(offset)
+}
+
+func GetSizePrefixedRootAsBestMoveView(buf []byte, offset flatbuffers.UOffsetT) *BestMoveView {
+ n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
+ x := &BestMoveView{}
+ x.Init(buf, n+offset+flatbuffers.SizeUint32)
+ return x
+}
+
+func FinishSizePrefixedBestMoveViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.FinishSizePrefixed(offset)
+}
+
+func (rcv *BestMoveView) Init(buf []byte, i flatbuffers.UOffsetT) {
+ rcv._tab.Bytes = buf
+ rcv._tab.Pos = i
+}
+
+func (rcv *BestMoveView) Table() flatbuffers.Table {
+ return rcv._tab
+}
+
+func (rcv *BestMoveView) Variant() []byte {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
+ if o != 0 {
+ return rcv._tab.ByteVector(o + rcv._tab.Pos)
+ }
+ return nil
+}
+
+func (rcv *BestMoveView) Score() int32 {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
+ if o != 0 {
+ return rcv._tab.GetInt32(o + rcv._tab.Pos)
+ }
+ return 0
+}
+
+func (rcv *BestMoveView) MutateScore(n int32) bool {
+ return rcv._tab.MutateInt32Slot(6, n)
+}
+
+func (rcv *BestMoveView) Word(obj *BestMoveTile, j int) bool {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
+ if o != 0 {
+ x := rcv._tab.Vector(o)
+ x += flatbuffers.UOffsetT(j) * 4
+ x = rcv._tab.Indirect(x)
+ obj.Init(rcv._tab.Bytes, x)
+ return true
+ }
+ return false
+}
+
+func (rcv *BestMoveView) WordLength() int {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
+ if o != 0 {
+ return rcv._tab.VectorLen(o)
+ }
+ return 0
+}
+
+func BestMoveViewStart(builder *flatbuffers.Builder) {
+ builder.StartObject(3)
+}
+func BestMoveViewAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
+}
+func BestMoveViewAddScore(builder *flatbuffers.Builder, score int32) {
+ builder.PrependInt32Slot(1, score, 0)
+}
+func BestMoveViewAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(word), 0)
+}
+func BestMoveViewStartWordVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
+ return builder.StartVector(4, numElems, 4)
+}
+func BestMoveViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
+ return builder.EndObject()
+}
diff --git a/pkg/fbs/scrabblefb/HintResult.go b/pkg/fbs/scrabblefb/HintResult.go
index bad7b75..8d7ba4a 100644
--- a/pkg/fbs/scrabblefb/HintResult.go
+++ b/pkg/fbs/scrabblefb/HintResult.go
@@ -66,8 +66,20 @@ func (rcv *HintResult) MutateHintsRemaining(n int32) bool {
return rcv._tab.MutateInt32Slot(6, n)
}
+func (rcv *HintResult) WalletBalance() int32 {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
+ if o != 0 {
+ return rcv._tab.GetInt32(o + rcv._tab.Pos)
+ }
+ return 0
+}
+
+func (rcv *HintResult) MutateWalletBalance(n int32) bool {
+ return rcv._tab.MutateInt32Slot(8, n)
+}
+
func HintResultStart(builder *flatbuffers.Builder) {
- builder.StartObject(2)
+ builder.StartObject(3)
}
func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0)
@@ -75,6 +87,9 @@ func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT)
func HintResultAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) {
builder.PrependInt32Slot(1, hintsRemaining, 0)
}
+func HintResultAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
+ builder.PrependInt32Slot(2, walletBalance, 0)
+}
func HintResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go
index 2c0138f..9ae7566 100644
--- a/pkg/fbs/scrabblefb/StateView.go
+++ b/pkg/fbs/scrabblefb/StateView.go
@@ -144,8 +144,20 @@ func (rcv *StateView) AlphabetLength() int {
return 0
}
+func (rcv *StateView) WalletBalance() int32 {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
+ if o != 0 {
+ return rcv._tab.GetInt32(o + rcv._tab.Pos)
+ }
+ return 0
+}
+
+func (rcv *StateView) MutateWalletBalance(n int32) bool {
+ return rcv._tab.MutateInt32Slot(16, n)
+}
+
func StateViewStart(builder *flatbuffers.Builder) {
- builder.StartObject(6)
+ builder.StartObject(7)
}
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
@@ -171,6 +183,9 @@ func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOf
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
+func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
+ builder.PrependInt32Slot(6, walletBalance, 0)
+}
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
diff --git a/pkg/fbs/scrabblefb/StatsView.go b/pkg/fbs/scrabblefb/StatsView.go
index 36748a4..3479d36 100644
--- a/pkg/fbs/scrabblefb/StatsView.go
+++ b/pkg/fbs/scrabblefb/StatsView.go
@@ -101,8 +101,52 @@ func (rcv *StatsView) MutateMaxWordPoints(n int32) bool {
return rcv._tab.MutateInt32Slot(12, n)
}
+func (rcv *StatsView) BestMoves(obj *BestMoveView, j int) bool {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
+ if o != 0 {
+ x := rcv._tab.Vector(o)
+ x += flatbuffers.UOffsetT(j) * 4
+ x = rcv._tab.Indirect(x)
+ obj.Init(rcv._tab.Bytes, x)
+ return true
+ }
+ return false
+}
+
+func (rcv *StatsView) BestMovesLength() int {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
+ if o != 0 {
+ return rcv._tab.VectorLen(o)
+ }
+ 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(5)
+ builder.StartObject(8)
}
func StatsViewAddWins(builder *flatbuffers.Builder, wins int32) {
builder.PrependInt32Slot(0, wins, 0)
@@ -119,6 +163,18 @@ func StatsViewAddMaxGamePoints(builder *flatbuffers.Builder, maxGamePoints int32
func StatsViewAddMaxWordPoints(builder *flatbuffers.Builder, maxWordPoints int32) {
builder.PrependInt32Slot(4, maxWordPoints, 0)
}
+func StatsViewAddBestMoves(builder *flatbuffers.Builder, bestMoves flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(bestMoves), 0)
+}
+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/pkg/wire/build.go b/pkg/wire/build.go
index 1a47b55..56dba8e 100644
--- a/pkg/wire/build.go
+++ b/pkg/wire/build.go
@@ -89,6 +89,7 @@ type StateView struct {
Rack []int
BagLen int
HintsRemaining int
+ WalletBalance int
Alphabet []AlphabetEntry
}
@@ -250,6 +251,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
fb.StateViewAddRack(b, rack)
fb.StateViewAddBagLen(b, int32(s.BagLen))
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
+ fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
if hasAlphabet {
fb.StateViewAddAlphabet(b, alphabet)
}
diff --git a/platform/telegram/internal/render/render.go b/platform/telegram/internal/render/render.go
index 2ac2800..e622aee 100644
--- a/platform/telegram/internal/render/render.go
+++ b/platform/telegram/internal/render/render.go
@@ -126,9 +126,9 @@ var english = phrases{
yourTurnExchange: "%s: swapping tiles, your turn.",
yourTurnPass: "%s: passing, your turn.",
yourTurnMoved: "%s moved, your turn.",
- gameOverWon: "Game over — you won! Score %s",
- gameOverLost: "Game over — you lost. Score %s",
- gameOverDraw: "Game over — a draw. Score %s",
+ gameOverWon: "Game over - 🏆 you won! Score %s",
+ gameOverLost: "Game over - you lost. Score %s",
+ gameOverDraw: "Game over - a draw. Score %s",
nudge: "You were nudged — it's your turn.",
nudgeBy: "%s: Waiting for your move 🤭",
matchFound: "Your game is ready.",
@@ -144,8 +144,8 @@ var russian = phrases{
yourTurnExchange: "%s: меняю фишки, ваш ход.",
yourTurnPass: "%s: пропускаю ход, ваш ход.",
yourTurnMoved: "%s сходил(а), ваш ход.",
- gameOverWon: "Игра окончена — вы выиграли! Счёт %s",
- gameOverLost: "Игра окончена — вы проиграли. Счёт %s",
+ gameOverWon: "Игра окончена — 🏆 Вы выиграли! Счёт %s",
+ gameOverLost: "Игра окончена — Вы проиграли. Счёт %s",
gameOverDraw: "Игра окончена — ничья. Счёт %s",
nudge: "Вас поторопили — ваш ход.",
nudgeBy: "%s: Жду Вашего хода 🤭",
diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts
index 750e559..3f3a4de 100644
--- a/ui/e2e/game.spec.ts
+++ b/ui/e2e/game.spec.ts
@@ -66,7 +66,7 @@ test('a placed tile is saved as a draft and restored on reopening the game', asy
test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
- await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
+ await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar -> auto-match
await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary
await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
});
@@ -74,7 +74,7 @@ test('new game: variant buttons show a rules summary and the move-limit', async
test('new game: auto-match shows the off-by-default rule toggle from the start (no layout jump on selection)', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
- await page.getByRole('button', { name: /New/ }).click(); // auto-match
+ await page.getByRole('button', { name: /🎲/ }).click(); // auto-match
// Several variants are offered, so nothing is selected: Start is disabled. The rule toggle is shown
// from the start (a Russian variant is available), so selecting one does not shift the layout.
const start = page.getByRole('button', { name: /Start game/i });
diff --git a/ui/e2e/gamelimit.spec.ts b/ui/e2e/gamelimit.spec.ts
index 92e7dd7..b316a0f 100644
--- a/ui/e2e/gamelimit.spec.ts
+++ b/ui/e2e/gamelimit.spec.ts
@@ -11,7 +11,7 @@ test('lobby: New Game disables and a notice shows at the simultaneous-game limit
await page.getByRole('button', { name: /guest/i }).click();
// Below the limit: the New Game tab is enabled and no notice is shown.
- const newGame = page.getByRole('button', { name: /New/ });
+ const newGame = page.getByRole('button', { name: /🎲/ });
await expect(newGame).toBeEnabled();
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts
index 2a2318d..59d9a3a 100644
--- a/ui/e2e/quickmatch.spec.ts
+++ b/ui/e2e/quickmatch.spec.ts
@@ -8,7 +8,7 @@ import { expect, test } from './fixtures';
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
- await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
+ await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar -> auto-match
// Choose a random human opponent (the default is the AI); pick a variant and start. The player
// lands in an open game at once (no "searching" screen).
@@ -38,7 +38,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
- await page.getByRole('button', { name: /New/ }).click();
+ await page.getByRole('button', { name: /🎲/ }).click();
// AI is the default opponent: the move-clock line is replaced by the 7-day inactivity rule.
await expect(page.getByText(/Loss after 7 days of inactivity/)).toBeVisible();
@@ -64,7 +64,7 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
test('AI game: no GCG export offered after it ends', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
- await page.getByRole('button', { name: /New/ }).click();
+ await page.getByRole('button', { name: /🎲/ }).click();
await page.locator('.variant').first().click(); // AI is the default opponent
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
@@ -90,7 +90,7 @@ test('AI game: no GCG export offered after it ends', async ({ page }) => {
async function enterOpenGame(page: import('@playwright/test').Page): Promise {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
- await page.getByRole('button', { name: /New/ }).click();
+ await page.getByRole('button', { name: /🎲/ }).click();
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts
index 64185ea..db219a2 100644
--- a/ui/e2e/social.spec.ts
+++ b/ui/e2e/social.spec.ts
@@ -53,11 +53,19 @@ test('invitations: the lobby shows an invitation and accepting clears it', async
await expect(page.getByText(/From Kaya/)).toBeHidden();
});
-test('stats screen shows the metrics', async ({ page }) => {
+test('stats screen shows the metrics and the per-variant best move', async ({ page }) => {
await loginLobby(page);
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();
+ await expect(page.getByText('134')).toBeVisible();
});
test('settings hub: tabs switch in place and back returns to the lobby', async ({ page }) => {
@@ -158,7 +166,7 @@ test('lobby ⚙️ tab shows the pending friend-request count', async ({ page })
test('play with friends: a game type is required to send an invitation', async ({ page }) => {
await loginLobby(page);
- await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar
+ await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar (New Game tab, by its 🎲 icon — the label is themeable)
await page.getByRole('button', { name: 'Play with friends' }).click();
const send = page.getByRole('button', { name: 'Send invitation' });
diff --git a/ui/src/components/WordTiles.svelte b/ui/src/components/WordTiles.svelte
new file mode 100644
index 0000000..1a70ff4
--- /dev/null
+++ b/ui/src/components/WordTiles.svelte
@@ -0,0 +1,53 @@
+
+
+
+ {#each word as tile, i (i)}
+
+ {tile.letter.toUpperCase()}
+ {#if !tile.blank}{tile.value}{/if}
+
+ {/each}
+
+
+
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index b098ea3..75edd9c 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -18,6 +18,7 @@
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
+ import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
@@ -132,6 +133,10 @@
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished');
+ // The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
+ // wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
+ // hint was spent in another game (see lib/hints).
+ const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
@@ -154,6 +159,13 @@
return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action;
}
+ // syncWallet adopts the server's authoritative hint-wallet balance into the global profile.
+ // The wallet is global, so keeping it live here (rather than per-game) is what stops the hint
+ // badge from going stale when a wallet hint was spent in another game.
+ function syncWallet(walletBalance: number) {
+ if (app.profile) app.profile.hintBalance = walletBalance;
+ }
+
async function load() {
try {
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
@@ -167,6 +179,7 @@
gateway.draftGet(id).catch(() => ''),
]);
view = st;
+ syncWallet(st.walletBalance);
// Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat);
moves = hist.moves;
@@ -615,7 +628,16 @@
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
// post-move game and the refilled rack — without a follow-up game.state + game.history.
function applyMoveResult(r: MoveResult) {
- view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
+ view = {
+ game: r.game,
+ seat: r.move.player,
+ rack: r.rack,
+ bagLen: r.bagLen,
+ // A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both
+ // forward (their difference is the stable allowance; the badge adds the live wallet).
+ hintsRemaining: view?.hintsRemaining ?? 0,
+ walletBalance: view?.walletBalance ?? 0,
+ };
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
// moving is already cleared server-side, so reconcile the unread flag from it.
seedChatUnread(id, r.game.unreadChat);
@@ -698,7 +720,8 @@
recenter++;
}
if (isCoarse()) zoomed = true;
- view = { ...view, hintsRemaining: h.hintsRemaining };
+ view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
+ syncWallet(h.walletBalance);
recompute();
}
} catch (e) {
@@ -1067,7 +1090,7 @@
{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}
{#if gameOver}
- {t('game.over')} — {resultText()}
+ {resultText()}
{:else if placement.pending.length === 0}
{isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if}
@@ -1107,10 +1130,10 @@
- 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if}
+ 🛟{#if hintCount > 0}{hintCount}{/if}{t('game.hint')}
{#if placement.pending.length > 0}
diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts
index 414ac35..25f0ac7 100644
--- a/ui/src/gen/fbs/scrabblefb.ts
+++ b/ui/src/gen/fbs/scrabblefb.ts
@@ -5,6 +5,8 @@ export { Ack } from './scrabblefb/ack.js';
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
export { BannerCampaign } from './scrabblefb/banner-campaign.js';
export { BannerInfo } from './scrabblefb/banner-info.js';
+export { BestMoveTile } from './scrabblefb/best-move-tile.js';
+export { BestMoveView } from './scrabblefb/best-move-view.js';
export { BlockList } from './scrabblefb/block-list.js';
export { BlockStatus } from './scrabblefb/block-status.js';
export { ChatList } from './scrabblefb/chat-list.js';
diff --git a/ui/src/gen/fbs/scrabblefb/best-move-tile.ts b/ui/src/gen/fbs/scrabblefb/best-move-tile.ts
new file mode 100644
index 0000000..1e9b8f0
--- /dev/null
+++ b/ui/src/gen/fbs/scrabblefb/best-move-tile.ts
@@ -0,0 +1,68 @@
+// automatically generated by the FlatBuffers compiler, do not modify
+
+import * as flatbuffers from 'flatbuffers';
+
+export class BestMoveTile {
+ bb: flatbuffers.ByteBuffer|null = null;
+ bb_pos = 0;
+ __init(i:number, bb:flatbuffers.ByteBuffer):BestMoveTile {
+ this.bb_pos = i;
+ this.bb = bb;
+ return this;
+}
+
+static getRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile {
+ return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+static getSizePrefixedRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile {
+ bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
+ return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+letter():string|null
+letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+letter(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 4);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
+value():number {
+ const offset = this.bb!.__offset(this.bb_pos, 6);
+ return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
+}
+
+blank():boolean {
+ const offset = this.bb!.__offset(this.bb_pos, 8);
+ return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
+}
+
+static startBestMoveTile(builder:flatbuffers.Builder) {
+ builder.startObject(3);
+}
+
+static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(0, letterOffset, 0);
+}
+
+static addValue(builder:flatbuffers.Builder, value:number) {
+ builder.addFieldInt32(1, value, 0);
+}
+
+static addBlank(builder:flatbuffers.Builder, blank:boolean) {
+ builder.addFieldInt8(2, +blank, +false);
+}
+
+static endBestMoveTile(builder:flatbuffers.Builder):flatbuffers.Offset {
+ const offset = builder.endObject();
+ return offset;
+}
+
+static createBestMoveTile(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset, value:number, blank:boolean):flatbuffers.Offset {
+ BestMoveTile.startBestMoveTile(builder);
+ BestMoveTile.addLetter(builder, letterOffset);
+ BestMoveTile.addValue(builder, value);
+ BestMoveTile.addBlank(builder, blank);
+ return BestMoveTile.endBestMoveTile(builder);
+}
+}
diff --git a/ui/src/gen/fbs/scrabblefb/best-move-view.ts b/ui/src/gen/fbs/scrabblefb/best-move-view.ts
new file mode 100644
index 0000000..800a5db
--- /dev/null
+++ b/ui/src/gen/fbs/scrabblefb/best-move-view.ts
@@ -0,0 +1,88 @@
+// automatically generated by the FlatBuffers compiler, do not modify
+
+import * as flatbuffers from 'flatbuffers';
+
+import { BestMoveTile } from '../scrabblefb/best-move-tile.js';
+
+
+export class BestMoveView {
+ bb: flatbuffers.ByteBuffer|null = null;
+ bb_pos = 0;
+ __init(i:number, bb:flatbuffers.ByteBuffer):BestMoveView {
+ this.bb_pos = i;
+ this.bb = bb;
+ return this;
+}
+
+static getRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView {
+ return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+static getSizePrefixedRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView {
+ bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
+ return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+variant():string|null
+variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+variant(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 4);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
+score():number {
+ const offset = this.bb!.__offset(this.bb_pos, 6);
+ return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
+}
+
+word(index: number, obj?:BestMoveTile):BestMoveTile|null {
+ const offset = this.bb!.__offset(this.bb_pos, 8);
+ return offset ? (obj || new BestMoveTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
+}
+
+wordLength():number {
+ const offset = this.bb!.__offset(this.bb_pos, 8);
+ return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
+}
+
+static startBestMoveView(builder:flatbuffers.Builder) {
+ builder.startObject(3);
+}
+
+static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(0, variantOffset, 0);
+}
+
+static addScore(builder:flatbuffers.Builder, score:number) {
+ builder.addFieldInt32(1, score, 0);
+}
+
+static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(2, wordOffset, 0);
+}
+
+static createWordVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
+ builder.startVector(4, data.length, 4);
+ for (let i = data.length - 1; i >= 0; i--) {
+ builder.addOffset(data[i]!);
+ }
+ return builder.endVector();
+}
+
+static startWordVector(builder:flatbuffers.Builder, numElems:number) {
+ builder.startVector(4, numElems, 4);
+}
+
+static endBestMoveView(builder:flatbuffers.Builder):flatbuffers.Offset {
+ const offset = builder.endObject();
+ return offset;
+}
+
+static createBestMoveView(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, score:number, wordOffset:flatbuffers.Offset):flatbuffers.Offset {
+ BestMoveView.startBestMoveView(builder);
+ BestMoveView.addVariant(builder, variantOffset);
+ BestMoveView.addScore(builder, score);
+ BestMoveView.addWord(builder, wordOffset);
+ return BestMoveView.endBestMoveView(builder);
+}
+}
diff --git a/ui/src/gen/fbs/scrabblefb/hint-result.ts b/ui/src/gen/fbs/scrabblefb/hint-result.ts
index 511412f..7a6a5f9 100644
--- a/ui/src/gen/fbs/scrabblefb/hint-result.ts
+++ b/ui/src/gen/fbs/scrabblefb/hint-result.ts
@@ -33,8 +33,13 @@ hintsRemaining():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
+walletBalance():number {
+ const offset = this.bb!.__offset(this.bb_pos, 8);
+ return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
+}
+
static startHintResult(builder:flatbuffers.Builder) {
- builder.startObject(2);
+ builder.startObject(3);
}
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
@@ -45,15 +50,20 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
builder.addFieldInt32(1, hintsRemaining, 0);
}
+static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
+ builder.addFieldInt32(2, walletBalance, 0);
+}
+
static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
-static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number):flatbuffers.Offset {
+static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number, walletBalance:number):flatbuffers.Offset {
HintResult.startHintResult(builder);
HintResult.addMove(builder, moveOffset);
HintResult.addHintsRemaining(builder, hintsRemaining);
+ HintResult.addWalletBalance(builder, walletBalance);
return HintResult.endHintResult(builder);
}
}
diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts
index 690e12a..f7a1ac8 100644
--- a/ui/src/gen/fbs/scrabblefb/state-view.ts
+++ b/ui/src/gen/fbs/scrabblefb/state-view.ts
@@ -69,8 +69,13 @@ alphabetLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
+walletBalance():number {
+ const offset = this.bb!.__offset(this.bb_pos, 16);
+ return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
+}
+
static startStateView(builder:flatbuffers.Builder) {
- builder.startObject(6);
+ builder.startObject(7);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
@@ -121,12 +126,16 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
+static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
+ builder.addFieldInt32(6, walletBalance, 0);
+}
+
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
-static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset):flatbuffers.Offset {
+static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset {
StateView.startStateView(builder);
StateView.addGame(builder, gameOffset);
StateView.addSeat(builder, seat);
@@ -134,6 +143,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
StateView.addBagLen(builder, bagLen);
StateView.addHintsRemaining(builder, hintsRemaining);
StateView.addAlphabet(builder, alphabetOffset);
+ StateView.addWalletBalance(builder, walletBalance);
return StateView.endStateView(builder);
}
}
diff --git a/ui/src/gen/fbs/scrabblefb/stats-view.ts b/ui/src/gen/fbs/scrabblefb/stats-view.ts
index dc49989..56a22b9 100644
--- a/ui/src/gen/fbs/scrabblefb/stats-view.ts
+++ b/ui/src/gen/fbs/scrabblefb/stats-view.ts
@@ -2,6 +2,9 @@
import * as flatbuffers from 'flatbuffers';
+import { BestMoveView } from '../scrabblefb/best-move-view.js';
+
+
export class StatsView {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
@@ -45,8 +48,28 @@ maxWordPoints():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
+bestMoves(index: number, obj?:BestMoveView):BestMoveView|null {
+ const offset = this.bb!.__offset(this.bb_pos, 14);
+ return offset ? (obj || new BestMoveView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
+}
+
+bestMovesLength():number {
+ const offset = this.bb!.__offset(this.bb_pos, 14);
+ 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(5);
+ builder.startObject(8);
}
static addWins(builder:flatbuffers.Builder, wins:number) {
@@ -69,18 +92,45 @@ static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) {
builder.addFieldInt32(4, maxWordPoints, 0);
}
+static addBestMoves(builder:flatbuffers.Builder, bestMovesOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(5, bestMovesOffset, 0);
+}
+
+static createBestMovesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
+ builder.startVector(4, data.length, 4);
+ for (let i = data.length - 1; i >= 0; i--) {
+ builder.addOffset(data[i]!);
+ }
+ return builder.endVector();
+}
+
+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):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);
StatsView.addDraws(builder, draws);
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 e05b7b9..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,57 @@ describe('codec', () => {
draws: 1,
maxGamePoints: 420,
maxWordPoints: 90,
+ moves: 248,
+ hintsUsed: 12,
+ bestMoves: [],
+ });
+ });
+
+ it('decodes a StatsView carrying a per-variant best move with a blank tile', () => {
+ const b = new Builder(256);
+ // Word "ca" where the 'a' is a blank: it carries its letter but scores 0.
+ const cLetter = b.createString('c');
+ fb.BestMoveTile.startBestMoveTile(b);
+ fb.BestMoveTile.addLetter(b, cLetter);
+ fb.BestMoveTile.addValue(b, 3);
+ fb.BestMoveTile.addBlank(b, false);
+ const tileC = fb.BestMoveTile.endBestMoveTile(b);
+ const aLetter = b.createString('a');
+ fb.BestMoveTile.startBestMoveTile(b);
+ fb.BestMoveTile.addLetter(b, aLetter);
+ fb.BestMoveTile.addValue(b, 0);
+ fb.BestMoveTile.addBlank(b, true);
+ const tileA = fb.BestMoveTile.endBestMoveTile(b);
+ const word = fb.BestMoveView.createWordVector(b, [tileC, tileA]);
+ const variant = b.createString('scrabble_en');
+ fb.BestMoveView.startBestMoveView(b);
+ fb.BestMoveView.addVariant(b, variant);
+ fb.BestMoveView.addScore(b, 90);
+ fb.BestMoveView.addWord(b, word);
+ const bm = fb.BestMoveView.endBestMoveView(b);
+ const bestMoves = fb.StatsView.createBestMovesVector(b, [bm]);
+ fb.StatsView.startStatsView(b);
+ fb.StatsView.addWins(b, 7);
+ fb.StatsView.addBestMoves(b, bestMoves);
+ b.finish(fb.StatsView.endStatsView(b));
+ expect(decodeStats(b.asUint8Array())).toEqual({
+ wins: 7,
+ losses: 0,
+ draws: 0,
+ maxGamePoints: 0,
+ maxWordPoints: 0,
+ moves: 0,
+ hintsUsed: 0,
+ bestMoves: [
+ {
+ variant: 'scrabble_en',
+ score: 90,
+ word: [
+ { letter: 'c', value: 3, blank: false },
+ { letter: 'a', value: 0, blank: true },
+ ],
+ },
+ ],
});
});
diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts
index 3184f92..f155747 100644
--- a/ui/src/lib/codec.ts
+++ b/ui/src/lib/codec.ts
@@ -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,
};
}
diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts
index d3b0715..ef3344f 100644
--- a/ui/src/lib/gamecache.test.ts
+++ b/ui/src/lib/gamecache.test.ts
@@ -22,7 +22,7 @@ function gameView(id: string): GameView {
}
function view(id: string, rack: string[] = ['A', 'B']): StateView {
- return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 };
+ return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
}
function move(player: number): MoveRecord {
diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts
index 6881605..2f25759 100644
--- a/ui/src/lib/gamedelta.test.ts
+++ b/ui/src/lib/gamedelta.test.ts
@@ -27,7 +27,7 @@ function move(player: number): MoveRecord {
}
function cache(moveCount: number, seat = 0, over = false): CachedGame {
- const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
+ const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
return { view, moves: [] };
}
@@ -37,7 +37,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
describe('seedInitialState', () => {
it('wraps an initial view with an empty journal', () => {
- const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
+ const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 };
expect(seedInitialState(view)).toEqual({ view, moves: [] });
});
});
@@ -152,12 +152,12 @@ describe('applyOpponentJoined', () => {
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
] };
- return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
+ return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0, walletBalance: 0 };
}
it("adopts the joined seats and status while preserving the cached rack and moves", () => {
// The cached open game is still "searching": empty seats, status open, the starter's own rack.
- const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
+ const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }, moves: [move(0)] };
const res = applyOpponentJoined(cached, joinedState());
expect(res?.view.game.status).toBe('active');
expect(res?.view.game.seats).toHaveLength(2);
diff --git a/ui/src/lib/hints.test.ts b/ui/src/lib/hints.test.ts
new file mode 100644
index 0000000..66937e4
--- /dev/null
+++ b/ui/src/lib/hints.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { hintsLeft } from './hints';
+
+// view carries only the two fields hintsLeft reads.
+const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
+
+describe('hintsLeft', () => {
+ it('is zero without a view', () => {
+ expect(hintsLeft(null, 5)).toBe(0);
+ });
+
+ it('adds the per-game allowance to the live wallet (fresh view)', () => {
+ // hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3.
+ expect(hintsLeft(view(4, 3), 3)).toBe(4);
+ });
+
+ it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => {
+ // The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent
+ // in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4.
+ expect(hintsLeft(view(4, 3), 2)).toBe(3);
+ });
+
+ it('shows just the wallet when the per-game allowance is used up', () => {
+ // allowance 0 (hints_remaining 3 == snapshot wallet 3); live wallet 3 → 0 + 3.
+ expect(hintsLeft(view(3, 3), 3)).toBe(3);
+ });
+
+ it('clamps a non-negative allowance and wallet', () => {
+ expect(hintsLeft(view(2, 3), 0)).toBe(0);
+ expect(hintsLeft(view(1, 0), -5)).toBe(1);
+ });
+});
diff --git a/ui/src/lib/hints.ts b/ui/src/lib/hints.ts
new file mode 100644
index 0000000..8862d4a
--- /dev/null
+++ b/ui/src/lib/hints.ts
@@ -0,0 +1,26 @@
+// Hint-count derivation, kept out of the .svelte component so it is unit-testable.
+//
+// The badge shows the per-game hint allowance remaining plus the player's global hint
+// wallet. The server's hints_remaining folds the two together, but the wallet is global —
+// shared across every game — so caching the combined number per game makes it go stale the
+// moment a wallet hint is spent in another game. We therefore split it: the per-game
+// allowance is hints_remaining - wallet_balance (both from the same fetch, so it is stable
+// and cacheable), and the wallet is read live from the global profile, never the per-game
+// snapshot.
+
+import type { StateView } from './model';
+
+/**
+ * hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's
+ * hints_remaining minus the wallet snapshot baked into that same view) plus the live global
+ * wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count
+ * correct when a wallet hint was spent in another game since this view was fetched.
+ */
+export function hintsLeft(
+ view: Pick | null,
+ walletBalance: number,
+): number {
+ if (!view) return 0;
+ const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
+ return allowance + Math.max(0, walletBalance);
+}
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index af0bcea..a8dbf42 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -34,7 +34,7 @@ export const en = {
'lobby.noActive': 'No active games yet.',
'lobby.noFinished': 'No finished games yet.',
'lobby.limitReached': "You've reached the simultaneous games limit.",
- 'lobby.new': 'New',
+ 'lobby.new': 'Play',
'lobby.stats': 'Stats',
'lobby.profile': 'Profile',
'lobby.settings': 'Settings',
@@ -83,7 +83,6 @@ export const en = {
'game.passNoExchange': 'Pass without exchanging',
'game.confirmResign': 'Resign this game?',
'game.hintShown': 'Best move: {word} for {n}',
- 'game.over': 'Game over',
'game.won': 'You won',
'game.lost': 'You lost',
'game.tied': 'Draw',
@@ -118,7 +117,7 @@ export const en = {
'chat.nudge': 'Waiting for your move 🤭',
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
'chat.nudgeAction': 'Nudge',
- 'chat.awaitingReply': "Waiting for the opponent's reply",
+ 'chat.awaitingReply': "Let's be patient",
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
'chat.sentThisTurn': 'You can write again next turn.',
@@ -275,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 8cc19c4..8b3eff8 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -35,8 +35,8 @@ export const ru: Record = {
'lobby.noActive': 'Пока нет активных игр.',
'lobby.noFinished': 'Пока нет завершённых игр.',
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
- 'lobby.new': 'Новая',
- 'lobby.stats': 'Статы',
+ 'lobby.new': 'Играть',
+ 'lobby.stats': 'Цифры',
'lobby.profile': 'Профиль',
'lobby.settings': 'Настройки',
'lobby.about': 'О программе',
@@ -56,7 +56,7 @@ export const ru: Record = {
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
- 'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
+ 'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
@@ -84,7 +84,6 @@ export const ru: Record = {
'game.passNoExchange': 'Пас без обмена',
'game.confirmResign': 'Сдаться в этой игре?',
'game.hintShown': 'Лучший ход: {word} на {n}',
- 'game.over': 'Игра окончена',
'game.won': 'Вы выиграли',
'game.lost': 'Вы проиграли',
'game.tied': 'Ничья',
@@ -119,7 +118,7 @@ export const ru: Record = {
'chat.nudge': 'Жду Вашего хода 🤭',
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
'chat.nudgeAction': 'Поторопить',
- 'chat.awaitingReply': 'Ждём реакцию соперника',
+ 'chat.awaitingReply': 'Немного терпения',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
'chat.sentThisTurn': 'Можно написать снова в следующем ходу.',
@@ -275,7 +274,9 @@ export const ru: Record = {
'stats.wins': 'Победы',
'stats.losses': 'Поражения',
'stats.draws': 'Ничьи',
- 'stats.played': 'Игр',
+ 'stats.played': 'Игры',
+ 'stats.moves': 'Ходы',
+ 'stats.hintShare': 'Доля подсказок',
'stats.winRate': 'Доля побед',
'stats.maxGame': 'Лучшая игра',
'stats.maxWord': 'Лучший ход',
diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts
index 41c5a6d..78ecca8 100644
--- a/ui/src/lib/mock/client.ts
+++ b/ui/src/lib/mock/client.ts
@@ -295,7 +295,10 @@ export class MockGateway implements GatewayClient {
seat: this.mySeat(g),
rack: [...g.rack],
bagLen: g.bagLen,
- hintsRemaining: g.hintsRemaining,
+ // g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance.
+ // hints_remaining folds the two together (as the backend does), walletBalance is the wallet.
+ hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
+ walletBalance: this.profile.hintBalance,
};
}
@@ -395,8 +398,11 @@ export class MockGateway implements GatewayClient {
async hint(gameId: string): Promise {
const g = this.game(gameId);
- if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable');
- g.hintsRemaining -= 1;
+ if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable');
+ // Spend the per-game allowance first, then the shared wallet — mirroring the backend, so a
+ // wallet hint in one game lowers the count shown in every other game (the bug this fixes).
+ if (g.hintsRemaining > 0) g.hintsRemaining -= 1;
+ else this.profile.hintBalance -= 1;
const letter = g.rack.find((l) => l !== '?') ?? 'A';
return {
move: {
@@ -411,7 +417,8 @@ export class MockGateway implements GatewayClient {
score: valueForLetter(g.view.variant, letter),
total: 0,
},
- hintsRemaining: g.hintsRemaining,
+ hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
+ walletBalance: this.profile.hintBalance,
};
}
diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts
index 3a3bac4..c4e7c20 100644
--- a/ui/src/lib/mock/data.ts
+++ b/ui/src/lib/mock/data.ts
@@ -50,7 +50,47 @@ export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Ka
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
-export const MOCK_STATS: Stats = { wins: 7, losses: 4, draws: 1, maxGamePoints: 421, maxWordPoints: 95 };
+export const MOCK_STATS: Stats = {
+ wins: 7,
+ losses: 4,
+ 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.
+ bestMoves: [
+ {
+ variant: 'scrabble_en',
+ score: 134,
+ word: [
+ { letter: 'w', value: 4, blank: false },
+ { letter: 'o', value: 1, blank: false },
+ { letter: 'n', value: 1, blank: false },
+ { letter: 'd', value: 0, blank: true },
+ { letter: 'e', value: 1, blank: false },
+ { letter: 'r', value: 1, blank: false },
+ { letter: 'f', value: 4, blank: false },
+ { letter: 'u', value: 1, blank: false },
+ { letter: 'l', value: 1, blank: false },
+ ],
+ },
+ {
+ variant: 'scrabble_ru',
+ score: 88,
+ word: [
+ { letter: 'с', value: 1, blank: false },
+ { letter: 'ъ', value: 10, blank: false },
+ { letter: 'ё', value: 4, blank: false },
+ { letter: 'м', value: 2, blank: false },
+ { letter: 'к', value: 2, blank: false },
+ { letter: 'а', value: 1, blank: false },
+ ],
+ },
+ ],
+};
export function mockInvitations(): Invitation[] {
return [
diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts
index ff7022a..5581fbf 100644
--- a/ui/src/lib/model.ts
+++ b/ui/src/lib/model.ts
@@ -67,13 +67,17 @@ export interface MoveRecord {
total: number;
}
-/** A seated player's private view of a game. */
+/** A seated player's private view of a game. hintsRemaining folds the per-game allowance
+ * together with the global wallet; walletBalance is the wallet alone, so the client can
+ * derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live
+ * across games (see lib/hints). */
export interface StateView {
game: GameView;
seat: number;
rack: string[];
bagLen: number;
hintsRemaining: number;
+ walletBalance: number;
}
export interface MoveResult {
@@ -87,6 +91,7 @@ export interface MoveResult {
export interface HintResult {
move: MoveRecord;
hintsRemaining: number;
+ walletBalance: number;
}
export interface EvalResult {
@@ -206,13 +211,37 @@ export interface FriendCode {
expiresAtUnix: number;
}
-/** A durable account's lifetime statistics. */
+/** One letter cell of a best-move word: its display letter, its tile value (0 for a
+ * blank) and whether it is a blank — enough to render it as a game tile without the
+ * variant's alphabet table. */
+export interface BestMoveTile {
+ letter: string;
+ value: number;
+ blank: boolean;
+}
+
+/** An account's highest-scoring single play within one variant: the variant, the play's
+ * total score and its main word as ordered tiles. */
+export interface BestMove {
+ variant: Variant;
+ score: number;
+ word: BestMoveTile[];
+}
+
+/** A durable account's lifetime statistics. bestMoves breaks the best move down per
+ * variant (with the word itself); it is empty for an account with no recorded play and
+ * lists only variants the account has played. */
export interface Stats {
wins: number;
losses: number;
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[];
}
/** Settings the inviter chooses for a friend game. */
diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts
index fbcf9e2..4aa53cc 100644
--- a/ui/src/lib/preload.test.ts
+++ b/ui/src/lib/preload.test.ts
@@ -34,7 +34,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
}
function stateView(id: string): StateView {
- return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 };
+ return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
}
beforeEach(() => {
diff --git a/ui/src/lib/stats.test.ts b/ui/src/lib/stats.test.ts
index ccfdee2..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,8 +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);
@@ -23,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/Lobby.svelte b/ui/src/screens/Lobby.svelte
index c6c175c..9b5f985 100644
--- a/ui/src/screens/Lobby.svelte
+++ b/ui/src/screens/Lobby.svelte
@@ -306,7 +306,7 @@
🎲{t('lobby.new')}