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)} + + {/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')}
    {/each} + {#if bestMoves.length > 0} +
    + {t('stats.maxWord')} +
    + {#each bestMoves as bm (bm.variant)} + {t(variantNameKey(bm.variant))} + + {bm.score} + {/each} +
    +
    + {/if} {/if} @@ -79,4 +111,33 @@ color: var(--text-muted); font-size: 0.85rem; } + .bestmove { + margin-top: 12px; + gap: 12px; + } + /* One grid for all rows so columns align across them: variant on the left, the word + tiles right-aligned to a shared edge, the score right-aligned in its own column. */ + .rows { + display: grid; + /* minmax(0, 1fr) lets the word column shrink below its tiles' intrinsic width on a + narrow screen (the cell then scrolls) instead of overlapping the variant label. */ + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + row-gap: 12px; + column-gap: 8px; + } + .variant { + color: var(--text-muted); + font-size: 0.95rem; + } + .wordcell { + justify-self: end; + min-width: 0; + overflow-x: auto; + } + .score { + justify-self: end; + font-weight: 700; + font-variant-numeric: tabular-nums; + }