diff --git a/backend/internal/accountdelete/delete.go b/backend/internal/accountdelete/delete.go index cab653f..b36c28e 100644 --- a/backend/internal/accountdelete/delete.go +++ b/backend/internal/accountdelete/delete.go @@ -25,8 +25,10 @@ import ( // AnonymizedName is the label a deleted account shows to opponents. Display names are // stored strings resolved identically for every viewer (no per-viewer localisation in this -// codebase), so a single canonical label is used. -const AnonymizedName = "Удалённый игрок" +// codebase), so a single canonical label is used. The brackets are deliberate: the +// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a +// name that impersonates a deleted account. +const AnonymizedName = "[Deleted]" // retainDelete is the retained_identities reason written when a credential is journalled // because its account is being deleted. @@ -70,6 +72,38 @@ func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID }) } +// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a +// human — a robot seat is one whose account holds a 'robot' identity, so this covers both +// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their +// moves/chat/players/complaints fall away through ON DELETE CASCADE. +const dropAllRobotGamesSQL = ` +DELETE FROM games g +WHERE EXISTS ( + SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1 +) AND NOT EXISTS ( + SELECT 1 FROM game_players o + WHERE o.game_id = g.game_id AND o.account_id <> $1 + AND NOT EXISTS ( + SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot' + ) +)` + +// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or +// auto-match-robot games), returning how many were removed. Games with any human seat are +// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the +// account's active games are resigned, so no live game is removed under the robot driver. +func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) { + res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID) + if err != nil { + return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("accountdelete: dropped games count: %w", err) + } + return n, nil +} + // journalAndDropIdentities copies the account's live identities into the retention journal // (reason=delete) and then removes them, freeing each (kind, external_id) for reuse. func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 553b90f..04697fa 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -12,6 +12,8 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/accountdelete" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" ) // deletedFields reads a tombstoned account's retained real name and its deleted_at. @@ -90,3 +92,44 @@ func TestAnonymizeAndTombstone(t *testing.T) { t.Fatalf("email should be free after deletion, got: %v", err) } } + +// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human +// opponent. +func TestDropAllRobotGames(t *testing.T) { + ctx := context.Background() + gsvc := newGameService() + robots := newRobotService(t, gsvc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + mm := newMatchmaker(t, robots, time.Minute, 0) + deleter := accountdelete.NewDeleter(testDB) + + user := provisionAccount(t) + other := provisionAccount(t) + + aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("start vs AI: %v", err) + } + humanGame, err := gsvc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1, + }) + if err != nil { + t.Fatalf("create human game: %v", err) + } + + n, err := deleter.DropAllRobotGames(ctx, user) + if err != nil { + t.Fatalf("drop: %v", err) + } + if n != 1 { + t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n) + } + if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil { + t.Error("the vs-AI game should be dropped") + } + if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil { + t.Errorf("the human game should be kept, got: %v", err) + } +}