feat(social): hide social controls on a deleted opponent's seat #286

Merged
developer merged 1 commits from feature/deleted-seat-social into development 2026-07-27 15:15:00 +00:00
30 changed files with 375 additions and 23 deletions
Showing only changes of commit 9471341a0e - Show all commits
+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
// 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,
+26
View File
@@ -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.
+114
View File
@@ -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) {
+4
View File
@@ -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.
+2
View File
@@ -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):
+40
View File
@@ -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)
}
+2
View File
@@ -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)
}
+10
View File
@@ -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
}
+5
View File
@@ -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
+5
View File
@@ -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
+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
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
+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
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
+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"`
}
// 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.
+1
View File
@@ -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{
+9 -1
View File
@@ -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)")
}
+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
// 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.
+16 -1
View File
@@ -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()
}
+4
View File
@@ -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))
+2 -2
View File
@@ -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;
+6 -5
View File
@@ -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}
/>
+16 -6
View File
@@ -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)
);
}
</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;
}
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);
}
}
+3
View File
@@ -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);
+1
View File
@@ -291,6 +291,7 @@ function decodeSeat(v: fb.SeatView): Seat {
score: v.score(),
hintsUsed: v.hintsUsed(),
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 {
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;
}
/**
* 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 };
}
+1
View File
@@ -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.',
+1
View File
@@ -434,6 +434,7 @@ export const ru: Record<MessageKey, string> = {
'error.request_not_found': 'Подходящей заявки нет.',
'error.no_shared_game': 'Можно добавить только того, с кем вы играли.',
'error.request_declined': 'Игрок отклонил вашу заявку.',
'error.account_deleted': 'Игрок удалил свою учётную запись.',
'error.friend_code_invalid': 'Код недействителен или истёк.',
'error.invalid_invitation': 'Неверное приглашение.',
'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
* 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 {