feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s

The statistics screen gains real depth, plus a hint-count bug fix found along the way.

- Best move per variant: the screen shows the actual best-move word (drawn as game
  tiles; a wildcard shows its letter but no value), broken down by game variant, empty
  variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
  plays that used a hint — from summed account_stats counters (moves, hints_used).
  Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
  wallet now rides the wire apart from the per-game allowance (wallet_balance on
  StateView/HintResult/StatsView), so the client reads the live wallet rather than a
  per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
  its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
  moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
  changes.

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
This commit was merged in pull request #81.
This commit is contained in:
2026-06-17 22:17:27 +00:00
parent 5a3f0951ae
commit 8793bd34f2
71 changed files with 1789 additions and 132 deletions
+66 -1
View File
@@ -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
}
+46 -4
View File
@@ -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 {
@@ -32,6 +32,8 @@
<li><b>Wins</b> {{.Stats.Wins}}</li>
<li><b>Losses</b> {{.Stats.Losses}}</li>
<li><b>Draws</b> {{.Stats.Draws}}</li>
<li><b>Moves</b> {{.Stats.Moves}}</li>
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
</ul>
+2
View File
@@ -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.
+1
View File
@@ -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)
+85 -13
View File
@@ -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.
+50
View File
@@ -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)
}
}
+63 -9
View File
@@ -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
}
+9 -1
View File
@@ -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.
+2 -1
View File
@@ -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)
}
}
+70 -4
View File
@@ -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{
+55
View File
@@ -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
+1
View File
@@ -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,
})
}
+1
View File
@@ -56,6 +56,7 @@ type PlayerState struct {
Rack []int
BagLen int
HintsRemaining int
WalletBalance int
Alphabet []AlphabetLetter
}
@@ -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
}
@@ -20,4 +20,6 @@ type AccountStats struct {
MaxGamePoints int32
MaxWordPoints int32
UpdatedAt time.Time
Moves int32
HintsUsed int32
}
@@ -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,
}
}
@@ -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,
@@ -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,
@@ -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;
@@ -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;
+2
View File
@@ -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)
+30 -6
View File
@@ -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,
})
}
@@ -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 {
+2
View File
@@ -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,
})
}
+26 -5
View File
@@ -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)
+6 -1
View File
@@ -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
+6 -1
View File
@@ -233,7 +233,12 @@ UTC), суточного окна отсутствия (away; сетка по 10
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки).
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
**доля подсказок** (процент таких ходов, на которых бралась подсказка).
Лучший ход также даётся **с разбивкой по вариантам игры****само слово**,
нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной
строке на каждый сыгранный вариант; варианты без ходов не выводятся.
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
+10 -3
View File
@@ -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
+2
View File
@@ -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
+26 -6
View File
@@ -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.
+2
View File
@@ -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()
}
+50 -2
View File
@@ -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()
}
@@ -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) {
+6 -6
View File
@@ -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)
+37 -2
View File
@@ -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,
+90
View File
@@ -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()
}
+101
View File
@@ -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()
}
+16 -1
View File
@@ -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()
}
+16 -1
View File
@@ -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()
}
+57 -1
View File
@@ -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()
}
+2
View File
@@ -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)
}
+5 -5
View File
@@ -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: Жду Вашего хода 🤭",
+2 -2
View File
@@ -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 });
+1 -1
View File
@@ -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);
+4 -4
View File
@@ -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<void> {
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();
+10 -2
View File
@@ -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' });
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
// shows its letter but no value, exactly as on the board. Letters are upper-cased for
// display. The tile values ride on each tile, so this renders without the variant's
// alphabet table (which the statistics screen has not cached).
import type { BestMoveTile } from '../lib/model';
let { word }: { word: BestMoveTile[] } = $props();
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
</script>
<span class="word" aria-label={label}>
{#each word as tile, i (i)}
<span class="tile" class:blank={tile.blank} aria-hidden="true">
<span class="letter">{tile.letter.toUpperCase()}</span>
{#if !tile.blank}<span class="val">{tile.value}</span>{/if}
</span>
{/each}
</span>
<style>
.word {
display: inline-flex;
gap: 2px;
}
.tile {
position: relative;
flex: none;
width: 22px;
height: 22px;
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 3px;
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
.letter {
position: absolute;
top: 6%;
left: 11%;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.val {
position: absolute;
right: 8%;
bottom: 3%;
font-size: 7px;
font-weight: 600;
}
</style>
+28 -5
View File
@@ -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 @@
<div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
<strong class="over">{resultText()}</strong>
{:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if}
@@ -1107,10 +1130,10 @@
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="sq">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{#if placement.pending.length > 0}
+2
View File
@@ -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';
@@ -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);
}
}
@@ -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);
}
}
+12 -2
View File
@@ -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);
}
}
+12 -2
View File
@@ -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);
}
}
+52 -2
View File
@@ -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);
}
}
+53
View File
@@ -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 },
],
},
],
});
});
+18 -1
View File
@@ -11,6 +11,8 @@ import type {
AccountRef,
Banner,
BannerCampaign,
BestMove,
BestMoveTile,
BlockStatus,
ChatMessage,
EvalResult,
@@ -381,6 +383,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
rack,
bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(),
walletBalance: v.walletBalance(),
};
}
@@ -407,7 +410,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult {
export function decodeHintResult(buf: Uint8Array): HintResult {
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
const m = r.move();
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining() };
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining(), walletBalance: r.walletBalance() };
}
export function decodeEvalResult(buf: Uint8Array): EvalResult {
@@ -742,12 +745,26 @@ export function decodeRedeemResult(buf: Uint8Array): AccountRef {
export function decodeStats(buf: Uint8Array): Stats {
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
const bestMoves: BestMove[] = [];
for (let i = 0; i < v.bestMovesLength(); i++) {
const m = v.bestMoves(i);
if (!m) continue;
const word: BestMoveTile[] = [];
for (let j = 0; j < m.wordLength(); j++) {
const t = m.word(j);
if (t) word.push({ letter: s(t.letter()), value: t.value(), blank: t.blank() });
}
bestMoves.push({ variant: s(m.variant()) as Variant, score: m.score(), word });
}
return {
wins: v.wins(),
losses: v.losses(),
draws: v.draws(),
maxGamePoints: v.maxGamePoints(),
maxWordPoints: v.maxWordPoints(),
moves: v.moves(),
hintsUsed: v.hintsUsed(),
bestMoves,
};
}
+1 -1
View File
@@ -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 {
+4 -4
View File
@@ -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);
+32
View File
@@ -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);
});
});
+26
View File
@@ -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<StateView, 'hintsRemaining' | 'walletBalance'> | null,
walletBalance: number,
): number {
if (!view) return 0;
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
return allowance + Math.max(0, walletBalance);
}
+4 -3
View File
@@ -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',
+7 -6
View File
@@ -35,8 +35,8 @@ export const ru: Record<MessageKey, string> = {
'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<MessageKey, string> = {
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
@@ -84,7 +84,6 @@ export const ru: Record<MessageKey, string> = {
'game.passNoExchange': 'Пас без обмена',
'game.confirmResign': 'Сдаться в этой игре?',
'game.hintShown': 'Лучший ход: {word} на {n}',
'game.over': 'Игра окончена',
'game.won': 'Вы выиграли',
'game.lost': 'Вы проиграли',
'game.tied': 'Ничья',
@@ -119,7 +118,7 @@ export const ru: Record<MessageKey, string> = {
'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<MessageKey, string> = {
'stats.wins': 'Победы',
'stats.losses': 'Поражения',
'stats.draws': 'Ничьи',
'stats.played': 'Игр',
'stats.played': 'Игры',
'stats.moves': 'Ходы',
'stats.hintShare': 'Доля подсказок',
'stats.winRate': 'Доля побед',
'stats.maxGame': 'Лучшая игра',
'stats.maxWord': 'Лучший ход',
+11 -4
View File
@@ -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<HintResult> {
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,
};
}
+41 -1
View File
@@ -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 [
+31 -2
View File
@@ -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. */
+1 -1
View File
@@ -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(() => {
+17 -1
View File
@@ -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);
});
});
+7
View File
@@ -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;
}
+1 -1
View File
@@ -306,7 +306,7 @@
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
<button class="tab" onclick={() => navigate('/stats')}>
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
<span class="sq">✏️</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
+69 -8
View File
@@ -1,11 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import WordTiles from '../components/WordTiles.svelte';
import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { gamesPlayed, winRate } from '../lib/stats';
import type { Stats } from '../lib/model';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { gamesPlayed, hintSharePercent, winRate } from '../lib/stats';
import { ALL_VARIANTS, variantNameKey } from '../lib/variants';
import type { BestMove, Stats } from '../lib/model';
// hintShare is shown to one decimal in the active locale's notation ("4.8%" / "4,8%").
function hintShare(s: Stats): string {
const pct = hintSharePercent(s).toLocaleString(i18n.locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return `${pct}%`;
}
let stats = $state<Stats | null>(null);
@@ -21,16 +32,25 @@
const cards = $derived<{ key: MessageKey; value: string | number }[]>(
stats
? [
{ key: 'stats.wins', value: stats.wins },
{ key: 'stats.losses', value: stats.losses },
{ key: 'stats.draws', value: stats.draws },
{ key: 'stats.played', value: gamesPlayed(stats) },
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
{ key: 'stats.wins', value: stats.wins },
{ key: 'stats.draws', value: stats.draws },
{ key: 'stats.losses', value: stats.losses },
{ key: 'stats.moves', value: stats.moves },
{ key: 'stats.hintShare', value: hintShare(stats) },
{ key: 'stats.maxGame', value: stats.maxGamePoints },
{ key: 'stats.maxWord', value: stats.maxWordPoints },
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
]
: [],
);
// The best move is shown as a full-width breakdown with the word itself, one row per
// variant the player has played, in catalogue order (the backend lists only non-empty
// variants). It replaces the former single "best move" number card.
const ORDER = ALL_VARIANTS.map((v) => v.id);
const bestMoves = $derived<BestMove[]>(
stats ? [...stats.bestMoves].sort((a, b) => ORDER.indexOf(a.variant) - ORDER.indexOf(b.variant)) : [],
);
</script>
<Screen title={t('stats.title')} back="/">
@@ -46,6 +66,18 @@
</div>
{/each}
</div>
{#if bestMoves.length > 0}
<div class="card bestmove">
<span class="lbl">{t('stats.maxWord')}</span>
<div class="rows">
{#each bestMoves as bm (bm.variant)}
<span class="variant">{t(variantNameKey(bm.variant))}</span>
<span class="wordcell"><WordTiles word={bm.word} /></span>
<span class="score">{bm.score}</span>
{/each}
</div>
</div>
{/if}
{/if}
</div>
</Screen>
@@ -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;
}
</style>