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

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

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

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
This commit was merged in pull request #81.
This commit is contained in:
2026-06-17 22:17:27 +00:00
parent 5a3f0951ae
commit 8793bd34f2
71 changed files with 1789 additions and 132 deletions
+46 -4
View File
@@ -1,5 +1,6 @@
// Package accountmerge retires a secondary account into a primary one in a single
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
// transaction: it sums statistics (merging the per-variant best moves), sums the hint
// wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
// (accounts.merged_into). It is the data core of account linking & merge
@@ -68,6 +69,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
return out, nil
}
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
// summed, max points kept) and deletes the secondary row.
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.AccountStats
err := postgres.SELECT(table.AccountStats.AllColumns).
@@ -178,13 +182,16 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
).SET(
postgres.Int(int64(pri.Wins+sec.Wins)),
postgres.Int(int64(pri.Losses+sec.Losses)),
postgres.Int(int64(pri.Draws+sec.Draws)),
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
postgres.Int(int64(pri.Moves+sec.Moves)),
postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
@@ -198,6 +205,41 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
return nil
}
// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
// they would linger on a dead account and never reach the merged statistics screen.
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var srows []model.AccountBestMove
err := postgres.SELECT(table.AccountBestMove.AllColumns).
FROM(table.AccountBestMove).
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
}
for _, s := range srows {
ins := table.AccountBestMove.
INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
DO_UPDATE(postgres.SET(
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
}
}
del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
}
return nil
}
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// all other profile fields stay the primary's.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {