feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
The statistics screen gains real depth, plus a hint-count bug fix found along the way. - Best move per variant: the screen shows the actual best-move word (drawn as game tiles; a wildcard shows its letter but no value), broken down by game variant, empty variants omitted. New account_best_move table, written at game finish. - Moves & hint share: two new lifetime tiles — the player's play count and the share of plays that used a hint — from summed account_stats counters (moves, hints_used). Honest-AI games are excluded, like the rest of the stats. - Hint-count fix: the in-game hint badge no longer goes stale across games. The global wallet now rides the wire apart from the per-game allowance (wallet_balance on StateView/HintResult/StatsView), so the client reads the live wallet rather than a per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet), its true per-game total. - Account merge: sums the new moves/hints_used counters and merges the per-variant best moves (higher score kept), which it previously dropped. - Admin: the user card shows Moves and Hints used. - UI polish: tab/label wording, game-over text, and e2e selectors hardened against label changes. All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru, UISN_DESIGN) updated in step.
This commit was merged in pull request #81.
This commit is contained in:
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -13,16 +14,43 @@ import (
|
|||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"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
|
// 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
|
// 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
|
// 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 {
|
type Stats struct {
|
||||||
Wins int
|
Wins int
|
||||||
Losses int
|
Losses int
|
||||||
Draws int
|
Draws int
|
||||||
MaxGamePoints int
|
MaxGamePoints int
|
||||||
MaxWordPoints 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
|
// 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)
|
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{
|
return Stats{
|
||||||
Wins: int(row.Wins),
|
Wins: int(row.Wins),
|
||||||
Losses: int(row.Losses),
|
Losses: int(row.Losses),
|
||||||
Draws: int(row.Draws),
|
Draws: int(row.Draws),
|
||||||
MaxGamePoints: int(row.MaxGamePoints),
|
MaxGamePoints: int(row.MaxGamePoints),
|
||||||
MaxWordPoints: int(row.MaxWordPoints),
|
MaxWordPoints: int(row.MaxWordPoints),
|
||||||
|
Moves: int(row.Moves),
|
||||||
|
HintsUsed: int(row.HintsUsed),
|
||||||
|
BestMoves: best,
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Package accountmerge retires a secondary account into a primary one in a single
|
// 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,
|
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
// 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
|
// (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 {
|
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
|
||||||
// summed, max points kept) and deletes the secondary row.
|
// 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 {
|
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||||
var sec model.AccountStats
|
var sec model.AccountStats
|
||||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
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(
|
upd := table.AccountStats.UPDATE(
|
||||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
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(
|
).SET(
|
||||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
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),
|
postgres.TimestampzT(now),
|
||||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
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
|
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;
|
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||||
// all other profile fields stay the primary's.
|
// all other profile fields stay the primary's.
|
||||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
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>Wins</b> {{.Stats.Wins}}</li>
|
||||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||||
<li><b>Draws</b> {{.Stats.Draws}}</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 game</b> {{.Stats.MaxGamePoints}}</li>
|
||||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ type StatsRow struct {
|
|||||||
Draws int
|
Draws int
|
||||||
MaxGamePoints int
|
MaxGamePoints int
|
||||||
MaxWordPoints int
|
MaxWordPoints int
|
||||||
|
Moves int
|
||||||
|
HintsUsed int
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityRow is one platform/email identity of an account.
|
// IdentityRow is one platform/email identity of an account.
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
|
WalletBalance: v.WalletBalance,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -1027,12 +1027,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
}
|
}
|
||||||
|
|
||||||
walletAfter := acc.HintBalance
|
walletAfter := acc.HintBalance
|
||||||
if fromAllowance {
|
if !fromAllowance {
|
||||||
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
|
|
||||||
return HintResult{}, err
|
|
||||||
}
|
|
||||||
used++
|
|
||||||
} else {
|
|
||||||
spent, err := svc.accounts.SpendHint(ctx, accountID)
|
spent, err := svc.accounts.SpendHint(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HintResult{}, err
|
return HintResult{}, err
|
||||||
@@ -1042,7 +1037,14 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
}
|
}
|
||||||
walletAfter--
|
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
|
// 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),
|
Rack: g.Hand(seat),
|
||||||
BagLen: g.BagLen(),
|
BagLen: g.BagLen(),
|
||||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||||
|
WalletBalance: acc.HintBalance,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1419,19 +1422,45 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildStats derives each seat's statistics contribution from a finished game:
|
// buildStats derives each seat's statistics contribution from a finished game:
|
||||||
// win/loss/draw from the (resignation-aware) winner, the final score, and the
|
// win/loss/draw from the (resignation-aware) winner, the final score, and the best
|
||||||
// best single-move score from the log.
|
// 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 {
|
func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||||
res := g.Result()
|
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() {
|
for _, rec := range g.Log() {
|
||||||
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
|
if rec.Action != engine.ActionPlay {
|
||||||
best[rec.Player] = rec.Score
|
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))
|
out := make([]statDelta, 0, len(seats))
|
||||||
for _, s := range 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 {
|
switch {
|
||||||
case res.Winner < 0:
|
case res.Winner < 0:
|
||||||
d.draws = 1
|
d.draws = 1
|
||||||
@@ -1445,6 +1474,49 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
|||||||
return out
|
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
|
// nonGuestSeats filters out guest seats so the finish-time statistics are
|
||||||
// recomputed for durable non-guest accounts only — guests never accrue
|
// recomputed for durable non-guest accounts only — guests never accrue
|
||||||
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
|
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package game
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
@@ -53,6 +55,10 @@ type gameInsert struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// statDelta is one account's contribution to its statistics on a game finish.
|
// 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 {
|
type statDelta struct {
|
||||||
accountID uuid.UUID
|
accountID uuid.UUID
|
||||||
wins int
|
wins int
|
||||||
@@ -60,6 +66,11 @@ type statDelta struct {
|
|||||||
draws int
|
draws int
|
||||||
gamePoints int
|
gamePoints int
|
||||||
wordPoints 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,
|
// 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 {
|
if err := upsertStats(ctx, tx, d, c.now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := upsertBestMove(ctx, tx, d, c.now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
if err := upsertStats(ctx, tx, d, v.now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := upsertBestMove(ctx, tx, d, v.now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
draws := row.Draws + int32(d.draws)
|
||||||
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
|
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
|
||||||
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
|
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
|
||||||
|
moves := row.Moves + int32(d.moves)
|
||||||
|
hintsUsed := row.HintsUsed + int32(d.hintsUsed)
|
||||||
|
|
||||||
upd := table.AccountStats.UPDATE(
|
upd := table.AccountStats.UPDATE(
|
||||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
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.UpdatedAt,
|
||||||
|
table.AccountStats.Moves, table.AccountStats.HintsUsed,
|
||||||
).SET(
|
).SET(
|
||||||
postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
|
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(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)))
|
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
|
||||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("update stats %s: %w", d.accountID, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpendHintAllowance increments a seat's per-game hint counter by one.
|
// upsertBestMove records the account's best play for a variant, keeping only the
|
||||||
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
|
// 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.
|
stmt := table.GamePlayers.
|
||||||
UPDATE(table.GamePlayers.HintsUsed).
|
UPDATE(table.GamePlayers.HintsUsed).
|
||||||
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
|
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)))),
|
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ type Seat struct {
|
|||||||
Seat int
|
Seat int
|
||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
Score int
|
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
|
HintsUsed int
|
||||||
IsWinner bool
|
IsWinner bool
|
||||||
// DisplayName is the seat's display-name snapshot, captured when the seat was taken
|
// 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
|
// 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 {
|
type HintResult struct {
|
||||||
Move engine.MoveRecord
|
Move engine.MoveRecord
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
|
WalletBalance int
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalResult previews a tentative play without committing it. Dir is the
|
// EvalResult previews a tentative play without committing it. Dir is the
|
||||||
@@ -220,6 +225,9 @@ type StateView struct {
|
|||||||
Rack []string
|
Rack []string
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining 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.
|
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get stats: %v", err)
|
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)
|
t.Fatalf("fresh stats = %+v, want zero", st)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
)
|
)
|
||||||
@@ -120,8 +121,8 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
|||||||
t.Fatalf("final game not finished: %+v", last.Game)
|
t.Fatalf("final game not finished: %+v", last.Game)
|
||||||
}
|
}
|
||||||
|
|
||||||
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
|
w0, l0, d0, mg0, mw0, ok0 := readStats(t, seats[0])
|
||||||
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
|
w1, l1, d1, mg1, mw1, ok1 := readStats(t, seats[1])
|
||||||
if !ok0 || !ok1 {
|
if !ok0 || !ok1 {
|
||||||
t.Fatal("both players must have a stats row")
|
t.Fatal("both players must have a stats row")
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,52 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
|||||||
if !decisive && !draw {
|
if !decisive && !draw {
|
||||||
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
|
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
|
// 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
|
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
|
||||||
t.Fatalf("first hint: %v", err)
|
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) {
|
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||||
}
|
}
|
||||||
@@ -377,8 +433,18 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("wallet hint: %v", err)
|
t.Fatalf("wallet hint: %v", err)
|
||||||
}
|
}
|
||||||
if res.HintsRemaining != 1 {
|
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
||||||
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
|
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{
|
off, err := svc.Create(ctx, game.CreateParams{
|
||||||
|
|||||||
@@ -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) {
|
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := testDB.ExecContext(context.Background(),
|
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, primary, 1, 0, 0, 100, 90)
|
||||||
setStats(t, secondary, 3, 1, 2, 400, 80)
|
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, primary, 2, false)
|
||||||
setWallet(t, secondary, 5, true)
|
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"
|
email := "merge-" + uuid.NewString() + "@example.com"
|
||||||
bindEmailIdentity(t, secondary, email)
|
bindEmailIdentity(t, secondary, email)
|
||||||
@@ -153,6 +188,26 @@ func TestAccountMergeCore(t *testing.T) {
|
|||||||
if mergedInto(t, secondary) != primary {
|
if mergedInto(t, secondary) != primary {
|
||||||
t.Errorf("secondary.merged_into = %s, want primary %s", 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
|
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
|||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
|
WalletBalance: s.WalletBalance,
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type PlayerState struct {
|
|||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
|
WalletBalance int
|
||||||
Alphabet []AlphabetLetter
|
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
|
MaxGamePoints int32
|
||||||
MaxWordPoints int32
|
MaxWordPoints int32
|
||||||
UpdatedAt time.Time
|
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
|
MaxGamePoints postgres.ColumnInteger
|
||||||
MaxWordPoints postgres.ColumnInteger
|
MaxWordPoints postgres.ColumnInteger
|
||||||
UpdatedAt postgres.ColumnTimestampz
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
Moves postgres.ColumnInteger
|
||||||
|
HintsUsed postgres.ColumnInteger
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -72,9 +74,11 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
|
|||||||
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
|
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
|
||||||
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
|
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
MovesColumn = postgres.IntegerColumn("moves")
|
||||||
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
HintsUsedColumn = postgres.IntegerColumn("hints_used")
|
||||||
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
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{
|
return accountStatsTable{
|
||||||
@@ -88,6 +92,8 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
|
|||||||
MaxGamePoints: MaxGamePointsColumn,
|
MaxGamePoints: MaxGamePointsColumn,
|
||||||
MaxWordPoints: MaxWordPointsColumn,
|
MaxWordPoints: MaxWordPointsColumn,
|
||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
Moves: MovesColumn,
|
||||||
|
HintsUsed: HintsUsedColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
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
|
-- 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
|
-- 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),
|
-- 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 (
|
CREATE TABLE game_players (
|
||||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
seat smallint NOT NULL,
|
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;
|
||||||
@@ -140,6 +140,7 @@ type stateDTO struct {
|
|||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
|
WalletBalance int `json:"wallet_balance"`
|
||||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +292,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
|
WalletBalance: v.WalletBalance,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -29,13 +29,27 @@ type updateProfileRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
// 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 {
|
type statsDTO struct {
|
||||||
Wins int `json:"wins"`
|
Wins int `json:"wins"`
|
||||||
Losses int `json:"losses"`
|
Losses int `json:"losses"`
|
||||||
Draws int `json:"draws"`
|
Draws int `json:"draws"`
|
||||||
MaxGamePoints int `json:"max_game_points"`
|
MaxGamePoints int `json:"max_game_points"`
|
||||||
MaxWordPoints int `json:"max_word_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.
|
// parseAwayTime parses an "HH:MM" away-window bound.
|
||||||
@@ -140,11 +154,21 @@ func (s *Server) handleStats(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, statsDTO{
|
||||||
Wins: st.Wins,
|
Wins: st.Wins,
|
||||||
Losses: st.Losses,
|
Losses: st.Losses,
|
||||||
Draws: st.Draws,
|
Draws: st.Draws,
|
||||||
MaxGamePoints: st.MaxGamePoints,
|
MaxGamePoints: st.MaxGamePoints,
|
||||||
MaxWordPoints: st.MaxWordPoints,
|
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 view.HasStats {
|
||||||
if st, err := s.accounts.GetStats(ctx, id); err == nil {
|
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 {
|
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
type hintResultDTO struct {
|
type hintResultDTO struct {
|
||||||
Move moveRecordDTO `json:"move"`
|
Move moveRecordDTO `json:"move"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
|
WalletBalance int `json:"wallet_balance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// evalResultDTO is an unlimited move preview: legality, score, the words formed
|
// 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{
|
c.JSON(http.StatusOK, hintResultDTO{
|
||||||
Move: moveRecordDTOFrom(h.Move),
|
Move: moveRecordDTOFrom(h.Move),
|
||||||
HintsRemaining: h.HintsRemaining,
|
HintsRemaining: h.HintsRemaining,
|
||||||
|
WalletBalance: h.WalletBalance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-5
@@ -203,7 +203,8 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
guest is promoted to durable, clearing `is_guest`).
|
guest is promoted to durable, clearing `is_guest`).
|
||||||
- **Merge** retires the account that owns the linked identity into the **current**
|
- **Merge** retires the account that owns the linked identity into the **current**
|
||||||
account, in a single transaction (`internal/accountmerge`): statistics summed
|
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
|
repointed, games / chat / complaints transferred, friends and blocks
|
||||||
de-duplicated (friendships keep the strongest status accepted>pending>declined),
|
de-duplicated (friendships keep the strongest status accepted>pending>declined),
|
||||||
pending invitations/codes dropped, and the secondary kept as an **audit
|
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
|
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
|
service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct
|
||||||
result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say
|
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
|
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
|
||||||
dictionary; each result offers a **complaint** (complainant, game, variant,
|
dictionary; each result offers a **complaint** (complainant, game, variant,
|
||||||
dict_version, word, the disputed result, an optional note) that lands in the admin
|
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`),
|
the `kind` admitting `robot`),
|
||||||
`sessions` (revoke-only opaque-token hashes), the game tables
|
`sessions` (revoke-only opaque-token hashes), the game tables
|
||||||
`games` (carrying the `dropout_tiles` disposition column), `game_players`,
|
`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
|
social/lobby tables `friendships` (the request/accept graph, its status admitting
|
||||||
`declined`), `blocks`
|
`declined`), `blocks`
|
||||||
(per-user blocks), `chat_messages` (per-game chat and nudges, carrying the per-message
|
(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`
|
non-guest accounts only — the finish-time recompute skips any `is_guest`
|
||||||
seat): wins, losses, **draws**, max points in a game, and
|
seat): wins, losses, **draws**, max points in a game, and
|
||||||
max points for a single **move** (which already folds in every word the move
|
max points for a single **move** (which already folds in every word the move
|
||||||
formed plus the all-tiles bonus). A tie increments draws only; a resignation or
|
formed plus the all-tiles bonus); plus two summed counters — `moves` (the player's
|
||||||
timeout is a loss for the acting player.
|
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)
|
### 9.1 History invariant (must hold forever)
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -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
|
game is throwaway). The client shares the `.gcg` file where the platform supports
|
||||||
it, otherwise downloads it. Statistics (durable accounts only):
|
it, otherwise downloads it. Statistics (durable accounts only):
|
||||||
wins, losses, draws, max points in a game, and max points for a single move (the
|
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
|
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,
|
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
|
never left stuck on an error: the move history shows an impersonal organizer note
|
||||||
|
|||||||
@@ -233,7 +233,12 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
|||||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
||||||
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||||
|
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||||
|
**доля подсказок** (процент таких ходов, на которых бралась подсказка).
|
||||||
|
Лучший ход также даётся **с разбивкой по вариантам игры** — **само слово**,
|
||||||
|
нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной
|
||||||
|
строке на каждый сыгранный вариант; варианты без ходов не выводятся.
|
||||||
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
|
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
|
||||||
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
|
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
|
||||||
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
|
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
|
||||||
|
|||||||
+10
-3
@@ -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 **🤖**
|
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
|
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.
|
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
|
- **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
|
cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) —
|
||||||
numbers, no charts.
|
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
|
- **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a
|
||||||
**UTC-offset** timezone dropdown (defaulting to the browser's offset), the away
|
**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
|
window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ type StateResp struct {
|
|||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
|
WalletBalance int `json:"wallet_balance"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +332,7 @@ func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP st
|
|||||||
type HintResultResp struct {
|
type HintResultResp struct {
|
||||||
Move MoveRecordResp `json:"move"`
|
Move MoveRecordResp `json:"move"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
|
WalletBalance int `json:"wallet_balance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalResultResp is an unlimited move preview. Dir is the orientation the backend
|
// EvalResultResp is an unlimited move preview. Dir is the orientation the backend
|
||||||
|
|||||||
@@ -47,13 +47,33 @@ type BlockListResp struct {
|
|||||||
Blocked []AccountRefResp `json:"blocked"`
|
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 {
|
type StatsResp struct {
|
||||||
Wins int `json:"wins"`
|
Wins int `json:"wins"`
|
||||||
Losses int `json:"losses"`
|
Losses int `json:"losses"`
|
||||||
Draws int `json:"draws"`
|
Draws int `json:"draws"`
|
||||||
MaxGamePoints int `json:"max_game_points"`
|
MaxGamePoints int `json:"max_game_points"`
|
||||||
MaxWordPoints int `json:"max_word_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.
|
// InvitationInviteeResp is one invitee's seat and response with their name.
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
|||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
|
WalletBalance: s.WalletBalance,
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,6 +279,7 @@ func encodeHintResult(r backendclient.HintResultResp) []byte {
|
|||||||
fb.HintResultStart(b)
|
fb.HintResultStart(b)
|
||||||
fb.HintResultAddMove(b, move)
|
fb.HintResultAddMove(b, move)
|
||||||
fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining))
|
fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining))
|
||||||
|
fb.HintResultAddWalletBalance(b, int32(r.WalletBalance))
|
||||||
b.Finish(fb.HintResultEnd(b))
|
b.Finish(fb.HintResultEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,15 +91,63 @@ func encodeRedeemResult(r backendclient.RedeemResultResp) []byte {
|
|||||||
return b.FinishedBytes()
|
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 {
|
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.StatsViewStart(b)
|
||||||
fb.StatsViewAddWins(b, int32(r.Wins))
|
fb.StatsViewAddWins(b, int32(r.Wins))
|
||||||
fb.StatsViewAddLosses(b, int32(r.Losses))
|
fb.StatsViewAddLosses(b, int32(r.Losses))
|
||||||
fb.StatsViewAddDraws(b, int32(r.Draws))
|
fb.StatsViewAddDraws(b, int32(r.Draws))
|
||||||
fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints))
|
fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints))
|
||||||
fb.StatsViewAddMaxWordPoints(b, int32(r.MaxWordPoints))
|
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))
|
b.Finish(fb.StatsViewEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,12 @@ func TestStatsRoundTrip(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/stats" {
|
if r.URL.Path != "/api/v1/user/stats" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
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()
|
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 {
|
if st.Wins() != 5 || st.Losses() != 3 || st.Draws() != 1 || st.MaxGamePoints() != 420 || st.MaxWordPoints() != 90 {
|
||||||
t.Fatalf("stats decoded wrong: %+v", st)
|
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) {
|
func TestGcgRoundTrip(t *testing.T) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
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()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
st := fb.GetRootAsStateView(payload, 0)
|
st := fb.GetRootAsStateView(payload, 0)
|
||||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 1 {
|
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
|
||||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining())
|
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)
|
game := st.Game(nil)
|
||||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
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" {
|
if r.URL.Path != "/api/v1/user/games/g-3/hint" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
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()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -328,8 +328,8 @@ func TestHintRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
hr := fb.GetRootAsHintResult(payload, 0)
|
hr := fb.GetRootAsHintResult(payload, 0)
|
||||||
if hr.HintsRemaining() != 2 {
|
if hr.HintsRemaining() != 2 || hr.WalletBalance() != 1 {
|
||||||
t.Errorf("hints remaining = %d, want 2", hr.HintsRemaining())
|
t.Errorf("hint decoded wrong: hints=%d wallet=%d", hr.HintsRemaining(), hr.WalletBalance())
|
||||||
}
|
}
|
||||||
var move fb.MoveRecord
|
var move fb.MoveRecord
|
||||||
hr.Move(&move)
|
hr.Move(&move)
|
||||||
|
|||||||
+37
-2
@@ -240,6 +240,12 @@ table StateView {
|
|||||||
bag_len:int;
|
bag_len:int;
|
||||||
hints_remaining:int;
|
hints_remaining:int;
|
||||||
alphabet:[AlphabetEntry];
|
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).
|
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||||
@@ -290,10 +296,14 @@ table ComplaintRequest {
|
|||||||
note:string;
|
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 {
|
table HintResult {
|
||||||
move:MoveRecord;
|
move:MoveRecord;
|
||||||
hints_remaining:int;
|
hints_remaining:int;
|
||||||
|
wallet_balance:int;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftRequest saves the player's client-side composition for a game: a single
|
// DraftRequest saves the player's client-side composition for a game: a single
|
||||||
@@ -458,14 +468,39 @@ table LinkResult {
|
|||||||
session:Session;
|
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
|
// 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 {
|
table StatsView {
|
||||||
wins:int;
|
wins:int;
|
||||||
losses:int;
|
losses:int;
|
||||||
draws:int;
|
draws:int;
|
||||||
max_game_points:int;
|
max_game_points:int;
|
||||||
max_word_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,
|
// TargetRequest names a single counterpart account (friend request/cancel/unfriend,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -66,8 +66,20 @@ func (rcv *HintResult) MutateHintsRemaining(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(6, n)
|
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) {
|
func HintResultStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) {
|
func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0)
|
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) {
|
func HintResultAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) {
|
||||||
builder.PrependInt32Slot(1, hintsRemaining, 0)
|
builder.PrependInt32Slot(1, hintsRemaining, 0)
|
||||||
}
|
}
|
||||||
|
func HintResultAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||||
|
builder.PrependInt32Slot(2, walletBalance, 0)
|
||||||
|
}
|
||||||
func HintResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func HintResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,8 +144,20 @@ func (rcv *StateView) AlphabetLength() int {
|
|||||||
return 0
|
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) {
|
func StateViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(6)
|
builder.StartObject(7)
|
||||||
}
|
}
|
||||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
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 {
|
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
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 {
|
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,52 @@ func (rcv *StatsView) MutateMaxWordPoints(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(12, n)
|
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) {
|
func StatsViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(5)
|
builder.StartObject(8)
|
||||||
}
|
}
|
||||||
func StatsViewAddWins(builder *flatbuffers.Builder, wins int32) {
|
func StatsViewAddWins(builder *flatbuffers.Builder, wins int32) {
|
||||||
builder.PrependInt32Slot(0, wins, 0)
|
builder.PrependInt32Slot(0, wins, 0)
|
||||||
@@ -119,6 +163,18 @@ func StatsViewAddMaxGamePoints(builder *flatbuffers.Builder, maxGamePoints int32
|
|||||||
func StatsViewAddMaxWordPoints(builder *flatbuffers.Builder, maxWordPoints int32) {
|
func StatsViewAddMaxWordPoints(builder *flatbuffers.Builder, maxWordPoints int32) {
|
||||||
builder.PrependInt32Slot(4, maxWordPoints, 0)
|
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 {
|
func StatsViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ type StateView struct {
|
|||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
|
WalletBalance int
|
||||||
Alphabet []AlphabetEntry
|
Alphabet []AlphabetEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +251,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
|||||||
fb.StateViewAddRack(b, rack)
|
fb.StateViewAddRack(b, rack)
|
||||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
|
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||||
if hasAlphabet {
|
if hasAlphabet {
|
||||||
fb.StateViewAddAlphabet(b, alphabet)
|
fb.StateViewAddAlphabet(b, alphabet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ var english = phrases{
|
|||||||
yourTurnExchange: "%s: swapping tiles, your turn.",
|
yourTurnExchange: "%s: swapping tiles, your turn.",
|
||||||
yourTurnPass: "%s: passing, your turn.",
|
yourTurnPass: "%s: passing, your turn.",
|
||||||
yourTurnMoved: "%s moved, your turn.",
|
yourTurnMoved: "%s moved, your turn.",
|
||||||
gameOverWon: "Game over — you won! Score %s",
|
gameOverWon: "Game over - 🏆 you won! Score %s",
|
||||||
gameOverLost: "Game over — you lost. Score %s",
|
gameOverLost: "Game over - you lost. Score %s",
|
||||||
gameOverDraw: "Game over — a draw. Score %s",
|
gameOverDraw: "Game over - a draw. Score %s",
|
||||||
nudge: "You were nudged — it's your turn.",
|
nudge: "You were nudged — it's your turn.",
|
||||||
nudgeBy: "%s: Waiting for your move 🤭",
|
nudgeBy: "%s: Waiting for your move 🤭",
|
||||||
matchFound: "Your game is ready.",
|
matchFound: "Your game is ready.",
|
||||||
@@ -144,8 +144,8 @@ var russian = phrases{
|
|||||||
yourTurnExchange: "%s: меняю фишки, ваш ход.",
|
yourTurnExchange: "%s: меняю фишки, ваш ход.",
|
||||||
yourTurnPass: "%s: пропускаю ход, ваш ход.",
|
yourTurnPass: "%s: пропускаю ход, ваш ход.",
|
||||||
yourTurnMoved: "%s сходил(а), ваш ход.",
|
yourTurnMoved: "%s сходил(а), ваш ход.",
|
||||||
gameOverWon: "Игра окончена — вы выиграли! Счёт %s",
|
gameOverWon: "Игра окончена — 🏆 Вы выиграли! Счёт %s",
|
||||||
gameOverLost: "Игра окончена — вы проиграли. Счёт %s",
|
gameOverLost: "Игра окончена — Вы проиграли. Счёт %s",
|
||||||
gameOverDraw: "Игра окончена — ничья. Счёт %s",
|
gameOverDraw: "Игра окончена — ничья. Счёт %s",
|
||||||
nudge: "Вас поторопили — ваш ход.",
|
nudge: "Вас поторопили — ваш ход.",
|
||||||
nudgeBy: "%s: Жду Вашего хода 🤭",
|
nudgeBy: "%s: Жду Вашего хода 🤭",
|
||||||
|
|||||||
+2
-2
@@ -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 }) => {
|
test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
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('.vrules').first()).toBeVisible(); // per-variant rules summary
|
||||||
await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
|
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 }) => {
|
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.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
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
|
// 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.
|
// 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 });
|
const start = page.getByRole('button', { name: /Start game/i });
|
||||||
|
|||||||
@@ -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();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
|
||||||
// Below the limit: the New Game tab is enabled and no notice is shown.
|
// 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(newGame).toBeEnabled();
|
||||||
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { expect, test } from './fixtures';
|
|||||||
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
|
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
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
|
// 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).
|
// 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 }) => {
|
test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
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.
|
// 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();
|
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 }) => {
|
test('AI game: no GCG export offered after it ends', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
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.locator('.variant').first().click(); // AI is the default opponent
|
||||||
await page.getByRole('button', { name: /Start game/i }).click();
|
await page.getByRole('button', { name: /Start game/i }).click();
|
||||||
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
|
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> {
|
async function enterOpenGame(page: import('@playwright/test').Page): Promise<void> {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
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.getByRole('button', { name: 'Random player' }).click();
|
||||||
await page.locator('.variant').first().click();
|
await page.locator('.variant').first().click();
|
||||||
await page.getByRole('button', { name: /Start game/i }).click();
|
await page.getByRole('button', { name: /Start game/i }).click();
|
||||||
|
|||||||
+10
-2
@@ -53,11 +53,19 @@ test('invitations: the lobby shows an invitation and accepting clears it', async
|
|||||||
await expect(page.getByText(/From Kaya/)).toBeHidden();
|
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 loginLobby(page);
|
||||||
await page.getByRole('button', { name: /Stats/ }).click();
|
await page.getByRole('button', { name: /Stats/ }).click();
|
||||||
await expect(page.getByText('Win rate')).toBeVisible();
|
await expect(page.getByText('Win rate')).toBeVisible();
|
||||||
await expect(page.getByText('Best move')).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 }) => {
|
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 }) => {
|
test('play with friends: a game type is required to send an invitation', async ({ page }) => {
|
||||||
await loginLobby(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();
|
await page.getByRole('button', { name: 'Play with friends' }).click();
|
||||||
|
|
||||||
const send = page.getByRole('button', { name: 'Send invitation' });
|
const send = page.getByRole('button', { name: 'Send invitation' });
|
||||||
|
|||||||
@@ -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
@@ -18,6 +18,7 @@
|
|||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey } from '../lib/variants';
|
import { variantNameKey } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
|
import { hintsLeft } from '../lib/hints';
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
@@ -132,6 +133,10 @@
|
|||||||
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
||||||
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
||||||
const gameOver = $derived(!!view && view.game.status === 'finished');
|
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
|
// 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
|
// 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).
|
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
||||||
@@ -154,6 +159,13 @@
|
|||||||
return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action;
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
// 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(() => ''),
|
gateway.draftGet(id).catch(() => ''),
|
||||||
]);
|
]);
|
||||||
view = st;
|
view = st;
|
||||||
|
syncWallet(st.walletBalance);
|
||||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||||
seedChatUnread(id, st.game.unreadChat);
|
seedChatUnread(id, st.game.unreadChat);
|
||||||
moves = hist.moves;
|
moves = hist.moves;
|
||||||
@@ -615,7 +628,16 @@
|
|||||||
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
|
// 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.
|
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
||||||
function applyMoveResult(r: MoveResult) {
|
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
|
// 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.
|
// moving is already cleared server-side, so reconcile the unread flag from it.
|
||||||
seedChatUnread(id, r.game.unreadChat);
|
seedChatUnread(id, r.game.unreadChat);
|
||||||
@@ -698,7 +720,8 @@
|
|||||||
recenter++;
|
recenter++;
|
||||||
}
|
}
|
||||||
if (isCoarse()) zoomed = true;
|
if (isCoarse()) zoomed = true;
|
||||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
|
||||||
|
syncWallet(h.walletBalance);
|
||||||
recompute();
|
recompute();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1067,7 +1090,7 @@
|
|||||||
<div class="status">
|
<div class="status">
|
||||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
<strong class="over">{resultText()}</strong>
|
||||||
{:else if placement.pending.length === 0}
|
{:else if placement.pending.length === 0}
|
||||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1107,10 +1130,10 @@
|
|||||||
<TapConfirm
|
<TapConfirm
|
||||||
triggerClass="tab"
|
triggerClass="tab"
|
||||||
label={t('game.hint')}
|
label={t('game.hint')}
|
||||||
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
|
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
||||||
onconfirm={doHint}
|
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>
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
</TapConfirm>
|
</TapConfirm>
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export { Ack } from './scrabblefb/ack.js';
|
|||||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||||
export { BannerCampaign } from './scrabblefb/banner-campaign.js';
|
export { BannerCampaign } from './scrabblefb/banner-campaign.js';
|
||||||
export { BannerInfo } from './scrabblefb/banner-info.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 { BlockList } from './scrabblefb/block-list.js';
|
||||||
export { BlockStatus } from './scrabblefb/block-status.js';
|
export { BlockStatus } from './scrabblefb/block-status.js';
|
||||||
export { ChatList } from './scrabblefb/chat-list.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,13 @@ hintsRemaining():number {
|
|||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
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) {
|
static startHintResult(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
|
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
|
||||||
@@ -45,15 +50,20 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
|
|||||||
builder.addFieldInt32(1, hintsRemaining, 0);
|
builder.addFieldInt32(1, hintsRemaining, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
||||||
|
builder.addFieldInt32(2, walletBalance, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startHintResult(builder);
|
||||||
HintResult.addMove(builder, moveOffset);
|
HintResult.addMove(builder, moveOffset);
|
||||||
HintResult.addHintsRemaining(builder, hintsRemaining);
|
HintResult.addHintsRemaining(builder, hintsRemaining);
|
||||||
|
HintResult.addWalletBalance(builder, walletBalance);
|
||||||
return HintResult.endHintResult(builder);
|
return HintResult.endHintResult(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,13 @@ alphabetLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
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) {
|
static startStateView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(6);
|
builder.startObject(7);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||||
@@ -121,12 +126,16 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
||||||
|
builder.addFieldInt32(6, walletBalance, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startStateView(builder);
|
||||||
StateView.addGame(builder, gameOffset);
|
StateView.addGame(builder, gameOffset);
|
||||||
StateView.addSeat(builder, seat);
|
StateView.addSeat(builder, seat);
|
||||||
@@ -134,6 +143,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
|
|||||||
StateView.addBagLen(builder, bagLen);
|
StateView.addBagLen(builder, bagLen);
|
||||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||||
StateView.addAlphabet(builder, alphabetOffset);
|
StateView.addAlphabet(builder, alphabetOffset);
|
||||||
|
StateView.addWalletBalance(builder, walletBalance);
|
||||||
return StateView.endStateView(builder);
|
return StateView.endStateView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import * as flatbuffers from 'flatbuffers';
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
import { BestMoveView } from '../scrabblefb/best-move-view.js';
|
||||||
|
|
||||||
|
|
||||||
export class StatsView {
|
export class StatsView {
|
||||||
bb: flatbuffers.ByteBuffer|null = null;
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
bb_pos = 0;
|
bb_pos = 0;
|
||||||
@@ -45,8 +48,28 @@ maxWordPoints():number {
|
|||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
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) {
|
static startStatsView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(5);
|
builder.startObject(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addWins(builder:flatbuffers.Builder, wins:number) {
|
static addWins(builder:flatbuffers.Builder, wins:number) {
|
||||||
@@ -69,18 +92,45 @@ static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) {
|
|||||||
builder.addFieldInt32(4, maxWordPoints, 0);
|
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 {
|
static endStatsView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startStatsView(builder);
|
||||||
StatsView.addWins(builder, wins);
|
StatsView.addWins(builder, wins);
|
||||||
StatsView.addLosses(builder, losses);
|
StatsView.addLosses(builder, losses);
|
||||||
StatsView.addDraws(builder, draws);
|
StatsView.addDraws(builder, draws);
|
||||||
StatsView.addMaxGamePoints(builder, maxGamePoints);
|
StatsView.addMaxGamePoints(builder, maxGamePoints);
|
||||||
StatsView.addMaxWordPoints(builder, maxWordPoints);
|
StatsView.addMaxWordPoints(builder, maxWordPoints);
|
||||||
|
StatsView.addBestMoves(builder, bestMovesOffset);
|
||||||
|
StatsView.addMoves(builder, moves);
|
||||||
|
StatsView.addHintsUsed(builder, hintsUsed);
|
||||||
return StatsView.endStatsView(builder);
|
return StatsView.endStatsView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ describe('codec', () => {
|
|||||||
fb.StatsView.addDraws(b, 1);
|
fb.StatsView.addDraws(b, 1);
|
||||||
fb.StatsView.addMaxGamePoints(b, 420);
|
fb.StatsView.addMaxGamePoints(b, 420);
|
||||||
fb.StatsView.addMaxWordPoints(b, 90);
|
fb.StatsView.addMaxWordPoints(b, 90);
|
||||||
|
fb.StatsView.addMoves(b, 248);
|
||||||
|
fb.StatsView.addHintsUsed(b, 12);
|
||||||
b.finish(fb.StatsView.endStatsView(b));
|
b.finish(fb.StatsView.endStatsView(b));
|
||||||
expect(decodeStats(b.asUint8Array())).toEqual({
|
expect(decodeStats(b.asUint8Array())).toEqual({
|
||||||
wins: 7,
|
wins: 7,
|
||||||
@@ -291,6 +293,57 @@ describe('codec', () => {
|
|||||||
draws: 1,
|
draws: 1,
|
||||||
maxGamePoints: 420,
|
maxGamePoints: 420,
|
||||||
maxWordPoints: 90,
|
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
@@ -11,6 +11,8 @@ import type {
|
|||||||
AccountRef,
|
AccountRef,
|
||||||
Banner,
|
Banner,
|
||||||
BannerCampaign,
|
BannerCampaign,
|
||||||
|
BestMove,
|
||||||
|
BestMoveTile,
|
||||||
BlockStatus,
|
BlockStatus,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
EvalResult,
|
EvalResult,
|
||||||
@@ -381,6 +383,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
|
|||||||
rack,
|
rack,
|
||||||
bagLen: v.bagLen(),
|
bagLen: v.bagLen(),
|
||||||
hintsRemaining: v.hintsRemaining(),
|
hintsRemaining: v.hintsRemaining(),
|
||||||
|
walletBalance: v.walletBalance(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +410,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult {
|
|||||||
export function decodeHintResult(buf: Uint8Array): HintResult {
|
export function decodeHintResult(buf: Uint8Array): HintResult {
|
||||||
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
|
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
|
||||||
const m = r.move();
|
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 {
|
export function decodeEvalResult(buf: Uint8Array): EvalResult {
|
||||||
@@ -742,12 +745,26 @@ export function decodeRedeemResult(buf: Uint8Array): AccountRef {
|
|||||||
|
|
||||||
export function decodeStats(buf: Uint8Array): Stats {
|
export function decodeStats(buf: Uint8Array): Stats {
|
||||||
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
|
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 {
|
return {
|
||||||
wins: v.wins(),
|
wins: v.wins(),
|
||||||
losses: v.losses(),
|
losses: v.losses(),
|
||||||
draws: v.draws(),
|
draws: v.draws(),
|
||||||
maxGamePoints: v.maxGamePoints(),
|
maxGamePoints: v.maxGamePoints(),
|
||||||
maxWordPoints: v.maxWordPoints(),
|
maxWordPoints: v.maxWordPoints(),
|
||||||
|
moves: v.moves(),
|
||||||
|
hintsUsed: v.hintsUsed(),
|
||||||
|
bestMoves,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function gameView(id: string): GameView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function view(id: string, rack: string[] = ['A', 'B']): StateView {
|
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 {
|
function move(player: number): MoveRecord {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function move(player: number): MoveRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cache(moveCount: number, seat = 0, over = false): CachedGame {
|
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: [] };
|
return { view, moves: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
|
|||||||
|
|
||||||
describe('seedInitialState', () => {
|
describe('seedInitialState', () => {
|
||||||
it('wraps an initial view with an empty journal', () => {
|
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: [] });
|
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: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: 'opp', displayName: 'Opp', 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", () => {
|
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.
|
// 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());
|
const res = applyOpponentJoined(cached, joinedState());
|
||||||
expect(res?.view.game.status).toBe('active');
|
expect(res?.view.game.status).toBe('active');
|
||||||
expect(res?.view.game.seats).toHaveLength(2);
|
expect(res?.view.game.seats).toHaveLength(2);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export const en = {
|
|||||||
'lobby.noActive': 'No active games yet.',
|
'lobby.noActive': 'No active games yet.',
|
||||||
'lobby.noFinished': 'No finished games yet.',
|
'lobby.noFinished': 'No finished games yet.',
|
||||||
'lobby.limitReached': "You've reached the simultaneous games limit.",
|
'lobby.limitReached': "You've reached the simultaneous games limit.",
|
||||||
'lobby.new': 'New',
|
'lobby.new': 'Play',
|
||||||
'lobby.stats': 'Stats',
|
'lobby.stats': 'Stats',
|
||||||
'lobby.profile': 'Profile',
|
'lobby.profile': 'Profile',
|
||||||
'lobby.settings': 'Settings',
|
'lobby.settings': 'Settings',
|
||||||
@@ -83,7 +83,6 @@ export const en = {
|
|||||||
'game.passNoExchange': 'Pass without exchanging',
|
'game.passNoExchange': 'Pass without exchanging',
|
||||||
'game.confirmResign': 'Resign this game?',
|
'game.confirmResign': 'Resign this game?',
|
||||||
'game.hintShown': 'Best move: {word} for {n}',
|
'game.hintShown': 'Best move: {word} for {n}',
|
||||||
'game.over': 'Game over',
|
|
||||||
'game.won': 'You won',
|
'game.won': 'You won',
|
||||||
'game.lost': 'You lost',
|
'game.lost': 'You lost',
|
||||||
'game.tied': 'Draw',
|
'game.tied': 'Draw',
|
||||||
@@ -118,7 +117,7 @@ export const en = {
|
|||||||
'chat.nudge': 'Waiting for your move 🤭',
|
'chat.nudge': 'Waiting for your move 🤭',
|
||||||
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
|
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
|
||||||
'chat.nudgeAction': 'Nudge',
|
'chat.nudgeAction': 'Nudge',
|
||||||
'chat.awaitingReply': "Waiting for the opponent's reply",
|
'chat.awaitingReply': "Let's be patient",
|
||||||
'chat.empty': 'No messages yet.',
|
'chat.empty': 'No messages yet.',
|
||||||
'chat.nudged': '{name} nudged you',
|
'chat.nudged': '{name} nudged you',
|
||||||
'chat.sentThisTurn': 'You can write again next turn.',
|
'chat.sentThisTurn': 'You can write again next turn.',
|
||||||
@@ -275,6 +274,8 @@ export const en = {
|
|||||||
'stats.losses': 'Losses',
|
'stats.losses': 'Losses',
|
||||||
'stats.draws': 'Draws',
|
'stats.draws': 'Draws',
|
||||||
'stats.played': 'Games',
|
'stats.played': 'Games',
|
||||||
|
'stats.moves': 'Moves',
|
||||||
|
'stats.hintShare': 'Hint share',
|
||||||
'stats.winRate': 'Win rate',
|
'stats.winRate': 'Win rate',
|
||||||
'stats.maxGame': 'Best game',
|
'stats.maxGame': 'Best game',
|
||||||
'stats.maxWord': 'Best move',
|
'stats.maxWord': 'Best move',
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'lobby.noActive': 'Пока нет активных игр.',
|
'lobby.noActive': 'Пока нет активных игр.',
|
||||||
'lobby.noFinished': 'Пока нет завершённых игр.',
|
'lobby.noFinished': 'Пока нет завершённых игр.',
|
||||||
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
|
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
|
||||||
'lobby.new': 'Новая',
|
'lobby.new': 'Играть',
|
||||||
'lobby.stats': 'Статы',
|
'lobby.stats': 'Цифры',
|
||||||
'lobby.profile': 'Профиль',
|
'lobby.profile': 'Профиль',
|
||||||
'lobby.settings': 'Настройки',
|
'lobby.settings': 'Настройки',
|
||||||
'lobby.about': 'О программе',
|
'lobby.about': 'О программе',
|
||||||
@@ -56,7 +56,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
|
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
|
||||||
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
|
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
||||||
|
|
||||||
'game.bag': '{n} в мешке',
|
'game.bag': '{n} в мешке',
|
||||||
'game.bagEmpty': 'Мешок пуст',
|
'game.bagEmpty': 'Мешок пуст',
|
||||||
@@ -84,7 +84,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.passNoExchange': 'Пас без обмена',
|
'game.passNoExchange': 'Пас без обмена',
|
||||||
'game.confirmResign': 'Сдаться в этой игре?',
|
'game.confirmResign': 'Сдаться в этой игре?',
|
||||||
'game.hintShown': 'Лучший ход: {word} на {n}',
|
'game.hintShown': 'Лучший ход: {word} на {n}',
|
||||||
'game.over': 'Игра окончена',
|
|
||||||
'game.won': 'Вы выиграли',
|
'game.won': 'Вы выиграли',
|
||||||
'game.lost': 'Вы проиграли',
|
'game.lost': 'Вы проиграли',
|
||||||
'game.tied': 'Ничья',
|
'game.tied': 'Ничья',
|
||||||
@@ -119,7 +118,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'chat.nudge': 'Жду Вашего хода 🤭',
|
'chat.nudge': 'Жду Вашего хода 🤭',
|
||||||
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
|
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
|
||||||
'chat.nudgeAction': 'Поторопить',
|
'chat.nudgeAction': 'Поторопить',
|
||||||
'chat.awaitingReply': 'Ждём реакцию соперника',
|
'chat.awaitingReply': 'Немного терпения',
|
||||||
'chat.empty': 'Сообщений пока нет.',
|
'chat.empty': 'Сообщений пока нет.',
|
||||||
'chat.nudged': '{name} торопит вас',
|
'chat.nudged': '{name} торопит вас',
|
||||||
'chat.sentThisTurn': 'Можно написать снова в следующем ходу.',
|
'chat.sentThisTurn': 'Можно написать снова в следующем ходу.',
|
||||||
@@ -275,7 +274,9 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'stats.wins': 'Победы',
|
'stats.wins': 'Победы',
|
||||||
'stats.losses': 'Поражения',
|
'stats.losses': 'Поражения',
|
||||||
'stats.draws': 'Ничьи',
|
'stats.draws': 'Ничьи',
|
||||||
'stats.played': 'Игр',
|
'stats.played': 'Игры',
|
||||||
|
'stats.moves': 'Ходы',
|
||||||
|
'stats.hintShare': 'Доля подсказок',
|
||||||
'stats.winRate': 'Доля побед',
|
'stats.winRate': 'Доля побед',
|
||||||
'stats.maxGame': 'Лучшая игра',
|
'stats.maxGame': 'Лучшая игра',
|
||||||
'stats.maxWord': 'Лучший ход',
|
'stats.maxWord': 'Лучший ход',
|
||||||
|
|||||||
@@ -295,7 +295,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
seat: this.mySeat(g),
|
seat: this.mySeat(g),
|
||||||
rack: [...g.rack],
|
rack: [...g.rack],
|
||||||
bagLen: g.bagLen,
|
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> {
|
async hint(gameId: string): Promise<HintResult> {
|
||||||
const g = this.game(gameId);
|
const g = this.game(gameId);
|
||||||
if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable');
|
if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable');
|
||||||
g.hintsRemaining -= 1;
|
// 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';
|
const letter = g.rack.find((l) => l !== '?') ?? 'A';
|
||||||
return {
|
return {
|
||||||
move: {
|
move: {
|
||||||
@@ -411,7 +417,8 @@ export class MockGateway implements GatewayClient {
|
|||||||
score: valueForLetter(g.view.variant, letter),
|
score: valueForLetter(g.view.variant, letter),
|
||||||
total: 0,
|
total: 0,
|
||||||
},
|
},
|
||||||
hintsRemaining: g.hintsRemaining,
|
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
|
||||||
|
walletBalance: this.profile.hintBalance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -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_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[] {
|
export function mockInvitations(): Invitation[] {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+31
-2
@@ -67,13 +67,17 @@ export interface MoveRecord {
|
|||||||
total: number;
|
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 {
|
export interface StateView {
|
||||||
game: GameView;
|
game: GameView;
|
||||||
seat: number;
|
seat: number;
|
||||||
rack: string[];
|
rack: string[];
|
||||||
bagLen: number;
|
bagLen: number;
|
||||||
hintsRemaining: number;
|
hintsRemaining: number;
|
||||||
|
walletBalance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoveResult {
|
export interface MoveResult {
|
||||||
@@ -87,6 +91,7 @@ export interface MoveResult {
|
|||||||
export interface HintResult {
|
export interface HintResult {
|
||||||
move: MoveRecord;
|
move: MoveRecord;
|
||||||
hintsRemaining: number;
|
hintsRemaining: number;
|
||||||
|
walletBalance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvalResult {
|
export interface EvalResult {
|
||||||
@@ -206,13 +211,37 @@ export interface FriendCode {
|
|||||||
expiresAtUnix: number;
|
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 {
|
export interface Stats {
|
||||||
wins: number;
|
wins: number;
|
||||||
losses: number;
|
losses: number;
|
||||||
draws: number;
|
draws: number;
|
||||||
maxGamePoints: number;
|
maxGamePoints: number;
|
||||||
maxWordPoints: 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. */
|
/** Settings the inviter chooses for a friend game. */
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stateView(id: string): StateView {
|
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(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { gamesPlayed, winRate } from './stats';
|
import { gamesPlayed, hintSharePercent, winRate } from './stats';
|
||||||
import type { Stats } from './model';
|
import type { Stats } from './model';
|
||||||
|
|
||||||
const s = (wins: number, losses: number, draws: number): Stats => ({
|
const s = (wins: number, losses: number, draws: number): Stats => ({
|
||||||
@@ -8,8 +8,14 @@ const s = (wins: number, losses: number, draws: number): Stats => ({
|
|||||||
draws,
|
draws,
|
||||||
maxGamePoints: 0,
|
maxGamePoints: 0,
|
||||||
maxWordPoints: 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', () => {
|
describe('stats', () => {
|
||||||
it('sums games played', () => {
|
it('sums games played', () => {
|
||||||
expect(gamesPlayed(s(7, 4, 1))).toBe(12);
|
expect(gamesPlayed(s(7, 4, 1))).toBe(12);
|
||||||
@@ -23,4 +29,14 @@ describe('stats', () => {
|
|||||||
it('win rate is 0 with no games', () => {
|
it('win rate is 0 with no games', () => {
|
||||||
expect(winRate(s(0, 0, 0))).toBe(0);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,3 +12,10 @@ export function winRate(s: Stats): number {
|
|||||||
const n = gamesPlayed(s);
|
const n = gamesPlayed(s);
|
||||||
return n > 0 ? Math.round((s.wins / n) * 100) : 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@
|
|||||||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" onclick={() => navigate('/stats')}>
|
<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>
|
||||||
<button class="tab" onclick={() => navigate('/settings')}>
|
<button class="tab" onclick={() => navigate('/settings')}>
|
||||||
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
|
import WordTiles from '../components/WordTiles.svelte';
|
||||||
import { app, handleError } from '../lib/app.svelte';
|
import { app, handleError } from '../lib/app.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { gamesPlayed, winRate } from '../lib/stats';
|
import { gamesPlayed, hintSharePercent, winRate } from '../lib/stats';
|
||||||
import type { Stats } from '../lib/model';
|
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);
|
let stats = $state<Stats | null>(null);
|
||||||
|
|
||||||
@@ -21,16 +32,25 @@
|
|||||||
const cards = $derived<{ key: MessageKey; value: string | number }[]>(
|
const cards = $derived<{ key: MessageKey; value: string | number }[]>(
|
||||||
stats
|
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.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.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>
|
</script>
|
||||||
|
|
||||||
<Screen title={t('stats.title')} back="/">
|
<Screen title={t('stats.title')} back="/">
|
||||||
@@ -46,6 +66,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Screen>
|
</Screen>
|
||||||
@@ -79,4 +111,33 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user