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) | | 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** | | 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** | | 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) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## Key findings (these reshaped the raw list — read before starting a phase)
+52
View File
@@ -10,7 +10,9 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/social" "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. // blockedWith reads the both-direction block set for id.
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID { func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
t.Helper() 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 ( import (
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -13,9 +14,21 @@ import (
// not delete — any friendship (an unblock restores it). They reuse the friend handlers' // not delete — any friendship (an unblock restores it). They reuse the friend handlers'
// targetIDRequest and account-ref resolution. // 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 { type blockListDTO struct {
Blocked []accountRefDTO `json:"blocked"` 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. // handleBlock blocks the body-supplied account.
@@ -35,7 +48,18 @@ func (s *Server) handleBlock(c *gin.Context) {
abortBadRequest(c, "invalid account id") abortBadRequest(c, "invalid account id")
return 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) s.abortErr(c, err)
return return
} }
@@ -68,10 +92,20 @@ func (s *Server) handleListBlocks(c *gin.Context) {
abortBadRequest(c, "missing identity") abortBadRequest(c, "missing identity")
return return
} }
ids, err := s.social.ListBlocks(c.Request.Context(), uid) ctx := c.Request.Context()
ids, err := s.social.ListBlocks(ctx, uid)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return 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"` 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 { type targetIDRequest struct {
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
GameID string `json:"game_id"`
} }
// friendRespondRequest accepts or declines a pending request from a requester. // 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 return nil
} }
// Unblock removes blockerID's block on blockedID and confirms it to the (former) // 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 // blocker with a user_unblocked event so their open game screens restore the controls and
// and un-strike the name in place. Any friendship the block had been overriding becomes // un-strike the name in place. The target is either a blocked human's account id (the
// effective again. It is idempotent. // blocks table) or a robot_blocks row id (a per-game disguised-robot block) — the robot
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error { // block is tried first, falling back to the human block. Any friendship the block had been
if err := svc.store.deleteBlock(ctx, blockerID, blockedID); err != nil { // 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 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 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 **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, 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` / 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` - **Friend games**: formed by **invitation → accept** (an `game_invitations`
record with one row per invitee). The 24 player game starts once **every** record with one row per invitee). The 24 player game starts once **every**
invitee accepts; any decline cancels the invitation, and a pending invitation 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 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 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 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, (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 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 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 символов) и не должны партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
+16 -4
View File
@@ -42,9 +42,20 @@ type RedeemResultResp struct {
Friend AccountRefResp `json:"friend"` 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 { type BlockListResp struct {
Blocked []AccountRefResp `json:"blocked"` 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 // 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 --- // --- blocks ---
// Block blocks an account. // Block blocks an account. A non-empty gameID marks an in-game block, so a disguised-robot
func (c *Client) Block(ctx context.Context, userID, targetID string) error { // 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, "", 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. // Unblock removes a block.
@@ -60,12 +60,35 @@ func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
return b.FinishedBytes() 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. // encodeBlockList builds a BlockList payload.
func encodeBlockList(r backendclient.BlockListResp) []byte { func encodeBlockList(r backendclient.BlockListResp) []byte {
b := flatbuffers.NewBuilder(256) b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector) v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector)
rv := buildRobotBlockVector(b, r.Robots)
fb.BlockListStart(b) fb.BlockListStart(b)
fb.BlockListAddBlocked(b, v) fb.BlockListAddBlocked(b, v)
fb.BlockListAddRobots(b, rv)
b.Finish(fb.BlockListEnd(b)) b.Finish(fb.BlockListEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
@@ -166,7 +166,9 @@ func blocksListHandler(backend *backendclient.Client) Handler {
func blockAddHandler(backend *backendclient.Client) Handler { func blockAddHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0) 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 nil, err
} }
return encodeAck(true), nil return encodeAck(true), nil
+18 -2
View File
@@ -504,9 +504,12 @@ table StatsView {
} }
// TargetRequest names a single counterpart account (friend request/cancel/unfriend, // 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 { table TargetRequest {
account_id:string; account_id:string;
game_id:string;
} }
// FriendRespondRequest accepts or declines a pending request from a requester. // FriendRespondRequest accepts or declines a pending request from a requester.
@@ -548,9 +551,22 @@ table RedeemResult {
friend:AccountRef; 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 { table BlockList {
blocked:[AccountRef]; blocked:[AccountRef];
robots:[RobotBlockRef];
} }
// InvitationInvitee is one invitee's seat and response, name resolved. // 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 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) { func BlockListStart(builder *flatbuffers.Builder) {
builder.StartObject(1) builder.StartObject(2)
} }
func BlockListAddBlocked(builder *flatbuffers.Builder, blocked flatbuffers.UOffsetT) { func BlockListAddBlocked(builder *flatbuffers.Builder, blocked flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(blocked), 0) 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 { func BlockListStartBlockedVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) 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 { func BlockListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() 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 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) { func TargetRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1) builder.StartObject(2)
} }
func TargetRequestAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) { func TargetRequestAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0) 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 { func TargetRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+9 -3
View File
@@ -17,8 +17,10 @@
let busy = $state(false); let busy = $state(false);
let tick = $state(0); let tick = $state(0);
// The opponents the viewer has blocked: when every opponent is blocked the composer is hidden // 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 blockedIds = $state(new Set<string>());
let blockedRobotSeats = $state(new Set<number>());
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const isMyTurn = $derived( const isMyTurn = $derived(
@@ -40,7 +42,10 @@
const v = view; const v = view;
if (!v) return false; if (!v) return false;
const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId); 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; const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the // 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; if (app.profile?.isGuest) return;
try { try {
const bl = await gateway.blocksList(); 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 { } catch {
/* best-effort */ /* best-effort */
} }
+33 -21
View File
@@ -892,8 +892,11 @@
let requested = $state(new Set<string>()); let requested = $state(new Set<string>());
// `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and // `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 // 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 blocked = $state(new Set<string>());
let blockedRobotSeats = $state(new Set<number>());
// Per-seat "confirming" flags for the 🤝 → ✅ and ✖️ → ✅ tap-to-confirm (TapConfirm writes them); // 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 // 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. // 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() { async function loadBlocked() {
if (app.profile?.isGuest) return; if (app.profile?.isGuest) return;
try { try {
const bl = await gateway.blocksList(); 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 { } catch {
/* best-effort */ /* 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 // 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 // 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 // fails to reach the server. The confirming user_blocked / user_added event then just reconciles
@@ -940,13 +951,16 @@
} }
} }
async function blockUser(accountId: string) { async function blockUser(s: { accountId: string; seat: number }) {
const had = blocked.has(accountId); // Optimistically mark this seat blocked (covers the struck name + hidden controls for both a
blocked = new Set([...blocked, accountId]); // 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 { try {
await gateway.block(accountId); await gateway.block(s.accountId, id);
} catch (e) { } 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); handleError(e);
} }
} }
@@ -975,26 +989,24 @@
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent // 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 // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // 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. // Never offer add-friend against an AI opponent, an existing friend, or a blocked player.
if (view?.game.vsAi) return false; if (view?.game.vsAi) return false;
return ( return (
!!accountId && !!s.accountId &&
!app.profile?.isGuest && !app.profile?.isGuest &&
accountId !== app.session?.userId && s.accountId !== app.session?.userId &&
!friends.has(accountId) && !friends.has(s.accountId) &&
!blocked.has(accountId) !seatBlocked(s)
); );
} }
// canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend // 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. // 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). // 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; if (view?.game.vsAi) return false;
return ( return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
!!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !blocked.has(accountId)
);
} }
</script> </script>
@@ -1044,22 +1056,22 @@
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if} {#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
{#each view.game.seats as s (s.seat)} {#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="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]}> <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} {#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
</div> </div>
{#if historyShown && canBlock(s.accountId) && !addConfirm[s.seat]} {#if historyShown && canBlock(s) && !addConfirm[s.seat]}
<span class="blockuser"> <span class="blockuser">
<TapConfirm <TapConfirm
label={t('friends.blockFromGame')} label={t('friends.blockFromGame')}
onConfirming={(v) => (blockConfirm[s.seat] = v)} onConfirming={(v) => (blockConfirm[s.seat] = v)}
onconfirm={() => blockUser(s.accountId)} onconfirm={() => blockUser(s)}
> >
<span class="fico">✖️</span> <span class="fico">✖️</span>
</TapConfirm> </TapConfirm>
</span> </span>
{/if} {/if}
{#if historyShown && canAddFriend(s.accountId) && !blockConfirm[s.seat]} {#if historyShown && canAddFriend(s) && !blockConfirm[s.seat]}
<span class="addfriend"> <span class="addfriend">
<TapConfirm <TapConfirm
label={t('friends.addFromGame')} 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 { Profile } from './scrabblefb/profile.js';
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
export { RedeemResult } from './scrabblefb/redeem-result.js'; export { RedeemResult } from './scrabblefb/redeem-result.js';
export { RobotBlockRef } from './scrabblefb/robot-block-ref.js';
export { SeatView } from './scrabblefb/seat-view.js'; export { SeatView } from './scrabblefb/seat-view.js';
export { Session } from './scrabblefb/session.js'; export { Session } from './scrabblefb/session.js';
export { StateRequest } from './scrabblefb/state-request.js'; export { StateRequest } from './scrabblefb/state-request.js';
+30 -2
View File
@@ -3,6 +3,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js'; import { AccountRef } from '../scrabblefb/account-ref.js';
import { RobotBlockRef } from '../scrabblefb/robot-block-ref.js';
export class BlockList { export class BlockList {
@@ -33,8 +34,18 @@ blockedLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; 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) { static startBlockList(builder:flatbuffers.Builder) {
builder.startObject(1); builder.startObject(2);
} }
static addBlocked(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset) { static addBlocked(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset) {
@@ -53,14 +64,31 @@ static startBlockedVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4); 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 { static endBlockList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startBlockList(builder);
BlockList.addBlocked(builder, blockedOffset); BlockList.addBlocked(builder, blockedOffset);
BlockList.addRobots(builder, robotsOffset);
return BlockList.endBlockList(builder); 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; 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) { static startTargetRequest(builder:flatbuffers.Builder) {
builder.startObject(1); builder.startObject(2);
} }
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) { static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, accountIdOffset, 0); 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 { static endTargetRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startTargetRequest(builder);
TargetRequest.addAccountId(builder, accountIdOffset); TargetRequest.addAccountId(builder, accountIdOffset);
TargetRequest.addGameId(builder, gameIdOffset);
return TargetRequest.endTargetRequest(builder); return TargetRequest.endTargetRequest(builder);
} }
} }
+3 -2
View File
@@ -6,6 +6,7 @@
import type { import type {
AccountRef, AccountRef,
BlockList,
BlockStatus, BlockStatus,
ChatMessage, ChatMessage,
EvalResult, EvalResult,
@@ -127,8 +128,8 @@ export interface GatewayClient {
friendCodeRedeem(code: string): Promise<AccountRef>; friendCodeRedeem(code: string): Promise<AccountRef>;
// --- blocks --- // --- blocks ---
blocksList(): Promise<AccountRef[]>; blocksList(): Promise<BlockList>;
block(accountId: string): Promise<void>; block(accountId: string, gameId?: string): Promise<void>;
unblock(accountId: string): Promise<void>; unblock(accountId: string): Promise<void>;
// --- invitations --- // --- invitations ---
+22 -5
View File
@@ -9,6 +9,8 @@ import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } f
import type { PlacedTile } from './client'; import type { PlacedTile } from './client';
import type { import type {
AccountRef, AccountRef,
BlockList,
RobotBlockEntry,
Banner, Banner,
BannerCampaign, BannerCampaign,
BestMove, BestMove,
@@ -576,11 +578,15 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
// --- social encoders --- // --- social encoders ---
export function encodeTarget(accountId: string): Uint8Array { export function encodeTarget(accountId: string, gameId?: string): Uint8Array {
const b = new Builder(64); const b = new Builder(64);
const id = b.createString(accountId); 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.startTargetRequest(b);
fb.TargetRequest.addAccountId(b, id); fb.TargetRequest.addAccountId(b, id);
if (gid) fb.TargetRequest.addGameId(b, gid);
return finish(b, fb.TargetRequest.endTargetRequest(b)); return finish(b, fb.TargetRequest.endTargetRequest(b));
} }
@@ -722,14 +728,25 @@ export function decodeOutgoingList(buf: Uint8Array): AccountRef[] {
return out; return out;
} }
export function decodeBlockList(buf: Uint8Array): AccountRef[] { export function decodeBlockList(buf: Uint8Array): BlockList {
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf)); const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
const out: AccountRef[] = []; const blocked: AccountRef[] = [];
for (let i = 0; i < l.blockedLength(); i++) { for (let i = 0; i < l.blockedLength(); i++) {
const r = l.blocked(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 { export function decodeFriendCode(buf: Uint8Array): FriendCode {
+7 -3
View File
@@ -13,6 +13,7 @@ import type {
import { GatewayError } from '../client'; import { GatewayError } from '../client';
import type { import type {
AccountRef, AccountRef,
BlockList,
BlockStatus, BlockStatus,
ChatMessage, ChatMessage,
EvalResult, EvalResult,
@@ -555,10 +556,13 @@ export class MockGateway implements GatewayClient {
} }
// --- blocks --- // --- blocks ---
async blocksList(): Promise<AccountRef[]> { async blocksList(): Promise<BlockList> {
return this.blocks.map((b) => ({ ...b })); // 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); this.friends = this.friends.filter((f) => f.accountId !== accountId);
if (!this.blocks.some((b) => b.accountId === accountId)) { if (!this.blocks.some((b) => b.accountId === accountId)) {
this.blocks.push({ accountId, displayName: this.nameFor(accountId) }); this.blocks.push({ accountId, displayName: this.nameFor(accountId) });
+16
View File
@@ -205,6 +205,22 @@ export interface AccountRef {
displayName: string; 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). */ /** A freshly issued one-time friend code (the plaintext is returned once). */
export interface FriendCode { export interface FriendCode {
code: string; code: string;
+2 -2
View File
@@ -187,8 +187,8 @@ export function createTransport(baseUrl: string): GatewayClient {
async blocksList() { async blocksList() {
return codec.decodeBlockList(await exec('blocks.list', codec.empty())); return codec.decodeBlockList(await exec('blocks.list', codec.empty()));
}, },
async block(accountId) { async block(accountId, gameId) {
await exec('blocks.add', codec.encodeTarget(accountId)); await exec('blocks.add', codec.encodeTarget(accountId, gameId));
}, },
async unblock(accountId) { async unblock(accountId) {
await exec('blocks.remove', codec.encodeTarget(accountId)); await exec('blocks.remove', codec.encodeTarget(accountId));
+15 -3
View File
@@ -8,21 +8,27 @@
import { translate } from '../lib/i18n/catalog'; import { translate } from '../lib/i18n/catalog';
import { friendCodeParam, shareLink } from '../lib/deeplink'; import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram'; 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 friends = $state<AccountRef[]>([]);
let incoming = $state<AccountRef[]>([]); let incoming = $state<AccountRef[]>([]);
let blocked = $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 code = $state<FriendCode | null>(null);
let redeemInput = $state(''); let redeemInput = $state('');
async function load() { async function load() {
try { try {
[friends, incoming, blocked] = await Promise.all([ const [fl, inc, bl] = await Promise.all([
gateway.friendsList(), gateway.friendsList(),
gateway.friendsIncoming(), gateway.friendsIncoming(),
gateway.blocksList(), gateway.blocksList(),
]); ]);
friends = fl;
incoming = inc;
blocked = bl.blocked;
robotBlocks = bl.robots;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
@@ -179,7 +185,7 @@
{/if} {/if}
</section> </section>
{#if blocked.length} {#if blocked.length || robotBlocks.length}
<section> <section>
<h3>{t('friends.blockedList')}</h3> <h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)} {#each blocked as b (b.accountId)}
@@ -188,6 +194,12 @@
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div> </div>
{/each} {/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> </section>
{/if} {/if}
{/if} {/if}