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
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:
+1
-1
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 2–4 player game starts once **every**
|
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
|
invitee accepts; any decline cancels the invitation, and a pending invitation
|
||||||
|
|||||||
+6
-1
@@ -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
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у
|
продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у
|
||||||
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
|
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
|
||||||
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
|
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
|
||||||
есть только там. Чат
|
есть только там.
|
||||||
|
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
|
||||||
|
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
|
||||||
|
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
|
||||||
|
аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что
|
||||||
|
заблокировать себя без соперников нельзя). Чат
|
||||||
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
|
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
|
||||||
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
||||||
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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')}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user