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
+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 {