feat(accountdelete): drop all-robot games + unspoofable [Deleted] label

Q2=B: DropAllRobotGames deletes the deletee's games with no human opponent (vs-AI or
auto-match-robot; children cascade), keeping games with any human seat (anonymized
instead). Q1=A: the anon label is the sentinel [Deleted] — the editable-name rule forbids
brackets, so no live player can impersonate a deleted account. Integration test covers
drop-vs-keep.
This commit is contained in:
Ilia Denisov
2026-07-03 13:02:14 +02:00
parent d889edfdb9
commit fcde7d3db6
2 changed files with 79 additions and 2 deletions
+36 -2
View File
@@ -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 {
+43
View File
@@ -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)
}
}