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')}