feat(social): hide social controls on a deleted opponent's seat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s

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.
This commit is contained in:
Ilia Denisov
2026-07-27 17:06:10 +02:00
parent 9506f89d9e
commit 9471341a0e
30 changed files with 375 additions and 23 deletions
+6
View File
@@ -71,6 +71,11 @@ type Account struct {
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid. // foreign keys of a shared finished game stay valid.
MergedInto uuid.UUID 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 // FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported // zero time for an unflagged account, otherwise when the gateway-reported
// rate-limiter rejections first crossed the sustained threshold. An // rate-limiter rejections first crossed the sustained threshold. An
@@ -630,6 +635,7 @@ func modelToAccount(row model.Accounts) Account {
IsGuest: row.IsGuest, IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly, NotificationsInAppOnly: row.NotificationsInAppOnly,
MergedInto: mergedInto, MergedInto: mergedInto,
Deleted: row.DeletedAt != nil,
FlaggedHighRateAt: flaggedHighRateAt, FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
+26
View File
@@ -180,6 +180,32 @@ func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (Deletion
return info, nil 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 // RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once // Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main. // from main.
+114
View File
@@ -5,17 +5,22 @@ package inttest
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"net/http"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete" "scrabble/backend/internal/accountdelete"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "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. // 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 // 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. // deeplink in the mail); a platform-only account has no email and cannot request a code.
func TestDeleteStepUpEmail(t *testing.T) { func TestDeleteStepUpEmail(t *testing.T) {
+4
View File
@@ -132,6 +132,10 @@ type seatDTO struct {
Score int `json:"score"` Score int `json:"score"`
HintsUsed int `json:"hints_used"` HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"` 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. // gameDTO is the shared game summary.
+2
View File
@@ -314,6 +314,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "request_exists" return http.StatusConflict, "request_exists"
case errors.Is(err, social.ErrRequestBlocked): case errors.Is(err, social.ErrRequestBlocked):
return http.StatusForbidden, "request_blocked" return http.StatusForbidden, "request_blocked"
case errors.Is(err, social.ErrAccountDeleted):
return http.StatusGone, "account_deleted"
case errors.Is(err, social.ErrRequestNotFound): case errors.Is(err, social.ErrRequestNotFound):
return http.StatusNotFound, "request_not_found" return http.StatusNotFound, "request_not_found"
case errors.Is(err, social.ErrNoSharedGame): case errors.Is(err, social.ErrNoSharedGame):
+40
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "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. // moveRecordDTOFromHistory projects a journal move into the shared move DTO.
func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO { func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO {
tiles := make([]tileDTO, 0, len(m.Tiles)) tiles := make([]tileDTO, 0, len(m.Tiles))
@@ -435,6 +472,7 @@ func (s *Server) handleListGames(c *gin.Context) {
return return
} }
memo := map[string]string{} 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 // 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 // any unread entry, and which of those have a real message (so the badge can colour
// message-bearing games apart from nudge-only ones). // message-bearing games apart from nudge-only ones).
@@ -447,6 +485,7 @@ func (s *Server) handleListGames(c *gin.Context) {
for _, g := range games { for _, g := range games {
dto := gameDTOFromGame(g) dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo) s.fillSeatNames(c.Request.Context(), &dto, memo)
s.fillSeatDeleted(c.Request.Context(), &dto, delMemo)
dto.UnreadChat = unread[g.ID] dto.UnreadChat = unread[g.ID]
dto.UnreadMessages = unreadMsg[g.ID] dto.UnreadMessages = unreadMsg[g.ID]
out = append(out, dto) out = append(out, dto)
@@ -526,6 +565,7 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
return return
} }
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) 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 { if uid, ok := userID(c); ok {
s.setUnreadChat(c.Request.Context(), &dto.Game, uid) s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
} }
+2
View File
@@ -137,6 +137,7 @@ func (s *Server) handleGameState(c *gin.Context) {
return return
} }
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) 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) s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
c.JSON(http.StatusOK, dto) c.JSON(http.StatusOK, dto)
} }
@@ -207,6 +208,7 @@ func (s *Server) handleEnqueue(c *gin.Context) {
dto := matchDTOFrom(res) dto := matchDTOFrom(res)
if dto.Game != nil { if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{}) 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) c.JSON(http.StatusOK, dto)
} }
+10
View File
@@ -28,6 +28,16 @@ func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) e
if blockerID == blockedID { if blockerID == blockedID {
return ErrSelfRelation 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 { if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
return err return err
} }
+5
View File
@@ -73,6 +73,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
} }
return err 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 { if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it. // The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked return ErrRequestBlocked
+5
View File
@@ -62,6 +62,11 @@ var (
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend // 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. // request, redeem a friend code); friends are a durable-account feature.
ErrGuestForbidden = errors.New("social: guests cannot use friends") 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 is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request") ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the // ErrNoSharedGame is returned when a friend request targets someone the
+10
View File
@@ -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 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 kind — e.g. each account held a confirmed email — keeps the primary's and journals the
secondary's with `reason=merge` before dropping it.) 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; 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. `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 `last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled
+6 -1
View File
@@ -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 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 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 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 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 player**. A per-user block is **one-directional and silent**: you stop receiving everything
+6 -1
View File
@@ -377,7 +377,12 @@ Telegram/VK** — в отличие от офлайн-режима игры вы
**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым** **исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым**
после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после
перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым
событием; при сбое транспорта контрол возвращается в прежнее состояние. событием; при сбое транспорта контрол возвращается в прежнее состояние. Если соперник **удалил
свою учётную запись**, обращаться больше не к кому: оба контрола на этом месте исчезают
навсегда — и в завершённой партии, и в идущей, — а когда доступных соперников не остаётся,
пропадает и поле чата (сообщение и nudge); сама переписка и партия остаются доступны для
чтения. Попытка выполнить действие всё равно (версия приложения, которая ещё показывает
контролы) отклоняется сообщением *Игрок удалил свою учётную запись*.
Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного
игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека
+3 -1
View File
@@ -153,7 +153,8 @@ type MoveRecordResp struct {
Total int `json:"total"` 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 { type SeatResp struct {
Seat int `json:"seat"` Seat int `json:"seat"`
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
@@ -161,6 +162,7 @@ type SeatResp struct {
Score int `json:"score"` Score int `json:"score"`
HintsUsed int `json:"hints_used"` HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"` IsWinner bool `json:"is_winner"`
Deleted bool `json:"deleted"`
} }
// GameResp is the shared game summary. // GameResp is the shared game summary.
+1
View File
@@ -618,6 +618,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
HintsUsed: s.HintsUsed, HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner, IsWinner: s.IsWinner,
DisplayName: s.DisplayName, DisplayName: s.DisplayName,
Deleted: s.Deleted,
} }
} }
return wire.GameView{ return wire.GameView{
+9 -1
View File
@@ -300,7 +300,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if r.URL.Path != "/api/v1/user/games" { if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
@@ -333,6 +333,14 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if string(seat.DisplayName()) != "Ann" { if string(seat.DisplayName()) != "Ann" {
t.Errorf("seat display name = %q, want Ann", seat.DisplayName()) 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() { if !gl.AtGameLimit() {
t.Error("at_game_limit = false, want true (the backend reported the cap)") t.Error("at_game_limit = false, want true (the backend reported the cap)")
} }
+4 -1
View File
@@ -44,7 +44,9 @@ table AlphabetEntry {
} }
// SeatView is one seat's public standing in a game. display_name is resolved by the // 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 { table SeatView {
seat:int; seat:int;
account_id:string; account_id:string;
@@ -52,6 +54,7 @@ table SeatView {
hints_used:int; hints_used:int;
is_winner:bool; is_winner:bool;
display_name:string; display_name:string;
deleted:bool;
} }
// GameView is the shared (non-private) game summary. // GameView is the shared (non-private) game summary.
+16 -1
View File
@@ -105,8 +105,20 @@ func (rcv *SeatView) DisplayName() []byte {
return nil 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) { func SeatViewStart(builder *flatbuffers.Builder) {
builder.StartObject(6) builder.StartObject(7)
} }
func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) { func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) {
builder.PrependInt32Slot(0, seat, 0) builder.PrependInt32Slot(0, seat, 0)
@@ -126,6 +138,9 @@ func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) {
func SeatViewAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { func SeatViewAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(displayName), 0) 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 { func SeatViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+4
View File
@@ -26,6 +26,9 @@ type SeatView struct {
HintsUsed int HintsUsed int
IsWinner bool IsWinner bool
DisplayName string 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. // 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.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
fb.SeatViewAddIsWinner(b, s.IsWinner) fb.SeatViewAddIsWinner(b, s.IsWinner)
fb.SeatViewAddDisplayName(b, dname) fb.SeatViewAddDisplayName(b, dname)
fb.SeatViewAddDeleted(b, s.Deleted)
seatOffs[i] = fb.SeatViewEnd(b) seatOffs[i] = fb.SeatViewEnd(b)
} }
fb.GameViewStartSeatsVector(b, len(seatOffs)) fb.GameViewStartSeatsVector(b, len(seatOffs))
+2 -2
View File
@@ -40,8 +40,8 @@
// nobody to message or hurry. The fields still show (turn-driven), but disabled. // nobody to message or hurry. The fields still show (turn-driven), but disabled.
vsAi?: boolean; vsAi?: boolean;
// blocked hides the whole composer (message field, send and nudge), leaving only the chat // 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 // log — as in a finished game — when no opponent can be reached: the viewer has blocked them
// they will message (the backend rejects it too). // (the backend rejects it too), or their account has been deleted.
blocked?: boolean; blocked?: boolean;
onsend: (text: string) => void; onsend: (text: string) => void;
onnudge: () => void; onnudge: () => void;
+6 -5
View File
@@ -36,15 +36,16 @@
const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat); 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. // While the auto-match game still has no opponent, chat and nudge are both disabled.
const waiting = $derived(!!view && view.game.status === 'open'); const waiting = $derived(!!view && view.game.status === 'open');
// peerBlocked is true when every seated opponent is one the viewer has blocked: the whole // peerUnreachable is true when no seated opponent can be reached — each is either one the viewer
// composer is then hidden (a blocked opponent cannot be messaged). // has blocked or a deleted account: the whole composer (message field, send and nudge) is then
const peerBlocked = $derived.by(() => { // hidden, leaving only the chat log.
const peerUnreachable = $derived.by(() => {
const v = view; const v = view;
if (!v) return false; if (!v) return false;
const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId); const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId);
return ( return (
opponents.length > 0 && 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; const nudgeCooldownSecs = 3600;
@@ -155,7 +156,7 @@
{waiting} {waiting}
{nudgeOnCooldown} {nudgeOnCooldown}
vsAi={!!view && view.game.vsAi} vsAi={!!view && view.game.vsAi}
blocked={peerBlocked} blocked={peerUnreachable}
onsend={sendChat} onsend={sendChat}
onnudge={nudge} onnudge={nudge}
/> />
+16 -6
View File
@@ -1472,13 +1472,15 @@
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent // 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 // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean { function canAddFriend(s: { accountId: string; seat: number; deleted?: boolean }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in // Never offer add-friend against an AI opponent, an existing friend, a blocked player or a
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids. // 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; if (view?.game.vsAi || isLocalGameId(id)) return false;
return ( return (
netReady && netReady &&
!!s.accountId && !!s.accountId &&
!s.deleted &&
!app.profile?.isGuest && !app.profile?.isGuest &&
s.accountId !== app.session?.userId && s.accountId !== app.session?.userId &&
!friends.has(s.accountId) && !friends.has(s.accountId) &&
@@ -1488,10 +1490,18 @@
// canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend // 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. // 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). // An already-blocked opponent hides it (both controls go, and the name is struck); a deleted
function canBlock(s: { accountId: string; seat: number }): boolean { // 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; 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)
);
} }
</script> </script>
+12 -2
View File
@@ -54,8 +54,13 @@ displayName(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : 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) { static startSeatView(builder:flatbuffers.Builder) {
builder.startObject(6); builder.startObject(7);
} }
static addSeat(builder:flatbuffers.Builder, seat:number) { static addSeat(builder:flatbuffers.Builder, seat:number) {
@@ -82,12 +87,16 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers
builder.addFieldOffset(5, displayNameOffset, 0); builder.addFieldOffset(5, displayNameOffset, 0);
} }
static addDeleted(builder:flatbuffers.Builder, deleted:boolean) {
builder.addFieldInt8(6, +deleted, +false);
}
static endSeatView(builder:flatbuffers.Builder):flatbuffers.Offset { static endSeatView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startSeatView(builder);
SeatView.addSeat(builder, seat); SeatView.addSeat(builder, seat);
SeatView.addAccountId(builder, accountIdOffset); SeatView.addAccountId(builder, accountIdOffset);
@@ -95,6 +104,7 @@ static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:
SeatView.addHintsUsed(builder, hintsUsed); SeatView.addHintsUsed(builder, hintsUsed);
SeatView.addIsWinner(builder, isWinner); SeatView.addIsWinner(builder, isWinner);
SeatView.addDisplayName(builder, displayNameOffset); SeatView.addDisplayName(builder, displayNameOffset);
SeatView.addDeleted(builder, deleted);
return SeatView.endSeatView(builder); return SeatView.endSeatView(builder);
} }
} }
+3
View File
@@ -400,6 +400,7 @@ describe('codec', () => {
fb.SeatView.addHintsUsed(b, 0); fb.SeatView.addHintsUsed(b, 0);
fb.SeatView.addIsWinner(b, false); fb.SeatView.addIsWinner(b, false);
fb.SeatView.addDisplayName(b, dn); fb.SeatView.addDisplayName(b, dn);
fb.SeatView.addDeleted(b, true);
const seat = fb.SeatView.endSeatView(b); const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]); const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1'); const id = b.createString('g1');
@@ -434,6 +435,8 @@ describe('codec', () => {
expect(gl.games[0].id).toBe('g1'); expect(gl.games[0].id).toBe('g1');
expect(gl.games[0].seats[0].displayName).toBe('Ann'); expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13); 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].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true); expect(gl.games[0].vsAi).toBe(true);
expect(gl.games[0].unreadChat).toBe(true); expect(gl.games[0].unreadChat).toBe(true);
+1
View File
@@ -291,6 +291,7 @@ function decodeSeat(v: fb.SeatView): Seat {
score: v.score(), score: v.score(),
hintsUsed: v.hintsUsed(), hintsUsed: v.hintsUsed(),
isWinner: v.isWinner(), isWinner: v.isWinner(),
deleted: v.deleted(),
}; };
} }
+41
View File
@@ -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 { function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen }; return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen };
} }
+15 -2
View File
@@ -23,6 +23,19 @@ export interface DeltaResult {
refetch: boolean; 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 / * 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. * 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 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. // 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 }; 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 }; 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 (!cached) return { refetch: false };
if (!game) return { refetch: cached.view.game.status !== 'finished' }; if (!game) return { refetch: cached.view.game.status !== 'finished' };
if (cached.view.game.moveCount < game.moveCount) return { refetch: true }; 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 }; return { cache: { view, moves: cached.moves }, refetch: false };
} }
+1
View File
@@ -434,6 +434,7 @@ export const en = {
'error.request_not_found': 'No matching friend request.', 'error.request_not_found': 'No matching friend request.',
'error.no_shared_game': 'You can only add someone you have played with.', 'error.no_shared_game': 'You can only add someone you have played with.',
'error.request_declined': 'This player declined your request.', '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.friend_code_invalid': 'That friend code is invalid or expired.',
'error.invalid_invitation': 'Invalid invitation.', 'error.invalid_invitation': 'Invalid invitation.',
'error.invitation_blocked': 'You cannot invite this player.', 'error.invitation_blocked': 'You cannot invite this player.',
+1
View File
@@ -434,6 +434,7 @@ export const ru: Record<MessageKey, string> = {
'error.request_not_found': 'Подходящей заявки нет.', 'error.request_not_found': 'Подходящей заявки нет.',
'error.no_shared_game': 'Можно добавить только того, с кем вы играли.', 'error.no_shared_game': 'Можно добавить только того, с кем вы играли.',
'error.request_declined': 'Игрок отклонил вашу заявку.', 'error.request_declined': 'Игрок отклонил вашу заявку.',
'error.account_deleted': 'Игрок удалил свою учётную запись.',
'error.friend_code_invalid': 'Код недействителен или истёк.', 'error.friend_code_invalid': 'Код недействителен или истёк.',
'error.invalid_invitation': 'Неверное приглашение.', 'error.invalid_invitation': 'Неверное приглашение.',
'error.invitation_blocked': 'Нельзя пригласить этого игрока.', 'error.invitation_blocked': 'Нельзя пригласить этого игрока.',
+4
View File
@@ -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 /** 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. */ * the finished-game medal ranking can place it last (no medal); undefined for online seats. */
resigned?: boolean; 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 { export interface GameView {