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 {