fix(social): robot blocks
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s

Blocking an auto-match opponent who is secretly a pooled robot is recorded  instead in a separate `robot_blocks` table.

Now blocking behaves the same in that game (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out of opponents).

- the shared robot account is never put in `blocks`
- the matchmaker keeps it free and it is not blocked under its other per-game names
- the blocked list and the in-game card still show it by joining that table; an unblock deletes the row
This commit is contained in:
Ilia Denisov
2026-06-18 13:12:19 +02:00
parent 81b9e1529e
commit 64be0572b3
29 changed files with 700 additions and 68 deletions
+9 -3
View File
@@ -17,8 +17,10 @@
let busy = $state(false);
let tick = $state(0);
// The opponents the viewer has blocked: when every opponent is blocked the composer is hidden
// (only the chat log remains), matching the backend guard that rejects messaging a blocked peer.
// (only the chat log remains). blockedIds are blocked humans (by account); blockedRobotSeats are
// the seats of blocked disguised robots in this game (a robot block is per game+seat).
let blockedIds = $state(new Set<string>());
let blockedRobotSeats = $state(new Set<number>());
const myId = $derived(app.session?.userId ?? '');
const isMyTurn = $derived(
@@ -40,7 +42,10 @@
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));
return (
opponents.length > 0 &&
opponents.every((s) => blockedIds.has(s.accountId) || blockedRobotSeats.has(s.seat))
);
});
const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
@@ -80,7 +85,8 @@
if (app.profile?.isGuest) return;
try {
const bl = await gateway.blocksList();
blockedIds = new Set(bl.map((b) => b.accountId));
blockedIds = new Set(bl.blocked.map((b) => b.accountId));
blockedRobotSeats = new Set(bl.robots.filter((r) => r.gameId === id).map((r) => r.seat));
} catch {
/* best-effort */
}
+33 -21
View File
@@ -892,8 +892,11 @@
let requested = $state(new Set<string>());
// `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and
// their name is struck. Derived from the server so it is correct across reloads and live-updates
// on a user_blocked / user_unblocked event.
// on a user_blocked / user_unblocked event. `blockedRobotSeats` are the seat indices of blocked
// disguised-robot opponents in THIS game (a robot block is recorded per game+seat, not by
// account, so it never touches the shared robot account or other games).
let blocked = $state(new Set<string>());
let blockedRobotSeats = $state(new Set<number>());
// Per-seat "confirming" flags for the 🤝 → ✅ and ✖️ → ✅ tap-to-confirm (TapConfirm writes them);
// while set, that seat's card shows "Add friend?" / "Block?" in place of the score, and the
// opposite control is hidden so the two never overlap. Reset when history closes.
@@ -913,17 +916,25 @@
}
}
// loadBlocked refreshes the blocked set for a non-guest. Best-effort.
// loadBlocked refreshes the blocked sets for a non-guest: blocked humans by account, plus the
// seats of any blocked disguised robots in this game. Best-effort.
async function loadBlocked() {
if (app.profile?.isGuest) return;
try {
const bl = await gateway.blocksList();
blocked = new Set(bl.map((b) => b.accountId));
blocked = new Set(bl.blocked.map((b) => b.accountId));
blockedRobotSeats = new Set(bl.robots.filter((r) => r.gameId === id).map((r) => r.seat));
} catch {
/* best-effort */
}
}
// seatBlocked reports whether the viewer has blocked this seat — a human (by account) or a
// disguised robot (by this game's seat). It drives the struck name and hidden controls.
function seatBlocked(s: { accountId: string; seat: number }): boolean {
return blocked.has(s.accountId) || blockedRobotSeats.has(s.seat);
}
// addFriend and blockUser apply the new relationship optimistically (so the controls and score
// caption update the instant the confirm fires) and roll back to the prior state if the command
// fails to reach the server. The confirming user_blocked / user_added event then just reconciles
@@ -940,13 +951,16 @@
}
}
async function blockUser(accountId: string) {
const had = blocked.has(accountId);
blocked = new Set([...blocked, accountId]);
async function blockUser(s: { accountId: string; seat: number }) {
// Optimistically mark this seat blocked (covers the struck name + hidden controls for both a
// human and a disguised robot until the server confirms). The block carries the game id so a
// robot opponent is recorded as a per-game block; the user_blocked event then reconciles.
const hadSeat = blockedRobotSeats.has(s.seat);
blockedRobotSeats = new Set([...blockedRobotSeats, s.seat]);
try {
await gateway.block(accountId);
await gateway.block(s.accountId, id);
} catch (e) {
if (!had) blocked = new Set([...blocked].filter((id) => id !== accountId));
if (!hadSeat) blockedRobotSeats = new Set([...blockedRobotSeats].filter((x) => x !== s.seat));
handleError(e);
}
}
@@ -975,26 +989,24 @@
// 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(accountId: string): boolean {
function canAddFriend(s: { accountId: string; seat: number }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player.
if (view?.game.vsAi) return false;
return (
!!accountId &&
!!s.accountId &&
!app.profile?.isGuest &&
accountId !== app.session?.userId &&
!friends.has(accountId) &&
!blocked.has(accountId)
s.accountId !== app.session?.userId &&
!friends.has(s.accountId) &&
!seatBlocked(s)
);
}
// 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(accountId: string): boolean {
function canBlock(s: { accountId: string; seat: number }): boolean {
if (view?.game.vsAi) return false;
return (
!!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !blocked.has(accountId)
);
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
}
</script>
@@ -1044,22 +1056,22 @@
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm" class:struck={blocked.has(s.accountId)}>{seatName(s)}</div>
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
</div>
{#if historyShown && canBlock(s.accountId) && !addConfirm[s.seat]}
{#if historyShown && canBlock(s) && !addConfirm[s.seat]}
<span class="blockuser">
<TapConfirm
label={t('friends.blockFromGame')}
onConfirming={(v) => (blockConfirm[s.seat] = v)}
onconfirm={() => blockUser(s.accountId)}
onconfirm={() => blockUser(s)}
>
<span class="fico">✖️</span>
</TapConfirm>
</span>
{/if}
{#if historyShown && canAddFriend(s.accountId) && !blockConfirm[s.seat]}
{#if historyShown && canAddFriend(s) && !blockConfirm[s.seat]}
<span class="addfriend">
<TapConfirm
label={t('friends.addFromGame')}
+1
View File
@@ -59,6 +59,7 @@ export { PlayTile } from './scrabblefb/play-tile.js';
export { Profile } from './scrabblefb/profile.js';
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
export { RedeemResult } from './scrabblefb/redeem-result.js';
export { RobotBlockRef } from './scrabblefb/robot-block-ref.js';
export { SeatView } from './scrabblefb/seat-view.js';
export { Session } from './scrabblefb/session.js';
export { StateRequest } from './scrabblefb/state-request.js';
+30 -2
View File
@@ -3,6 +3,7 @@
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
import { RobotBlockRef } from '../scrabblefb/robot-block-ref.js';
export class BlockList {
@@ -33,8 +34,18 @@ blockedLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
robots(index: number, obj?:RobotBlockRef):RobotBlockRef|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new RobotBlockRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
robotsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startBlockList(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(2);
}
static addBlocked(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset) {
@@ -53,14 +64,31 @@ static startBlockedVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addRobots(builder:flatbuffers.Builder, robotsOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, robotsOffset, 0);
}
static createRobotsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startRobotsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endBlockList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createBlockList(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset):flatbuffers.Offset {
static createBlockList(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset, robotsOffset:flatbuffers.Offset):flatbuffers.Offset {
BlockList.startBlockList(builder);
BlockList.addBlocked(builder, blockedOffset);
BlockList.addRobots(builder, robotsOffset);
return BlockList.endBlockList(builder);
}
}
@@ -0,0 +1,82 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class RobotBlockRef {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):RobotBlockRef {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsRobotBlockRef(bb:flatbuffers.ByteBuffer, obj?:RobotBlockRef):RobotBlockRef {
return (obj || new RobotBlockRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsRobotBlockRef(bb:flatbuffers.ByteBuffer, obj?:RobotBlockRef):RobotBlockRef {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new RobotBlockRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
id():string|null
id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
id(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
seat():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startRobotBlockRef(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, idOffset, 0);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, displayNameOffset, 0);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, gameIdOffset, 0);
}
static addSeat(builder:flatbuffers.Builder, seat:number) {
builder.addFieldInt32(3, seat, 0);
}
static endRobotBlockRef(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createRobotBlockRef(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, gameIdOffset:flatbuffers.Offset, seat:number):flatbuffers.Offset {
RobotBlockRef.startRobotBlockRef(builder);
RobotBlockRef.addId(builder, idOffset);
RobotBlockRef.addDisplayName(builder, displayNameOffset);
RobotBlockRef.addGameId(builder, gameIdOffset);
RobotBlockRef.addSeat(builder, seat);
return RobotBlockRef.endRobotBlockRef(builder);
}
}
+14 -2
View File
@@ -27,22 +27,34 @@ accountId(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startTargetRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(2);
}
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, accountIdOffset, 0);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, gameIdOffset, 0);
}
static endTargetRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createTargetRequest(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset):flatbuffers.Offset {
static createTargetRequest(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
TargetRequest.startTargetRequest(builder);
TargetRequest.addAccountId(builder, accountIdOffset);
TargetRequest.addGameId(builder, gameIdOffset);
return TargetRequest.endTargetRequest(builder);
}
}
+3 -2
View File
@@ -6,6 +6,7 @@
import type {
AccountRef,
BlockList,
BlockStatus,
ChatMessage,
EvalResult,
@@ -127,8 +128,8 @@ export interface GatewayClient {
friendCodeRedeem(code: string): Promise<AccountRef>;
// --- blocks ---
blocksList(): Promise<AccountRef[]>;
block(accountId: string): Promise<void>;
blocksList(): Promise<BlockList>;
block(accountId: string, gameId?: string): Promise<void>;
unblock(accountId: string): Promise<void>;
// --- invitations ---
+22 -5
View File
@@ -9,6 +9,8 @@ import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } f
import type { PlacedTile } from './client';
import type {
AccountRef,
BlockList,
RobotBlockEntry,
Banner,
BannerCampaign,
BestMove,
@@ -576,11 +578,15 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
// --- social encoders ---
export function encodeTarget(accountId: string): Uint8Array {
export function encodeTarget(accountId: string, gameId?: string): Uint8Array {
const b = new Builder(64);
const id = b.createString(accountId);
// game_id is set only by an in-game block, so a disguised-robot opponent is recorded
// per-game; every other target path omits it.
const gid = gameId ? b.createString(gameId) : 0;
fb.TargetRequest.startTargetRequest(b);
fb.TargetRequest.addAccountId(b, id);
if (gid) fb.TargetRequest.addGameId(b, gid);
return finish(b, fb.TargetRequest.endTargetRequest(b));
}
@@ -722,14 +728,25 @@ export function decodeOutgoingList(buf: Uint8Array): AccountRef[] {
return out;
}
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
export function decodeBlockList(buf: Uint8Array): BlockList {
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
const out: AccountRef[] = [];
const blocked: AccountRef[] = [];
for (let i = 0; i < l.blockedLength(); i++) {
const r = l.blocked(i);
if (r) out.push(decodeAccountRef(r));
if (r) blocked.push(decodeAccountRef(r));
}
return out;
const robots: RobotBlockEntry[] = [];
for (let i = 0; i < l.robotsLength(); i++) {
const r = l.robots(i);
if (r)
robots.push({
id: r.id() ?? '',
displayName: r.displayName() ?? '',
gameId: r.gameId() ?? '',
seat: r.seat(),
});
}
return { blocked, robots };
}
export function decodeFriendCode(buf: Uint8Array): FriendCode {
+7 -3
View File
@@ -13,6 +13,7 @@ import type {
import { GatewayError } from '../client';
import type {
AccountRef,
BlockList,
BlockStatus,
ChatMessage,
EvalResult,
@@ -555,10 +556,13 @@ export class MockGateway implements GatewayClient {
}
// --- blocks ---
async blocksList(): Promise<AccountRef[]> {
return this.blocks.map((b) => ({ ...b }));
async blocksList(): Promise<BlockList> {
// The mock models human blocks only (no disguised robots); robots stays empty.
return { blocked: this.blocks.map((b) => ({ ...b })), robots: [] };
}
async block(accountId: string): Promise<void> {
async block(accountId: string, _gameId?: string): Promise<void> {
// A block hides the blocked person from the blocker's own friends list (the real
// ListFriends filters them out), without deleting the underlying friendship.
this.friends = this.friends.filter((f) => f.accountId !== accountId);
if (!this.blocks.some((b) => b.accountId === accountId)) {
this.blocks.push({ accountId, displayName: this.nameFor(accountId) });
+16
View File
@@ -205,6 +205,22 @@ export interface AccountRef {
displayName: string;
}
// RobotBlockEntry is one blocked disguised-robot opponent: a per-game record (not a real
// account) carrying the game name the player saw and the game/seat it was blocked in. Its id
// is used to unblock it.
export interface RobotBlockEntry {
id: string;
displayName: string;
gameId: string;
seat: number;
}
// BlockList is the caller's blocked humans plus the per-game disguised-robot blocks.
export interface BlockList {
blocked: AccountRef[];
robots: RobotBlockEntry[];
}
/** A freshly issued one-time friend code (the plaintext is returned once). */
export interface FriendCode {
code: string;
+2 -2
View File
@@ -187,8 +187,8 @@ export function createTransport(baseUrl: string): GatewayClient {
async blocksList() {
return codec.decodeBlockList(await exec('blocks.list', codec.empty()));
},
async block(accountId) {
await exec('blocks.add', codec.encodeTarget(accountId));
async block(accountId, gameId) {
await exec('blocks.add', codec.encodeTarget(accountId, gameId));
},
async unblock(accountId) {
await exec('blocks.remove', codec.encodeTarget(accountId));
+15 -3
View File
@@ -8,21 +8,27 @@
import { translate } from '../lib/i18n/catalog';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode } from '../lib/model';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
let friends = $state<AccountRef[]>([]);
let incoming = $state<AccountRef[]>([]);
let blocked = $state<AccountRef[]>([]);
// Per-game disguised-robot blocks, shown as distinct entries alongside blocked humans.
let robotBlocks = $state<RobotBlockEntry[]>([]);
let code = $state<FriendCode | null>(null);
let redeemInput = $state('');
async function load() {
try {
[friends, incoming, blocked] = await Promise.all([
const [fl, inc, bl] = await Promise.all([
gateway.friendsList(),
gateway.friendsIncoming(),
gateway.blocksList(),
]);
friends = fl;
incoming = inc;
blocked = bl.blocked;
robotBlocks = bl.robots;
} catch (e) {
handleError(e);
}
@@ -179,7 +185,7 @@
{/if}
</section>
{#if blocked.length}
{#if blocked.length || robotBlocks.length}
<section>
<h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)}
@@ -188,6 +194,12 @@
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
{#each robotBlocks as r (r.id)}
<div class="item">
<span class="who">{r.displayName}</span>
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
</section>
{/if}
{/if}