Merge pull request 'feat(social): hide social controls on a deleted opponent's seat' (#286) from feature/deleted-seat-social into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m46s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m46s
This commit was merged in pull request #286.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -377,7 +377,12 @@ Telegram/VK** — в отличие от офлайн-режима игры вы
|
||||
**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым**
|
||||
после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после
|
||||
перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым
|
||||
событием; при сбое транспорта контрол возвращается в прежнее состояние.
|
||||
событием; при сбое транспорта контрол возвращается в прежнее состояние. Если соперник **удалил
|
||||
свою учётную запись**, обращаться больше не к кому: оба контрола на этом месте исчезают
|
||||
навсегда — и в завершённой партии, и в идущей, — а когда доступных соперников не остаётся,
|
||||
пропадает и поле чата (сообщение и nudge); сама переписка и партия остаются доступны для
|
||||
чтения. Попытка выполнить действие всё равно (версия приложения, которая ещё показывает
|
||||
контролы) отклоняется сообщением *Игрок удалил свою учётную запись*.
|
||||
|
||||
Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного
|
||||
игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -291,6 +291,7 @@ function decodeSeat(v: fb.SeatView): Seat {
|
||||
score: v.score(),
|
||||
hintsUsed: v.hintsUsed(),
|
||||
isWinner: v.isWinner(),
|
||||
deleted: v.deleted(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Нельзя пригласить этого игрока.',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user