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
+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));