feat(stats): per-game hints used + lifetime moves and hint share
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
Store the hints a player used in each game, and add two lifetime tiles — Moves and Hint share — to the statistics screen. - per-game: game_players.hints_used now counts EVERY hint (allowance + wallet), not just the free allowance, so it is the seat's true total hints used this game. The allowance decision (used < HintsPerPlayer) and the hint badge values (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does NOT regress; only the admin "hints used" column, which silently under-counted wallet hints, becomes accurate. - account_stats gains two summed counters: moves (the player's plays — passes and exchanges excluded) and hints_used (every hint). Computed at game finish in buildStats over the same non-guest, non-honest-AI games as the rest of the stats. - wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model; regen. - ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate. - docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN. Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats (moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
This commit is contained in:
@@ -46,7 +46,11 @@ type Stats struct {
|
|||||||
Draws int
|
Draws int
|
||||||
MaxGamePoints int
|
MaxGamePoints int
|
||||||
MaxWordPoints int
|
MaxWordPoints int
|
||||||
BestMoves []BestMove
|
// Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the
|
||||||
|
// lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves).
|
||||||
|
Moves int
|
||||||
|
HintsUsed int
|
||||||
|
BestMoves []BestMove
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStats returns the lifetime statistics for id. An account with no account_stats
|
// GetStats returns the lifetime statistics for id. An account with no account_stats
|
||||||
@@ -74,6 +78,8 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
|
|||||||
Draws: int(row.Draws),
|
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,
|
BestMoves: best,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +1037,13 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
}
|
}
|
||||||
walletAfter--
|
walletAfter--
|
||||||
}
|
}
|
||||||
|
// hints_used is the per-game total (allowance + wallet): every hint increments it. The first
|
||||||
|
// HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest
|
||||||
|
// are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics.
|
||||||
|
if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil {
|
||||||
|
return HintResult{}, err
|
||||||
|
}
|
||||||
|
used++
|
||||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
|
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,10 +1431,12 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
|||||||
res := g.Result()
|
res := g.Result()
|
||||||
bestRec := make(map[int]engine.MoveRecord)
|
bestRec := make(map[int]engine.MoveRecord)
|
||||||
blanks := make(map[[2]int]bool)
|
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 {
|
if rec.Action != engine.ActionPlay {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
plays[rec.Player]++
|
||||||
for _, t := range rec.Tiles {
|
for _, t := range rec.Tiles {
|
||||||
if t.Blank {
|
if t.Blank {
|
||||||
blanks[[2]int{t.Row, t.Col}] = true
|
blanks[[2]int{t.Row, t.Col}] = true
|
||||||
@@ -1446,7 +1450,9 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
|||||||
values := letterValues(g.Variant())
|
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)}
|
// 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 {
|
if rec, ok := bestRec[s.Seat]; ok {
|
||||||
d.wordPoints = rec.Score
|
d.wordPoints = rec.Score
|
||||||
if rec.Score > 0 {
|
if rec.Score > 0 {
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ 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
|
bestVariant string
|
||||||
bestScore int
|
bestScore int
|
||||||
bestTiles []account.BestMoveTile
|
bestTiles []account.BestMoveTile
|
||||||
@@ -729,13 +731,17 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
|
|||||||
draws := row.Draws + int32(d.draws)
|
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)
|
||||||
@@ -774,8 +780,10 @@ func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpendHintAllowance increments a seat's per-game hint counter by one.
|
// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for
|
||||||
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
|
// 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))).
|
||||||
@@ -784,7 +792,7 @@ func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat i
|
|||||||
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
|
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
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get stats: %v", err)
|
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 {
|
if len(st.BestMoves) != 1 {
|
||||||
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
|
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
|
||||||
}
|
}
|
||||||
@@ -429,6 +437,15 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
||||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
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{
|
||||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
@@ -38,6 +38,8 @@ type statsDTO struct {
|
|||||||
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"`
|
BestMoves []bestMoveDTO `json:"best_moves,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +167,8 @@ func (s *Server) handleStats(c *gin.Context) {
|
|||||||
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,
|
BestMoves: best,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,7 +603,10 @@ 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
|
||||||
|
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`**
|
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
|
(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
|
variant** with the word itself: its main word as an ordered JSON array of tiles
|
||||||
|
|||||||
+3
-1
@@ -229,7 +229,9 @@ honest-AI practice game (a live game's export would leak the move journal; an AI
|
|||||||
game is throwaway). The client shares the `.gcg` file where the platform supports
|
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**
|
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
|
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.
|
the player has played, omitting variants with no plays.
|
||||||
|
|||||||
@@ -233,7 +233,9 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
|||||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
||||||
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||||
|
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||||
|
**доля подсказок** (процент таких ходов, на которых бралась подсказка).
|
||||||
Лучший ход также даётся **с разбивкой по вариантам игры** — **само слово**,
|
Лучший ход также даётся **с разбивкой по вариантам игры** — **само слово**,
|
||||||
нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной
|
нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной
|
||||||
строке на каждый сыгранный вариант; варианты без ходов не выводятся.
|
строке на каждый сыгранный вариант; варианты без ходов не выводятся.
|
||||||
|
|||||||
+4
-2
@@ -280,8 +280,10 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's
|
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) — pure numbers, no
|
cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) —
|
||||||
charts — followed by a **full-width "best move" card** that breaks the best move
|
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
|
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**
|
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;
|
(`components/WordTiles.svelte`, the board's placed-tile look at a small fixed size;
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ type StatsResp struct {
|
|||||||
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"`
|
BestMoves []BestMoveResp `json:"best_moves"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ func encodeStats(r backendclient.StatsResp) []byte {
|
|||||||
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 {
|
if len(r.BestMoves) > 0 {
|
||||||
fb.StatsViewAddBestMoves(b, bestMoves)
|
fb.StatsViewAddBestMoves(b, bestMoves)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ func TestStatsRoundTrip(t *testing.T) {
|
|||||||
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":[` +
|
`"best_moves":[{"variant":"scrabble_en","score":90,"word":[` +
|
||||||
`{"letter":"c","value":3,"blank":false},` +
|
`{"letter":"c","value":3,"blank":false},` +
|
||||||
`{"letter":"a","value":0,"blank":true},` +
|
`{"letter":"a","value":0,"blank":true},` +
|
||||||
@@ -212,6 +213,9 @@ func TestStatsRoundTrip(t *testing.T) {
|
|||||||
if st.Wins() != 5 || st.Losses() != 3 || st.Draws() != 1 || st.MaxGamePoints() != 420 || st.MaxWordPoints() != 90 {
|
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 {
|
if st.BestMovesLength() != 1 {
|
||||||
t.Fatalf("best moves length = %d, want 1", st.BestMovesLength())
|
t.Fatalf("best moves length = %d, want 1", st.BestMovesLength())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -497,6 +497,10 @@ table StatsView {
|
|||||||
max_game_points:int;
|
max_game_points:int;
|
||||||
max_word_points:int;
|
max_word_points:int;
|
||||||
best_moves:[BestMoveView];
|
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,
|
||||||
|
|||||||
@@ -121,8 +121,32 @@ func (rcv *StatsView) BestMovesLength() int {
|
|||||||
return 0
|
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(6)
|
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)
|
||||||
@@ -145,6 +169,12 @@ func StatsViewAddBestMoves(builder *flatbuffers.Builder, bestMoves flatbuffers.U
|
|||||||
func StatsViewStartBestMovesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func StatsViewStartBestMovesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ test('stats screen shows the metrics and the per-variant best move', async ({ pa
|
|||||||
await page.getByRole('button', { name: /Stats/ }).click();
|
await 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
|
// 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).
|
// 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('Scrabble', { exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -58,8 +58,18 @@ bestMovesLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
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(6);
|
builder.startObject(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addWins(builder:flatbuffers.Builder, wins:number) {
|
static addWins(builder:flatbuffers.Builder, wins:number) {
|
||||||
@@ -98,12 +108,20 @@ static startBestMovesVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
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, bestMovesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number, bestMovesOffset:flatbuffers.Offset, moves:number, hintsUsed:number):flatbuffers.Offset {
|
||||||
StatsView.startStatsView(builder);
|
StatsView.startStatsView(builder);
|
||||||
StatsView.addWins(builder, wins);
|
StatsView.addWins(builder, wins);
|
||||||
StatsView.addLosses(builder, losses);
|
StatsView.addLosses(builder, losses);
|
||||||
@@ -111,6 +129,8 @@ static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number,
|
|||||||
StatsView.addMaxGamePoints(builder, maxGamePoints);
|
StatsView.addMaxGamePoints(builder, maxGamePoints);
|
||||||
StatsView.addMaxWordPoints(builder, maxWordPoints);
|
StatsView.addMaxWordPoints(builder, maxWordPoints);
|
||||||
StatsView.addBestMoves(builder, bestMovesOffset);
|
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,8 @@ describe('codec', () => {
|
|||||||
draws: 1,
|
draws: 1,
|
||||||
maxGamePoints: 420,
|
maxGamePoints: 420,
|
||||||
maxWordPoints: 90,
|
maxWordPoints: 90,
|
||||||
|
moves: 248,
|
||||||
|
hintsUsed: 12,
|
||||||
bestMoves: [],
|
bestMoves: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -328,6 +332,8 @@ describe('codec', () => {
|
|||||||
draws: 0,
|
draws: 0,
|
||||||
maxGamePoints: 0,
|
maxGamePoints: 0,
|
||||||
maxWordPoints: 0,
|
maxWordPoints: 0,
|
||||||
|
moves: 0,
|
||||||
|
hintsUsed: 0,
|
||||||
bestMoves: [
|
bestMoves: [
|
||||||
{
|
{
|
||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
|
|||||||
@@ -762,6 +762,8 @@ export function decodeStats(buf: Uint8Array): Stats {
|
|||||||
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,
|
bestMoves,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,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',
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'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': 'Лучший ход',
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export const MOCK_STATS: Stats = {
|
|||||||
draws: 1,
|
draws: 1,
|
||||||
maxGamePoints: 421,
|
maxGamePoints: 421,
|
||||||
maxWordPoints: 134,
|
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
|
// 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.
|
// 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.
|
// Erudit is absent on purpose, so the screen demonstrates skipping a not-yet-played variant.
|
||||||
|
|||||||
@@ -237,6 +237,10 @@ export interface Stats {
|
|||||||
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[];
|
bestMoves: BestMove[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +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: [],
|
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);
|
||||||
@@ -24,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,20 @@
|
|||||||
import WordTiles from '../components/WordTiles.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 { ALL_VARIANTS, variantNameKey } from '../lib/variants';
|
import { ALL_VARIANTS, variantNameKey } from '../lib/variants';
|
||||||
import type { BestMove, Stats } from '../lib/model';
|
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);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -23,12 +32,14 @@
|
|||||||
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.winRate', value: `${winRate(stats)}%` },
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user