From 9471341a0e4ca43c269e34a05c20b877827e6fa1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 27 Jul 2026 17:06:10 +0200 Subject: [PATCH] feat(social): hide social controls on a deleted opponent's seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deleted account keeps its seats in every shared game, so its opponents still saw the add-friend and block controls on the scoreboard (deletion drops the friendship, so the 🤝 even reappeared for a former friend) and a chat composer nobody was behind. A friend request sent that way was accepted by the server and stayed pending forever. The per-viewer game views now mark such a seat (SeatView.deleted, resolved beside the seat display names by a batch accounts.deleted_at lookup) and the client hides every control aimed at it: add-friend, block, and the chat composer (message + nudge) once no reachable opponent is left. Live events carry the game domain's seat standings and so leave the mark unset, so the delta reducers preserve the cached one. SendFriendRequest and Block against a tombstone are refused with social.ErrAccountDeleted (410 account_deleted) — the source of truth for an older client. --- backend/internal/account/account.go | 6 + backend/internal/account/retention.go | 26 +++++ backend/internal/inttest/delete_test.go | 114 +++++++++++++++++++ backend/internal/server/dto.go | 4 + backend/internal/server/handlers.go | 2 + backend/internal/server/handlers_game.go | 40 +++++++ backend/internal/server/handlers_user.go | 2 + backend/internal/social/blocks.go | 10 ++ backend/internal/social/friends.go | 5 + backend/internal/social/social.go | 5 + docs/ARCHITECTURE.md | 10 ++ docs/FUNCTIONAL.md | 7 +- docs/FUNCTIONAL_ru.md | 7 +- gateway/internal/backendclient/api.go | 4 +- gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/transcode_test.go | 10 +- pkg/fbs/scrabble.fbs | 5 +- pkg/fbs/scrabblefb/SeatView.go | 17 ++- pkg/wire/build.go | 4 + ui/src/game/Chat.svelte | 4 +- ui/src/game/ChatScreen.svelte | 11 +- ui/src/game/Game.svelte | 22 +++- ui/src/gen/fbs/scrabblefb/seat-view.ts | 14 ++- ui/src/lib/codec.test.ts | 3 + ui/src/lib/codec.ts | 1 + ui/src/lib/gamedelta.test.ts | 41 +++++++ ui/src/lib/gamedelta.ts | 17 ++- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/model.ts | 4 + 30 files changed, 375 insertions(+), 23 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index e4826d5..1dcb901 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -71,6 +71,11 @@ type Account struct { // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade // foreign keys of a shared finished game stay valid. MergedInto uuid.UUID + // Deleted marks a tombstoned account (accounts.deleted_at set): its credentials are + // journalled and freed and its live surfaces anonymised, but the row survives for the + // no-cascade foreign keys. Nobody is behind it any more, so it takes part in no social + // exchange (docs/ARCHITECTURE.md §9.1). + Deleted bool // FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the // zero time for an unflagged account, otherwise when the gateway-reported // rate-limiter rejections first crossed the sustained threshold. An @@ -630,6 +635,7 @@ func modelToAccount(row model.Accounts) Account { IsGuest: row.IsGuest, NotificationsInAppOnly: row.NotificationsInAppOnly, MergedInto: mergedInto, + Deleted: row.DeletedAt != nil, FlaggedHighRateAt: flaggedHighRateAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go index b6f161e..e57cbe9 100644 --- a/backend/internal/account/retention.go +++ b/backend/internal/account/retention.go @@ -180,6 +180,32 @@ func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (Deletion return info, nil } +// DeletedIDs returns the subset of ids whose accounts are tombstoned (deleted_at set), as a +// set. It is the batch form of Account.Deleted for a caller holding several ids at once — +// the game views resolve every seat's deleted mark in one round-trip. An empty ids slice +// queries nothing and returns an empty set; an unknown id is simply absent from the result. +func (s *Store) DeletedIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]bool, error) { + out := map[uuid.UUID]bool{} + if len(ids) == 0 { + return out, nil + } + exprs := make([]postgres.Expression, len(ids)) + for i, id := range ids { + exprs[i] = postgres.UUID(id) + } + stmt := postgres.SELECT(table.Accounts.AccountID). + FROM(table.Accounts). + WHERE(table.Accounts.AccountID.IN(exprs...).AND(table.Accounts.DeletedAt.IS_NOT_NULL())) + var rows []model.Accounts + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("account: deleted ids: %w", err) + } + for _, r := range rows { + out[r.AccountID] = true + } + return out, nil +} + // RetentionReaper periodically purges expired account-deletion retention data via // Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once // from main. diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 984321c..6e7e6a2 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -5,17 +5,22 @@ package inttest import ( "context" "database/sql" + "encoding/json" "errors" + "net/http" "strings" "testing" "time" "github.com/google/uuid" + "go.uber.org/zap/zaptest" "scrabble/backend/internal/account" "scrabble/backend/internal/accountdelete" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/server" + "scrabble/backend/internal/social" ) // deletedFields reads a tombstoned account's retained real name and its deleted_at. @@ -277,6 +282,115 @@ func TestDropAllRobotGames(t *testing.T) { } } +// TestDeletedAccountRefusesSocialActions: once an opponent's account is deleted, neither a +// friend request nor a block may target it — the client hides both controls on a deleted +// seat, and the server refuses them for an older client. The seat's deleted mark that drives +// that hiding is resolved by Store.DeletedIDs. +func TestDeletedAccountRefusesSocialActions(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + svc := newSocialService() + deleter := accountdelete.NewDeleter(testDB) + + _, seats := newGameWithSeats(t, 2) // a shared game: the befriend-an-opponent gate + viewer, gone := seats[0], seats[1] + + if err := svc.SendFriendRequest(ctx, viewer, gone); err != nil { + t.Fatalf("friend request before deletion = %v, want nil", err) + } + if err := deleter.AnonymizeAndTombstone(ctx, gone); err != nil { + t.Fatalf("delete: %v", err) + } + + // Deletion dropped the pending request with the rest of the account's social rows, so + // this is a fresh request against the tombstone — exactly what an older client sends + // after the 🤝 reappears on the finished game's scoreboard. + if err := svc.SendFriendRequest(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) { + t.Errorf("friend request to a deleted account = %v, want ErrAccountDeleted", err) + } + if err := svc.Block(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) { + t.Errorf("block of a deleted account = %v, want ErrAccountDeleted", err) + } + // The live opponent stays actionable — the guard is per-account, not a blanket switch. + live := provisionAccount(t) + if err := svc.Block(ctx, viewer, live); err != nil { + t.Errorf("block of a live account = %v, want nil", err) + } + + deleted, err := store.DeletedIDs(ctx, []uuid.UUID{viewer, gone}) + if err != nil { + t.Fatalf("deleted ids: %v", err) + } + if !deleted[gone] { + t.Error("the deleted account must be marked deleted") + } + if deleted[viewer] { + t.Error("a live account must not be marked deleted") + } +} + +// TestGameStateMarksDeletedSeat: the per-viewer game state marks the seat of a deleted +// opponent, which is what hides the in-game social controls (add-friend, block, chat, nudge) +// on the client; the viewer's own live seat stays unmarked. +func TestGameStateMarksDeletedSeat(t *testing.T) { + ctx := context.Background() + games := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + g, err := games.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + srv := server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: account.NewStore(testDB), + Games: games, + }) + viewer, gone := seats[0], seats[1] + + if marks := stateSeatDeleted(t, srv, g.ID, viewer); marks[0] || marks[1] { + t.Fatalf("before deletion no seat may be marked deleted, got %v", marks) + } + if err := accountdelete.NewDeleter(testDB).AnonymizeAndTombstone(ctx, gone); err != nil { + t.Fatalf("delete: %v", err) + } + marks := stateSeatDeleted(t, srv, g.ID, viewer) + if marks[0] { + t.Error("the viewer's own live seat must not be marked deleted") + } + if !marks[1] { + t.Error("the deleted opponent's seat must be marked deleted") + } +} + +// stateSeatDeleted fetches viewer's game state and returns the seats' deleted marks, indexed +// by seat. +func stateSeatDeleted(t *testing.T, srv *server.Server, gameID, viewer uuid.UUID) map[int]bool { + t.Helper() + rec := userGet(t, srv, "/api/v1/user/games/"+gameID.String()+"/state", viewer) + if rec.Code != http.StatusOK { + t.Fatalf("game state = %d (%s), want 200", rec.Code, rec.Body.String()) + } + var body struct { + Game struct { + Seats []struct { + Seat int `json:"seat"` + Deleted bool `json:"deleted"` + } `json:"seats"` + } `json:"game"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode game state: %v", err) + } + out := map[int]bool{} + for _, s := range body.Game.Seats { + out[s.Seat] = s.Deleted + } + return out +} + // TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no // deeplink in the mail); a platform-only account has no email and cannot request a code. func TestDeleteStepUpEmail(t *testing.T) { diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index a4c2dba..23b7ce2 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -132,6 +132,10 @@ type seatDTO struct { Score int `json:"score"` HintsUsed int `json:"hints_used"` IsWinner bool `json:"is_winner"` + // Deleted marks a seat whose account has been deleted: the client hides every social + // control aimed at it (add-friend, block, chat, nudge). gameDTOFromGame leaves it false + // (it does not reach the account store); fillSeatDeleted fills it. + Deleted bool `json:"deleted"` } // gameDTO is the shared game summary. diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 5176daf..f8dece0 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -314,6 +314,8 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "request_exists" case errors.Is(err, social.ErrRequestBlocked): return http.StatusForbidden, "request_blocked" + case errors.Is(err, social.ErrAccountDeleted): + return http.StatusGone, "account_deleted" case errors.Is(err, social.ErrRequestNotFound): return http.StatusNotFound, "request_not_found" case errors.Is(err, social.ErrNoSharedGame): diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index b3a25cd..afd39a6 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "go.uber.org/zap" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" @@ -97,6 +98,42 @@ func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string] } } +// fillSeatDeleted marks each seat whose account has been deleted, memoising across seats and +// games within one request (the same shape as fillSeatNames) and resolving the seats still +// unknown in a single batch query. A seat with no account (an open game's empty seat) is +// skipped. It is best-effort: on a store error the seats keep the false the projection left, +// which only leaves the social controls as they were before. +func (s *Server) fillSeatDeleted(ctx context.Context, g *gameDTO, memo map[string]bool) { + var ask []uuid.UUID + for _, seat := range g.Seats { + if seat.AccountID == "" { + continue + } + if _, ok := memo[seat.AccountID]; ok { + continue + } + uid, err := uuid.Parse(seat.AccountID) + if err != nil { + memo[seat.AccountID] = false + continue + } + memo[seat.AccountID] = false // resolved below; a failed lookup stays false + ask = append(ask, uid) + } + if len(ask) > 0 { + deleted, err := s.accounts.DeletedIDs(ctx, ask) + if err != nil { + s.log.Warn("seat deleted marks failed", zap.Error(err)) + } + for id := range deleted { + memo[id.String()] = true + } + } + for i := range g.Seats { + g.Seats[i].Deleted = memo[g.Seats[i].AccountID] + } +} + // moveRecordDTOFromHistory projects a journal move into the shared move DTO. func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO { tiles := make([]tileDTO, 0, len(m.Tiles)) @@ -435,6 +472,7 @@ func (s *Server) handleListGames(c *gin.Context) { return } memo := map[string]string{} + delMemo := map[string]bool{} // Two queries seed the lobby's per-card unread badge for every listed game: which games have // any unread entry, and which of those have a real message (so the badge can colour // message-bearing games apart from nudge-only ones). @@ -447,6 +485,7 @@ func (s *Server) handleListGames(c *gin.Context) { for _, g := range games { dto := gameDTOFromGame(g) s.fillSeatNames(c.Request.Context(), &dto, memo) + s.fillSeatDeleted(c.Request.Context(), &dto, delMemo) dto.UnreadChat = unread[g.ID] dto.UnreadMessages = unreadMsg[g.ID] out = append(out, dto) @@ -526,6 +565,7 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) { return } s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) + s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{}) if uid, ok := userID(c); ok { s.setUnreadChat(c.Request.Context(), &dto.Game, uid) } diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 493dc59..937b838 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -137,6 +137,7 @@ func (s *Server) handleGameState(c *gin.Context) { return } s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) + s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{}) s.setUnreadChat(c.Request.Context(), &dto.Game, uid) c.JSON(http.StatusOK, dto) } @@ -207,6 +208,7 @@ func (s *Server) handleEnqueue(c *gin.Context) { dto := matchDTOFrom(res) if dto.Game != nil { s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{}) + s.fillSeatDeleted(c.Request.Context(), dto.Game, map[string]bool{}) } c.JSON(http.StatusOK, dto) } diff --git a/backend/internal/social/blocks.go b/backend/internal/social/blocks.go index 998e805..3863640 100644 --- a/backend/internal/social/blocks.go +++ b/backend/internal/social/blocks.go @@ -28,6 +28,16 @@ func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) e if blockerID == blockedID { return ErrSelfRelation } + // A deleted account sends nothing there is anything left to suppress, and the block would + // outlive the retention purge as a dangling row: refuse it. The client already hides the + // control on a deleted seat; this guard catches an older one. + blocked, err := svc.accounts.GetByID(ctx, blockedID) + if err != nil { + return err + } + if blocked.Deleted { + return ErrAccountDeleted + } if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil { return err } diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index c48b8cf..badc977 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -73,6 +73,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse } return err } + if addressee.Deleted { + // The addressee deleted their account: there is nobody to answer the request, so it is + // refused rather than left pending forever. + return ErrAccountDeleted + } if iBlockThem { // The requester has blocked the addressee — they are the blocker and aware of it. return ErrRequestBlocked diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index d704e80..661880a 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -62,6 +62,11 @@ var ( // ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend // request, redeem a friend code); friends are a durable-account feature. ErrGuestForbidden = errors.New("social: guests cannot use friends") + // ErrAccountDeleted is returned when a social action targets a deleted (tombstoned) + // account: nobody is behind it any more, so it can be neither befriended nor blocked. + // The client already hides those controls on a deleted seat; this is the server source + // of truth, and the guard an older client hits. + ErrAccountDeleted = errors.New("social: this account has been deleted") // ErrRequestNotFound is returned when no pending friend request matches. ErrRequestNotFound = errors.New("social: no pending friend request") // ErrNoSharedGame is returned when a friend request targets someone the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 916f54e..4f95ad3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -398,6 +398,16 @@ arrive from a platform rather than completing a mandatory registration). full timeline. (A merge that would otherwise leave the survivor with two identities of one kind — e.g. each account held a confirmed email — keeps the primary's and journals the secondary's with `reason=merge` before dropping it.) + A tombstoned account **takes part in no further social exchange**: the per-viewer game + views mark its seats (`SeatView.deleted`, resolved by a batch `accounts.deleted_at` + lookup beside the seat display names), and the client hides every control aimed at such a + seat — add-friend, block, and the chat composer (message + nudge) once no reachable + opponent is left. The live events carry the seat standings the game domain holds and so + leave the mark unset; the client seeds it from the REST-backed views and the delta + reducers preserve it. The server is the source of truth for an older client: + `SendFriendRequest` and `Block` against a tombstone return `social.ErrAccountDeleted` + (HTTP 410, code `account_deleted`) — otherwise a request would hang pending forever, + since deletion already dropped the account's social rows. Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete; `ConfirmByToken` refuses a delete token) for an email account, else a typed phrase. `last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 69f6dbe..f82a2d8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -374,7 +374,12 @@ goes **disabled** while a request is pending or was declined and **disappears** friends; both controls **disappear and the opponent's name is struck through** once you have blocked them. They update in place the moment the relationship changes and stay correct across reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a -live event; a transport failure rolls the control back to its prior state. +live event; a transport failure rolls the control back to its prior state. Once an opponent +**deletes their account** there is nobody left to reach, so both controls disappear for that +seat for good — in the finished game as much as in one still running — and the chat composer +goes too (message and nudge) when no reachable opponent remains; the chat log and the game +itself stay readable. Attempting either action anyway (an app version that still shows the +controls) is refused with *This player has deleted their account*. Block globally — switch off incoming chat and/or friend requests — or block an **individual player**. A per-user block is **one-directional and silent**: you stop receiving everything diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 8254c5e..ee3e542 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -377,7 +377,12 @@ Telegram/VK** — в отличие от офлайн-режима игры вы **исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым** после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым -событием; при сбое транспорта контрол возвращается в прежнее состояние. +событием; при сбое транспорта контрол возвращается в прежнее состояние. Если соперник **удалил +свою учётную запись**, обращаться больше не к кому: оба контрола на этом месте исчезают +навсегда — и в завершённой партии, и в идущей, — а когда доступных соперников не остаётся, +пропадает и поле чата (сообщение и nudge); сама переписка и партия остаются доступны для +чтения. Попытка выполнить действие всё равно (версия приложения, которая ещё показывает +контролы) отклоняется сообщением *Игрок удалил свою учётную запись*. Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index e4bf934..a41b6ab 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -153,7 +153,8 @@ type MoveRecordResp struct { Total int `json:"total"` } -// SeatResp is one seat's public standing. +// SeatResp is one seat's public standing. Deleted marks a seat whose account has been +// deleted, so the client can hide the social controls aimed at it. type SeatResp struct { Seat int `json:"seat"` AccountID string `json:"account_id"` @@ -161,6 +162,7 @@ type SeatResp struct { Score int `json:"score"` HintsUsed int `json:"hints_used"` IsWinner bool `json:"is_winner"` + Deleted bool `json:"deleted"` } // GameResp is the shared game summary. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 8f9f9aa..c219408 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -618,6 +618,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView { HintsUsed: s.HintsUsed, IsWinner: s.IsWinner, DisplayName: s.DisplayName, + Deleted: s.Deleted, } } return wire.GameView{ diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index 267b60e..15783c0 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -300,7 +300,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if r.URL.Path != "/api/v1/user/games" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"unread_messages":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`)) + _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"unread_messages":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7,"deleted":true}]}],"at_game_limit":true}`)) }) defer cleanup() @@ -333,6 +333,14 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if string(seat.DisplayName()) != "Ann" { t.Errorf("seat display name = %q, want Ann", seat.DisplayName()) } + if !seat.Deleted() { + t.Error("seat deleted = false, want true (the backend marked the seat's account deleted)") + } + var own fb.SeatView + g.Seats(&own, 0) + if own.Deleted() { + t.Error("seat deleted = true for a live account, want false") + } if !gl.AtGameLimit() { t.Error("at_game_limit = false, want true (the backend reported the cap)") } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 55ef736..5f2c553 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -44,7 +44,9 @@ table AlphabetEntry { } // SeatView is one seat's public standing in a game. display_name is resolved by the -// backend from the account store (added trailing — backward-compatible). +// backend from the account store (added trailing — backward-compatible). deleted marks a +// seat whose account has been deleted (tombstoned): the client hides every social control +// aimed at it — add-friend, block, chat and nudge (added trailing). table SeatView { seat:int; account_id:string; @@ -52,6 +54,7 @@ table SeatView { hints_used:int; is_winner:bool; display_name:string; + deleted:bool; } // GameView is the shared (non-private) game summary. diff --git a/pkg/fbs/scrabblefb/SeatView.go b/pkg/fbs/scrabblefb/SeatView.go index f9d78e2..d52976f 100644 --- a/pkg/fbs/scrabblefb/SeatView.go +++ b/pkg/fbs/scrabblefb/SeatView.go @@ -105,8 +105,20 @@ func (rcv *SeatView) DisplayName() []byte { return nil } +func (rcv *SeatView) Deleted() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *SeatView) MutateDeleted(n bool) bool { + return rcv._tab.MutateBoolSlot(16, n) +} + func SeatViewStart(builder *flatbuffers.Builder) { - builder.StartObject(6) + builder.StartObject(7) } func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) { builder.PrependInt32Slot(0, seat, 0) @@ -126,6 +138,9 @@ func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) { func SeatViewAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(displayName), 0) } +func SeatViewAddDeleted(builder *flatbuffers.Builder, deleted bool) { + builder.PrependBoolSlot(6, deleted, false) +} func SeatViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 5fd62dd..3ab30d8 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -26,6 +26,9 @@ type SeatView struct { HintsUsed int IsWinner bool DisplayName string + // Deleted marks a seat whose account has been deleted (tombstoned): the client hides + // every social control aimed at it — add-friend, block, chat and nudge. + Deleted bool } // GameView is the shared, non-private game summary. @@ -142,6 +145,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) fb.SeatViewAddIsWinner(b, s.IsWinner) fb.SeatViewAddDisplayName(b, dname) + fb.SeatViewAddDeleted(b, s.Deleted) seatOffs[i] = fb.SeatViewEnd(b) } fb.GameViewStartSeatsVector(b, len(seatOffs)) diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index e58199d..df74b30 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -40,8 +40,8 @@ // nobody to message or hurry. The fields still show (turn-driven), but disabled. vsAi?: boolean; // blocked hides the whole composer (message field, send and nudge), leaving only the chat - // log — as in a finished game — when the viewer has blocked the opponent: there is no one - // they will message (the backend rejects it too). + // log — as in a finished game — when no opponent can be reached: the viewer has blocked them + // (the backend rejects it too), or their account has been deleted. blocked?: boolean; onsend: (text: string) => void; onnudge: () => void; diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 1beef2c..49bd8aa 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -36,15 +36,16 @@ const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat); // While the auto-match game still has no opponent, chat and nudge are both disabled. const waiting = $derived(!!view && view.game.status === 'open'); - // peerBlocked is true when every seated opponent is one the viewer has blocked: the whole - // composer is then hidden (a blocked opponent cannot be messaged). - const peerBlocked = $derived.by(() => { + // peerUnreachable is true when no seated opponent can be reached — each is either one the viewer + // has blocked or a deleted account: the whole composer (message field, send and nudge) is then + // hidden, leaving only the chat log. + const peerUnreachable = $derived.by(() => { const v = view; if (!v) return false; const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId); return ( opponents.length > 0 && - opponents.every((s) => blockedIds.has(s.accountId) || blockedRobotSeats.has(s.seat)) + opponents.every((s) => s.deleted || blockedIds.has(s.accountId) || blockedRobotSeats.has(s.seat)) ); }); const nudgeCooldownSecs = 3600; @@ -155,7 +156,7 @@ {waiting} {nudgeOnCooldown} vsAi={!!view && view.game.vsAi} - blocked={peerBlocked} + blocked={peerUnreachable} onsend={sendChat} onnudge={nudge} /> diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2e21ee4..47be9db 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1472,13 +1472,15 @@ // canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent // (not the still-empty seat of an open game) who is not yet a friend (an already-requested // opponent still shows it, but disabled). - function canAddFriend(s: { accountId: string; seat: number }): boolean { - // Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in - // a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids. + function canAddFriend(s: { accountId: string; seat: number; deleted?: boolean }): boolean { + // Never offer add-friend against an AI opponent, an existing friend, a blocked player or a + // deleted account — nor in a local (offline) game, whose seats are account-less: vs_ai, and + // hotseat's synthetic seat ids. if (view?.game.vsAi || isLocalGameId(id)) return false; return ( netReady && !!s.accountId && + !s.deleted && !app.profile?.isGuest && s.accountId !== app.session?.userId && !friends.has(s.accountId) && @@ -1488,10 +1490,18 @@ // canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend // may still be blocked (the block overrides the friendship), so it omits the friend exclusion. - // An already-blocked opponent hides it (both controls go, and the name is struck). - function canBlock(s: { accountId: string; seat: number }): boolean { + // An already-blocked opponent hides it (both controls go, and the name is struck); a deleted + // account hides it too — there is nobody left to block. + function canBlock(s: { accountId: string; seat: number; deleted?: boolean }): boolean { if (view?.game.vsAi || isLocalGameId(id)) return false; - return netReady && !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s); + return ( + netReady && + !!s.accountId && + !s.deleted && + !app.profile?.isGuest && + s.accountId !== app.session?.userId && + !seatBlocked(s) + ); } diff --git a/ui/src/gen/fbs/scrabblefb/seat-view.ts b/ui/src/gen/fbs/scrabblefb/seat-view.ts index 9d253be..48adff6 100644 --- a/ui/src/gen/fbs/scrabblefb/seat-view.ts +++ b/ui/src/gen/fbs/scrabblefb/seat-view.ts @@ -54,8 +54,13 @@ displayName(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +deleted():boolean { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + static startSeatView(builder:flatbuffers.Builder) { - builder.startObject(6); + builder.startObject(7); } static addSeat(builder:flatbuffers.Builder, seat:number) { @@ -82,12 +87,16 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers builder.addFieldOffset(5, displayNameOffset, 0); } +static addDeleted(builder:flatbuffers.Builder, deleted:boolean) { + builder.addFieldInt8(6, +deleted, +false); +} + static endSeatView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:flatbuffers.Offset, score:number, hintsUsed:number, isWinner:boolean, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset { +static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:flatbuffers.Offset, score:number, hintsUsed:number, isWinner:boolean, displayNameOffset:flatbuffers.Offset, deleted:boolean):flatbuffers.Offset { SeatView.startSeatView(builder); SeatView.addSeat(builder, seat); SeatView.addAccountId(builder, accountIdOffset); @@ -95,6 +104,7 @@ static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset: SeatView.addHintsUsed(builder, hintsUsed); SeatView.addIsWinner(builder, isWinner); SeatView.addDisplayName(builder, displayNameOffset); + SeatView.addDeleted(builder, deleted); return SeatView.endSeatView(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index bec02e8..fc7fdd3 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -400,6 +400,7 @@ describe('codec', () => { fb.SeatView.addHintsUsed(b, 0); fb.SeatView.addIsWinner(b, false); fb.SeatView.addDisplayName(b, dn); + fb.SeatView.addDeleted(b, true); const seat = fb.SeatView.endSeatView(b); const seats = fb.GameView.createSeatsVector(b, [seat]); const id = b.createString('g1'); @@ -434,6 +435,8 @@ describe('codec', () => { expect(gl.games[0].id).toBe('g1'); expect(gl.games[0].seats[0].displayName).toBe('Ann'); expect(gl.games[0].seats[0].score).toBe(13); + // The deleted mark rides the seat: it hides the social controls aimed at that seat. + expect(gl.games[0].seats[0].deleted).toBe(true); expect(gl.games[0].lastActivityUnix).toBe(1717000000); expect(gl.games[0].vsAi).toBe(true); expect(gl.games[0].unreadChat).toBe(true); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index d4135c4..1a3500a 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -291,6 +291,7 @@ function decodeSeat(v: fb.SeatView): Seat { score: v.score(), hintsUsed: v.hintsUsed(), isWinner: v.isWinner(), + deleted: v.deleted(), }; } diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index b3d0760..5349f8c 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -103,6 +103,47 @@ describe('applyGameOver', () => { }); }); +describe('deleted seat marks', () => { + // The live events carry no deleted mark (only the REST-backed views resolve it), so a delta must + // not clear the mark the cache already holds — otherwise the social controls aimed at a deleted + // opponent reappear until the next cold load. + function cacheWithDeletedSeat(moveCount: number): CachedGame { + const c = cache(moveCount); + c.view.game.seats = [ + { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false }, + { seat: 1, accountId: 'gone', displayName: '[Deleted]', score: 0, hintsUsed: 0, isWinner: false, deleted: true }, + ]; + return c; + } + + function eventSeats(): GameView['seats'] { + return [ + { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false }, + { seat: 1, accountId: 'gone', displayName: '[Deleted]', score: 12, hintsUsed: 0, isWinner: false }, + ]; + } + + it('keeps a deleted seat marked across a move delta', () => { + const d = delta(4, 1); + d.game = { ...gameView(4), seats: eventSeats() }; + const res = applyMoveDelta(cacheWithDeletedSeat(3), d); + expect(res.cache?.view.game.seats.map((s) => !!s.deleted)).toEqual([false, true]); + expect(res.cache?.view.game.seats[1].score).toBe(12); // the event's own fields still win + }); + + it('keeps a deleted seat marked across the final summary', () => { + const final: GameView = { ...gameView(3, true), seats: eventSeats() }; + const res = applyGameOver(cacheWithDeletedSeat(3), final); + expect(res.cache?.view.game.seats.map((s) => !!s.deleted)).toEqual([false, true]); + }); + + it('leaves the event seats untouched when no cached seat is deleted', () => { + const final: GameView = { ...gameView(3, true), seats: eventSeats() }; + const res = applyGameOver(cache(3), final); + expect(res.cache?.view.game.seats).toBe(final.seats); + }); +}); + function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent { return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen }; } diff --git a/ui/src/lib/gamedelta.ts b/ui/src/lib/gamedelta.ts index b5ead5c..c6e8bdb 100644 --- a/ui/src/lib/gamedelta.ts +++ b/ui/src/lib/gamedelta.ts @@ -23,6 +23,19 @@ export interface DeltaResult { refetch: boolean; } +/** + * keepDeletedMarks carries the seats' deleted marks from the cached game over to a game view that + * arrived on the live stream. The mark says the seat's account has been deleted; only the REST-backed + * views resolve it (the events carry the seat standings the game domain holds, with no account-store + * lookup), so without this a live move or the final summary would clear it and the social controls + * aimed at a deleted seat would reappear until the next cold load. + */ +function keepDeletedMarks(prev: GameView, next: GameView): GameView { + if (!prev.seats.some((s) => s.deleted)) return next; + const wasDeleted = new Set(prev.seats.filter((s) => s.deleted).map((s) => s.seat)); + return { ...next, seats: next.seats.map((s) => (wasDeleted.has(s.seat) ? { ...s, deleted: true } : s)) }; +} + /** * seedInitialState builds a fresh cache entry from a started game's initial view (match_found / * game_started). A freshly started game has no moves, so the board replays from an empty journal. @@ -50,7 +63,7 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed // The actor's own move changed their rack (a draw), which opponent_moved does not carry. if (d.move.player === cached.view.seat) return { refetch: true }; - const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen }; + const view: StateView = { ...cached.view, game: keepDeletedMarks(cached.view.game, d.game), bagLen: d.bagLen }; return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false }; } @@ -64,7 +77,7 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u if (!cached) return { refetch: false }; if (!game) return { refetch: cached.view.game.status !== 'finished' }; if (cached.view.game.moveCount < game.moveCount) return { refetch: true }; - const view: StateView = { ...cached.view, game }; + const view: StateView = { ...cached.view, game: keepDeletedMarks(cached.view.game, game) }; return { cache: { view, moves: cached.moves }, refetch: false }; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index ef4ed52..55fe818 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -434,6 +434,7 @@ export const en = { 'error.request_not_found': 'No matching friend request.', 'error.no_shared_game': 'You can only add someone you have played with.', 'error.request_declined': 'This player declined your request.', + 'error.account_deleted': 'This player has deleted their account.', 'error.friend_code_invalid': 'That friend code is invalid or expired.', 'error.invalid_invitation': 'Invalid invitation.', 'error.invitation_blocked': 'You cannot invite this player.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index d29fc5d..1af501d 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -434,6 +434,7 @@ export const ru: Record = { 'error.request_not_found': 'Подходящей заявки нет.', 'error.no_shared_game': 'Можно добавить только того, с кем вы играли.', 'error.request_declined': 'Игрок отклонил вашу заявку.', + 'error.account_deleted': 'Игрок удалил свою учётную запись.', 'error.friend_code_invalid': 'Код недействителен или истёк.', 'error.invalid_invitation': 'Неверное приглашение.', 'error.invitation_blocked': 'Нельзя пригласить этого игрока.', diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 34f1146..a5d90df 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -32,6 +32,10 @@ export interface Seat { /** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so * the finished-game medal ranking can place it last (no medal); undefined for online seats. */ resigned?: boolean; + /** The seat's account has been deleted: every social control aimed at it is hidden (add-friend, + * block, chat, nudge) — there is nobody behind it any more. Carried by the REST-backed views + * only; the live events leave it unset, so the delta reducers preserve the cached value. */ + deleted?: boolean; } export interface GameView {