feat(stats): show the best move word per game variant #81
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user