diff --git a/PRERELEASE.md b/PRERELEASE.md index 3321c6c..c1ed1a1 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -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) diff --git a/backend/internal/inttest/block_test.go b/backend/internal/inttest/block_test.go index 7993323..bad78a7 100644 --- a/backend/internal/inttest/block_test.go +++ b/backend/internal/inttest/block_test.go @@ -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() diff --git a/backend/internal/postgres/migrations/00011_robot_blocks.sql b/backend/internal/postgres/migrations/00011_robot_blocks.sql new file mode 100644 index 0000000..32a9083 --- /dev/null +++ b/backend/internal/postgres/migrations/00011_robot_blocks.sql @@ -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; diff --git a/backend/internal/server/handlers_blocks.go b/backend/internal/server/handlers_blocks.go index 0f5b4a3..1f345ca 100644 --- a/backend/internal/server/handlers_blocks.go +++ b/backend/internal/server/handlers_blocks.go @@ -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) } diff --git a/backend/internal/server/handlers_friends.go b/backend/internal/server/handlers_friends.go index 636efc4..c300694 100644 --- a/backend/internal/server/handlers_friends.go +++ b/backend/internal/server/handlers_friends.go @@ -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. diff --git a/backend/internal/social/blocks.go b/backend/internal/social/blocks.go index b9d36bf..998e805 100644 --- a/backend/internal/social/blocks.go +++ b/backend/internal/social/blocks.go @@ -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 } diff --git a/backend/internal/social/robotblocks.go b/backend/internal/social/robotblocks.go new file mode 100644 index 0000000..0823a55 --- /dev/null +++ b/backend/internal/social/robotblocks.go @@ -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() +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f28570c..6a0fc3a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 2–4 player game starts once **every** invitee accepts; any decline cancels the invitation, and a pending invitation diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 200a226..f541828 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index caeeec0..6e633ad 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -203,7 +203,12 @@ nudge) приходят от бота **этой партии** — по язы продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей** -есть только там. Чат +есть только там. +Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же +(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, +которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий +аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что +заблокировать себя без соперников нельзя). Чат партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index c28dadd..f9b9fb4 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -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. diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index c183ae5..ca4cad3 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -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() } diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index 576c58d..c3a900c 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -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 diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 19b0107..53f2917 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -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. diff --git a/pkg/fbs/scrabblefb/BlockList.go b/pkg/fbs/scrabblefb/BlockList.go index 6ecba6e..ed5a8cb 100644 --- a/pkg/fbs/scrabblefb/BlockList.go +++ b/pkg/fbs/scrabblefb/BlockList.go @@ -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() } diff --git a/pkg/fbs/scrabblefb/RobotBlockRef.go b/pkg/fbs/scrabblefb/RobotBlockRef.go new file mode 100644 index 0000000..c2e179d --- /dev/null +++ b/pkg/fbs/scrabblefb/RobotBlockRef.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/TargetRequest.go b/pkg/fbs/scrabblefb/TargetRequest.go index a211e8c..e45b1a9 100644 --- a/pkg/fbs/scrabblefb/TargetRequest.go +++ b/pkg/fbs/scrabblefb/TargetRequest.go @@ -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() } diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 50f1efc..1beef2c 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -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()); + let blockedRobotSeats = $state(new Set()); 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 */ } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index d38a6d0..5358a99 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -892,8 +892,11 @@ let requested = $state(new Set()); // `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()); + let blockedRobotSeats = $state(new Set()); // 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); } @@ -1044,22 +1056,22 @@ {#if app.chatUnread[id]}{/if} {#each view.game.seats as s (s.seat)}
-
{seatName(s)}
+
{seatName(s)}
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
- {#if historyShown && canBlock(s.accountId) && !addConfirm[s.seat]} + {#if historyShown && canBlock(s) && !addConfirm[s.seat]} (blockConfirm[s.seat] = v)} - onconfirm={() => blockUser(s.accountId)} + onconfirm={() => blockUser(s)} > ✖️ {/if} - {#if historyShown && canAddFriend(s.accountId) && !blockConfirm[s.seat]} + {#if historyShown && canAddFriend(s) && !blockConfirm[s.seat]} = 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); } } diff --git a/ui/src/gen/fbs/scrabblefb/robot-block-ref.ts b/ui/src/gen/fbs/scrabblefb/robot-block-ref.ts new file mode 100644 index 0000000..164c79d --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/robot-block-ref.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/target-request.ts b/ui/src/gen/fbs/scrabblefb/target-request.ts index 7cf9276..9262648 100644 --- a/ui/src/gen/fbs/scrabblefb/target-request.ts +++ b/ui/src/gen/fbs/scrabblefb/target-request.ts @@ -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); } } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 8013596..bb55630 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -6,6 +6,7 @@ import type { AccountRef, + BlockList, BlockStatus, ChatMessage, EvalResult, @@ -127,8 +128,8 @@ export interface GatewayClient { friendCodeRedeem(code: string): Promise; // --- blocks --- - blocksList(): Promise; - block(accountId: string): Promise; + blocksList(): Promise; + block(accountId: string, gameId?: string): Promise; unblock(accountId: string): Promise; // --- invitations --- diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index f155747..7f23cdb 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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 { diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 78ecca8..ed24928 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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 { - return this.blocks.map((b) => ({ ...b })); + async blocksList(): Promise { + // 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 { + async block(accountId: string, _gameId?: string): Promise { + // 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) }); diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 5581fbf..402bd43 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index e486391..4219024 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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)); diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index 13d27f5..ceb5e7e 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -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([]); let incoming = $state([]); let blocked = $state([]); + // Per-game disguised-robot blocks, shown as distinct entries alongside blocked humans. + let robotBlocks = $state([]); let code = $state(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} - {#if blocked.length} + {#if blocked.length || robotBlocks.length}

{t('friends.blockedList')}

{#each blocked as b (b.accountId)} @@ -188,6 +194,12 @@
{/each} + {#each robotBlocks as r (r.id)} +
+ {r.displayName} + +
+ {/each} {/if} {/if}