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
+1 -1
View File
@@ -35,7 +35,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
+52
View File
@@ -10,7 +10,9 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social"
)
@@ -249,6 +251,56 @@ func TestAdminSocialLists(t *testing.T) {
}
}
// TestRobotBlockIsPerGameAndMatchmakerImmune checks that blocking a disguised-robot opponent
// is recorded per-game in robot_blocks — never in the blocks table or against the shared robot
// account — so the matchmaker keeps the robot free, and that unblocking by the row id removes it.
func TestRobotBlockIsPerGameAndMatchmakerImmune(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
accs := account.NewStore(testDB)
human := provisionAccount(t)
robot, err := accs.ProvisionRobot(ctx, "robot-block-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
if err := svc.BlockInGame(ctx, human, robot.ID, g.ID); err != nil {
t.Fatalf("block robot: %v", err)
}
rbs, err := svc.ListRobotBlocks(ctx, human)
if err != nil {
t.Fatalf("list robot blocks: %v", err)
}
if len(rbs) != 1 || rbs[0].GameID != g.ID {
t.Fatalf("robot blocks = %v, want one for game %s", rbs, g.ID)
}
if bl, _ := svc.ListBlocks(ctx, human); len(bl) != 0 {
t.Errorf("blocks table must stay empty for a robot block, got %v", bl)
}
if yes, _ := svc.IsBlocked(ctx, human, robot.ID); yes {
t.Error("the shared robot account must never be blocked")
}
// Matchmaking immunity: the robot is never in the caller's exclusion set, so the matchmaker
// keeps giving robots and the player can never be starved by blocking them.
if with, _ := svc.BlockedWith(ctx, human); contains(with, robot.ID) {
t.Error("a robot block must not put the robot in BlockedWith")
}
// Unblock by the robot_blocks row id removes it.
if err := svc.Unblock(ctx, human, rbs[0].ID); err != nil {
t.Fatalf("unblock robot: %v", err)
}
if rbs, _ := svc.ListRobotBlocks(ctx, human); len(rbs) != 0 {
t.Errorf("robot block not removed by unblock: %v", rbs)
}
}
// blockedWith reads the both-direction block set for id.
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
t.Helper()
@@ -0,0 +1,27 @@
-- +goose Up
-- Per-game blocks of a disguised-robot opponent. A disguised robot is a shared pool
-- account reused across games under different per-game names, so it must never go into
-- `blocks` (that would make the same robot look blocked in the blocker's other games under
-- other names, leaking that it is a bot, and show its pool name instead of the one seen).
-- Each such block is recorded here against the specific game + seat, snapshotting the name
-- the player saw, so the blocked list shows it as a distinct personality and the in-game
-- card re-marks that one seat. The real robot account is never blocked, so the matchmaker
-- leaves robots free. Unblocking deletes the row.
SET search_path = backend, pg_catalog;
CREATE TABLE robot_blocks (
id uuid PRIMARY KEY,
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
robot_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (blocker_id, game_id, seat)
);
CREATE INDEX robot_blocks_blocker_idx ON robot_blocks (blocker_id);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE robot_blocks;
+38 -4
View File
@@ -2,6 +2,7 @@ package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -13,9 +14,21 @@ import (
// not delete — any friendship (an unblock restores it). They reuse the friend handlers'
// targetIDRequest and account-ref resolution.
// blockListDTO is the accounts the caller has blocked.
// blockListDTO is the accounts the caller has blocked, plus the per-game disguised-robot
// blocks (not real accounts), which the blocked list shows as distinct personalities.
type blockListDTO struct {
Blocked []accountRefDTO `json:"blocked"`
Robots []robotBlockDTO `json:"robots"`
}
// robotBlockDTO is one per-game disguised-robot block: the row id (used to unblock it), the
// game name the player saw, and the game + seat it was blocked in (so the in-game card can
// re-mark that seat).
type robotBlockDTO struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
GameID string `json:"game_id"`
Seat int `json:"seat"`
}
// handleBlock blocks the body-supplied account.
@@ -35,7 +48,18 @@ func (s *Server) handleBlock(c *gin.Context) {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.Block(c.Request.Context(), uid, target); err != nil {
// An in-game block carries the game id, so a disguised-robot opponent is recorded as a
// per-game block (BlockInGame); a settings-screen block (no game) blocks a human directly.
var err error
if strings.TrimSpace(req.GameID) == "" {
err = s.social.Block(c.Request.Context(), uid, target)
} else if gameID, ok := parseUUIDField(req.GameID); !ok {
abortBadRequest(c, "invalid game id")
return
} else {
err = s.social.BlockInGame(c.Request.Context(), uid, target, gameID)
}
if err != nil {
s.abortErr(c, err)
return
}
@@ -68,10 +92,20 @@ func (s *Server) handleListBlocks(c *gin.Context) {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListBlocks(c.Request.Context(), uid)
ctx := c.Request.Context()
ids, err := s.social.ListBlocks(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, blockListDTO{Blocked: s.accountRefs(c.Request.Context(), ids)})
robots, err := s.social.ListRobotBlocks(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
dto := blockListDTO{Blocked: s.accountRefs(ctx, ids)}
for _, r := range robots {
dto.Robots = append(dto.Robots, robotBlockDTO{ID: r.ID.String(), DisplayName: r.DisplayName, GameID: r.GameID.String(), Seat: r.Seat})
}
c.JSON(http.StatusOK, dto)
}
+4 -1
View File
@@ -48,9 +48,12 @@ type redeemResultDTO struct {
Friend accountRefDTO `json:"friend"`
}
// targetIDRequest carries a single counterpart account id.
// targetIDRequest carries a single counterpart account id. GameID is set only by an
// in-game block (so a disguised-robot opponent can be recorded as a per-game block); the
// other target paths leave it empty.
type targetIDRequest struct {
AccountID string `json:"account_id"`
GameID string `json:"game_id"`
}
// friendRespondRequest accepts or declines a pending request from a requester.
+15 -7
View File
@@ -35,15 +35,23 @@ func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) e
return nil
}
// Unblock removes blockerID's block on blockedID and confirms it to the (former)
// blocker with a user_unblocked event so their open game screens restore the controls
// and un-strike the name in place. Any friendship the block had been overriding becomes
// effective again. It is idempotent.
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
if err := svc.store.deleteBlock(ctx, blockerID, blockedID); err != nil {
// Unblock removes blockerID's block on the given target and confirms it to the (former)
// blocker with a user_unblocked event so their open game screens restore the controls and
// un-strike the name in place. The target is either a blocked human's account id (the
// blocks table) or a robot_blocks row id (a per-game disguised-robot block) — the robot
// block is tried first, falling back to the human block. Any friendship the block had been
// overriding becomes effective again. It is idempotent.
func (svc *Service) Unblock(ctx context.Context, blockerID, target uuid.UUID) error {
removed, err := svc.store.deleteRobotBlock(ctx, blockerID, target)
if err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, blockedID)))
if !removed {
if err := svc.store.deleteBlock(ctx, blockerID, target); err != nil {
return err
}
}
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, target)))
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package social
import (
"context"
"fmt"
"slices"
"github.com/google/uuid"
"scrabble/backend/internal/notify"
)
// RobotBlock is one per-game block of a disguised-robot opponent: the row id (used to
// unblock it), the game name the player saw, and the game + seat it was blocked in.
type RobotBlock struct {
ID uuid.UUID
DisplayName string
GameID uuid.UUID
Seat int
}
// BlockInGame blocks blockedID for blockerID from within gameID. A human is blocked
// normally (the blocks table, via Block). A disguised-robot opponent is instead recorded
// per-game in robot_blocks — never the shared robot account — so the matchmaker keeps
// robots free and the same robot stays unblocked in the blocker's other games (under its
// other names); the row snapshots the seat and the name the player saw so the blocked list
// and the in-game card can show it. It is the entry point for the in-game block control;
// the settings-screen block (no game) goes through Block directly.
func (svc *Service) BlockInGame(ctx context.Context, blockerID, blockedID, gameID uuid.UUID) error {
if blockerID == blockedID {
return ErrSelfRelation
}
isRobot, err := svc.accounts.IsRobot(ctx, blockedID)
if err != nil {
return err
}
if !isRobot {
return svc.Block(ctx, blockerID, blockedID)
}
if gameID == uuid.Nil {
return ErrNotParticipant
}
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
return err
}
seat := slices.Index(seats, blockedID)
if seat < 0 {
return ErrNotParticipant
}
name, _ := svc.games.SeatName(ctx, gameID, blockedID)
if err := svc.store.insertRobotBlock(ctx, blockerID, gameID, seat, blockedID, name); err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserBlocked, svc.accountRef(ctx, blockedID)))
return nil
}
// ListRobotBlocks returns blockerID's per-game disguised-robot blocks, newest first.
func (svc *Service) ListRobotBlocks(ctx context.Context, blockerID uuid.UUID) ([]RobotBlock, error) {
return svc.store.listRobotBlocks(ctx, blockerID)
}
// insertRobotBlock records a per-game robot block; a duplicate (same blocker, game, seat)
// is ignored.
func (s *Store) insertRobotBlock(ctx context.Context, blocker, gameID uuid.UUID, seat int, robotID uuid.UUID, name string) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("social: new robot block id: %w", err)
}
const q = `INSERT INTO backend.robot_blocks (id, blocker_id, game_id, seat, robot_id, display_name)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (blocker_id, game_id, seat) DO NOTHING`
if _, err := s.db.ExecContext(ctx, q, id, blocker, gameID, int16(seat), robotID, name); err != nil {
return fmt.Errorf("social: insert robot block: %w", err)
}
return nil
}
// deleteRobotBlock removes blocker's robot block by its row id, reporting whether a row
// matched (so the caller can fall back to a human block).
func (s *Store) deleteRobotBlock(ctx context.Context, blocker, id uuid.UUID) (bool, error) {
const q = `DELETE FROM backend.robot_blocks WHERE id = $2 AND blocker_id = $1`
res, err := s.db.ExecContext(ctx, q, blocker, id)
if err != nil {
return false, fmt.Errorf("social: delete robot block: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("social: delete robot block rows: %w", err)
}
return n > 0, nil
}
// listRobotBlocks returns blocker's robot blocks, newest first.
func (s *Store) listRobotBlocks(ctx context.Context, blocker uuid.UUID) ([]RobotBlock, error) {
const q = `SELECT id, display_name, game_id, seat FROM backend.robot_blocks
WHERE blocker_id = $1 ORDER BY created_at DESC`
rows, err := s.db.QueryContext(ctx, q, blocker)
if err != nil {
return nil, fmt.Errorf("social: list robot blocks: %w", err)
}
defer rows.Close()
var out []RobotBlock
for rows.Next() {
var rb RobotBlock
var seat int16
if err := rows.Scan(&rb.ID, &rb.DisplayName, &rb.GameID, &seat); err != nil {
return nil, fmt.Errorf("social: scan robot block: %w", err)
}
rb.Seat = int(seat)
out = append(out, rb)
}
return out, rows.Err()
}
+6 -1
View File
@@ -520,7 +520,12 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
**overrides but does not delete** a friendship (an unblock cleanly restores it), so a pair may
be friends **and** blocked at once; the admin user card shows the full truth (blocks,
blocked-by, friends) regardless of this suppression. Block/unblock emit a `user_blocked` /
`user_unblocked` notification to the blocker only (§10).
`user_unblocked` notification to the blocker only (§10). Blocking an auto-match opponent who
is secretly a pooled robot is recorded instead in a separate **`robot_blocks`** table, keyed on
the blocker + game + seat with the seen name snapshotted (`BlockInGame`): the shared robot
account is never put in `blocks` (so the matchmaker keeps it free and it is not blocked under
its other per-game names), while the blocked list and the in-game card still show it by joining
that table; an unblock deletes the row.
- **Friend games**: formed by **invitation → accept** (an `game_invitations`
record with one row per invitee). The 24 player game starts once **every**
invitee accepts; any decline cancels the invitation, and a pending invitation
+6 -1
View File
@@ -198,7 +198,12 @@ read at once), and a friend request or invitation they send you is kept but neve
block **overrides but does not delete** an existing friendship (so you may block a friend, and
they keep seeing you as one); active games are never interrupted — you can finish them, with
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only. Per-game chat is for quick reactions: messages are short
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only.
Blocking an **auto-match opponent who is secretly a robot** 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). Per-game chat is for quick reactions: messages are short
(up to 60 characters) and may not contain links, email addresses or phone numbers,
even disguised. You may send **one message per turn, on your own turn**; once it is sent
the field gives way to a short caption until your next turn. Nudge the player whose turn
+6 -1
View File
@@ -203,7 +203,12 @@ nudge) приходят от бота **этой партии** — по язы
продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там. Чат
есть только там.
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что
заблокировать себя без соперников нельзя). Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
+16 -4
View File
@@ -42,9 +42,20 @@ type RedeemResultResp struct {
Friend AccountRefResp `json:"friend"`
}
// BlockListResp is the accounts the caller has blocked.
// BlockListResp is the accounts the caller has blocked, plus the per-game disguised-robot
// blocks (not real accounts).
type BlockListResp struct {
Blocked []AccountRefResp `json:"blocked"`
Robots []RobotBlockResp `json:"robots"`
}
// RobotBlockResp is one per-game disguised-robot block: the row id (to unblock it), the game
// name the player saw, and the game + seat it was blocked in.
type RobotBlockResp struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
GameID string `json:"game_id"`
Seat int `json:"seat"`
}
// StatsResp is a durable account's lifetime statistics. BestMoves breaks the best move
@@ -188,10 +199,11 @@ func (c *Client) RedeemFriendCode(ctx context.Context, userID, code string) (Red
// --- blocks ---
// Block blocks an account.
func (c *Client) Block(ctx context.Context, userID, targetID string) error {
// Block blocks an account. A non-empty gameID marks an in-game block, so a disguised-robot
// opponent is recorded as a per-game block; it is empty for a settings-screen block.
func (c *Client) Block(ctx context.Context, userID, targetID, gameID string) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/blocks", userID, "",
map[string]string{"account_id": targetID}, nil)
map[string]string{"account_id": targetID, "game_id": gameID}, nil)
}
// Unblock removes a block.
@@ -60,12 +60,35 @@ func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
return b.FinishedBytes()
}
// buildRobotBlockVector builds the BlockList robots vector (per-game disguised-robot blocks).
func buildRobotBlockVector(b *flatbuffers.Builder, robots []backendclient.RobotBlockResp) flatbuffers.UOffsetT {
offs := make([]flatbuffers.UOffsetT, len(robots))
for i, r := range robots {
id := b.CreateString(r.ID)
name := b.CreateString(r.DisplayName)
gid := b.CreateString(r.GameID)
fb.RobotBlockRefStart(b)
fb.RobotBlockRefAddId(b, id)
fb.RobotBlockRefAddDisplayName(b, name)
fb.RobotBlockRefAddGameId(b, gid)
fb.RobotBlockRefAddSeat(b, int32(r.Seat))
offs[i] = fb.RobotBlockRefEnd(b)
}
fb.BlockListStartRobotsVector(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
return b.EndVector(len(offs))
}
// encodeBlockList builds a BlockList payload.
func encodeBlockList(r backendclient.BlockListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector)
rv := buildRobotBlockVector(b, r.Robots)
fb.BlockListStart(b)
fb.BlockListAddBlocked(b, v)
fb.BlockListAddRobots(b, rv)
b.Finish(fb.BlockListEnd(b))
return b.FinishedBytes()
}
@@ -166,7 +166,9 @@ func blocksListHandler(backend *backendclient.Client) Handler {
func blockAddHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.Block(ctx, req.UserID, string(in.AccountId())); err != nil {
// game_id is set only by an in-game block, so a disguised-robot opponent is recorded
// per-game; it is empty for a settings-screen block.
if err := backend.Block(ctx, req.UserID, string(in.AccountId()), string(in.GameId())); err != nil {
return nil, err
}
return encodeAck(true), nil
+18 -2
View File
@@ -504,9 +504,12 @@ table StatsView {
}
// TargetRequest names a single counterpart account (friend request/cancel/unfriend,
// block/unblock).
// block/unblock). game_id is set only by an in-game block of a disguised-robot opponent,
// so the backend can record the per-game robot block (the seat name the player saw)
// against that game; every other path leaves it empty (FlatBuffers-optional).
table TargetRequest {
account_id:string;
game_id:string;
}
// FriendRespondRequest accepts or declines a pending request from a requester.
@@ -548,9 +551,22 @@ table RedeemResult {
friend:AccountRef;
}
// BlockList is the accounts the caller has blocked.
// RobotBlockRef 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, so
// the blocked list shows it as a distinct personality and the in-game card can re-mark
// that seat. Its id is the robot_blocks row, used to unblock it.
table RobotBlockRef {
id:string;
display_name:string;
game_id:string;
seat:int;
}
// BlockList is the accounts the caller has blocked, plus the per-game disguised-robot
// blocks (robots) which are not real accounts.
table BlockList {
blocked:[AccountRef];
robots:[RobotBlockRef];
}
// InvitationInvitee is one invitee's seat and response, name resolved.
+27 -1
View File
@@ -61,8 +61,28 @@ func (rcv *BlockList) BlockedLength() int {
return 0
}
func (rcv *BlockList) Robots(obj *RobotBlockRef, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *BlockList) RobotsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func BlockListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func BlockListAddBlocked(builder *flatbuffers.Builder, blocked flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(blocked), 0)
@@ -70,6 +90,12 @@ func BlockListAddBlocked(builder *flatbuffers.Builder, blocked flatbuffers.UOffs
func BlockListStartBlockedVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func BlockListAddRobots(builder *flatbuffers.Builder, robots flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(robots), 0)
}
func BlockListStartRobotsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func BlockListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+97
View File
@@ -0,0 +1,97 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type RobotBlockRef struct {
_tab flatbuffers.Table
}
func GetRootAsRobotBlockRef(buf []byte, offset flatbuffers.UOffsetT) *RobotBlockRef {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &RobotBlockRef{}
x.Init(buf, n+offset)
return x
}
func FinishRobotBlockRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsRobotBlockRef(buf []byte, offset flatbuffers.UOffsetT) *RobotBlockRef {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &RobotBlockRef{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedRobotBlockRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *RobotBlockRef) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *RobotBlockRef) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *RobotBlockRef) Id() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *RobotBlockRef) DisplayName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *RobotBlockRef) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *RobotBlockRef) Seat() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *RobotBlockRef) MutateSeat(n int32) bool {
return rcv._tab.MutateInt32Slot(10, n)
}
func RobotBlockRefStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
}
func RobotBlockRefAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
}
func RobotBlockRefAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0)
}
func RobotBlockRefAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(gameId), 0)
}
func RobotBlockRefAddSeat(builder *flatbuffers.Builder, seat int32) {
builder.PrependInt32Slot(3, seat, 0)
}
func RobotBlockRefEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *TargetRequest) AccountId() []byte {
return nil
}
func (rcv *TargetRequest) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func TargetRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func TargetRequestAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0)
}
func TargetRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(gameId), 0)
}
func TargetRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+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}