From ed7c6ac56d876f9a1980c4cd320b971ae6b34aa8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 23:56:04 +0200 Subject: [PATCH] fix(merge): carry moves/hints and per-variant best moves on account merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account merge predated the recent statistics additions and silently dropped them: - account_stats.moves and hints_used were not summed into the primary (the secondary's row is read, then deleted), so the secondary's lifetime moves and hints were lost. - account_best_move was not handled at all; the secondary is only tombstoned (not deleted), so its per-variant best moves lingered on the dead account and never reached the merged statistics screen. mergeStats now also sums moves + hints_used; a new mergeBestMoves folds the secondary's best moves into the primary keeping the higher-scoring play per variant (the rule the per-game upsert uses), then deletes the secondary's rows. Profile fields were already correct (hint wallet summed, paid_account ORed; no new accounts columns since). Tests: TestAccountMergeCore extended — moves/hints summed, best moves merged (higher score per variant kept), secondary rows cleaned up. Docs: ARCHITECTURE §4. --- backend/internal/accountmerge/merge.go | 50 +++++++++++++++++++++-- backend/internal/inttest/merge_test.go | 55 ++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 3 +- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/backend/internal/accountmerge/merge.go b/backend/internal/accountmerge/merge.go index 21933f2..216e373 100644 --- a/backend/internal/accountmerge/merge.go +++ b/backend/internal/accountmerge/merge.go @@ -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 { diff --git a/backend/internal/inttest/merge_test.go b/backend/internal/inttest/merge_test.go index 8f1f906..7c537f9 100644 --- a/backend/internal/inttest/merge_test.go +++ b/backend/internal/inttest/merge_test.go @@ -30,6 +30,34 @@ func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) { } } +func setStatsCounts(t *testing.T, id uuid.UUID, moves, hints int) { + t.Helper() + if _, err := testDB.ExecContext(context.Background(), + `UPDATE backend.account_stats SET moves=$2, hints_used=$3 WHERE account_id=$1`, id, moves, hints); err != nil { + t.Fatalf("set stats counts: %v", err) + } +} + +func setBestMove(t *testing.T, id uuid.UUID, variant string, score int) { + t.Helper() + tiles := `[{"letter":"c","value":3,"blank":false}]` + if _, err := testDB.ExecContext(context.Background(), + `INSERT INTO backend.account_best_move (account_id, variant, score, tiles) VALUES ($1,$2,$3,$4) + ON CONFLICT (account_id, variant) DO UPDATE SET score=$3, tiles=$4`, id, variant, score, tiles); err != nil { + t.Fatalf("set best move: %v", err) + } +} + +func bestMoveCount(t *testing.T, id uuid.UUID) int { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.account_best_move WHERE account_id=$1`, id).Scan(&n); err != nil { + t.Fatalf("best move count: %v", err) + } + return n +} + func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) { t.Helper() if _, err := testDB.ExecContext(context.Background(), @@ -110,8 +138,15 @@ func TestAccountMergeCore(t *testing.T) { setStats(t, primary, 1, 0, 0, 100, 90) setStats(t, secondary, 3, 1, 2, 400, 80) + setStatsCounts(t, primary, 100, 5) + setStatsCounts(t, secondary, 50, 8) setWallet(t, primary, 2, false) setWallet(t, secondary, 5, true) + // Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's + // scrabble_ru (30) is new to primary and carried over. + setBestMove(t, primary, "scrabble_en", 50) + setBestMove(t, secondary, "scrabble_en", 80) + setBestMove(t, secondary, "scrabble_ru", 30) email := "merge-" + uuid.NewString() + "@example.com" bindEmailIdentity(t, secondary, email) @@ -153,6 +188,26 @@ func TestAccountMergeCore(t *testing.T) { if mergedInto(t, secondary) != primary { t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary) } + + // The moves/hints counters sum, and the per-variant best moves merge (the higher score + // per variant kept), with the secondary's best-move rows cleaned up. + st, err := store.GetStats(ctx, primary) + if err != nil { + t.Fatalf("get merged stats: %v", err) + } + if st.Moves != 150 || st.HintsUsed != 13 { + t.Errorf("primary moves/hints = %d/%d, want 150/13", st.Moves, st.HintsUsed) + } + best := map[string]int{} + for _, b := range st.BestMoves { + best[b.Variant] = b.Score + } + if len(st.BestMoves) != 2 || best["scrabble_en"] != 80 || best["scrabble_ru"] != 30 { + t.Errorf("primary best moves = %+v, want scrabble_en:80 + scrabble_ru:30", st.BestMoves) + } + if bestMoveCount(t, secondary) != 0 { + t.Error("secondary best-move rows should be deleted after merge") + } } // TestAccountMergeActiveGameConflict refuses a merge when the two share an active diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0050fd9..2f30438 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -203,7 +203,8 @@ arrive from a platform rather than completing a mandatory registration). guest is promoted to durable, clearing `is_guest`). - **Merge** retires the account that owns the linked identity into the **current** account, in a single transaction (`internal/accountmerge`): statistics summed - (max points kept), the hint wallet summed, `paid_account` ORed, identities + (counters incl. moves/hints added, max points kept, and the per-variant best moves + merged keeping the higher-scoring play), the hint wallet summed, `paid_account` ORed, identities repointed, games / chat / complaints transferred, friends and blocks de-duplicated (friendships keep the strongest status accepted>pending>declined), pending invitations/codes dropped, and the secondary kept as an **audit