Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -35,6 +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. 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)
|
||||
|
||||
@@ -189,6 +189,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
|
||||
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
|
||||
matchmaker.SetNotifier(hub)
|
||||
matchmaker.SetBlocker(socialSvc)
|
||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||
invitations.SetNotifier(hub)
|
||||
|
||||
@@ -26,6 +26,11 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
|
||||
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
|
||||
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
|
||||
}, `/_gm/users/c3`},
|
||||
{"throttled", ThrottledView{
|
||||
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
|
||||
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
|
||||
|
||||
@@ -102,6 +102,36 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Friends</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Friends}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocks</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Blocks}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocked by</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .BlockedBy}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{if .TelegramID}}
|
||||
<section class="panel"><h2>Send Telegram message</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
|
||||
@@ -175,6 +175,21 @@ type UserDetailView struct {
|
||||
// grant form offers. The first role is the feedback ban (see internal/account).
|
||||
Roles []string
|
||||
KnownRoles []string
|
||||
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
|
||||
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
|
||||
// other account with the date it happened. They are the full truth; the asymmetric block
|
||||
// suppression that hides relationships from players never applies to the console.
|
||||
Blocks []RelationRow
|
||||
BlockedBy []RelationRow
|
||||
Friends []RelationRow
|
||||
}
|
||||
|
||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
||||
type RelationRow struct {
|
||||
AccountID string
|
||||
DisplayName string
|
||||
Date string
|
||||
}
|
||||
|
||||
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||
|
||||
@@ -274,7 +274,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
|
||||
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
|
||||
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) {
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
@@ -319,7 +319,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
if seed&1 == 1 {
|
||||
seats = []seatInsert{{}, caller}
|
||||
}
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
|
||||
@@ -200,22 +200,27 @@ func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
// order) used only when a game is created; callerName is the caller's display-name
|
||||
// snapshot, stamped on their seat whether they open a fresh game or fill another
|
||||
// player's open one.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
return fmt.Errorf("open match lock: %w", e)
|
||||
}
|
||||
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
|
||||
// A game whose waiting player is in exclude (the caller's per-user block set, either
|
||||
// direction) is skipped, so a block keeps the pair out of the same anonymous game;
|
||||
// an empty exclude (the "{}" literal) excludes nothing.
|
||||
var other uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
|
||||
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
|
||||
WHERE p.game_id = g.game_id AND p.account_id = $3)
|
||||
AND NOT EXISTS (SELECT 1 FROM backend.game_players b
|
||||
WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
|
||||
ORDER BY g.created_at
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
||||
return er
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked
|
||||
// user had left unread for the blocker in their shared games, so no stale unread badge
|
||||
// lingers for someone the blocker no longer sees.
|
||||
func TestBlockInstantReadsExistingUnread(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat
|
||||
|
||||
if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread {
|
||||
t.Fatal("viewer should have the message unread before blocking")
|
||||
}
|
||||
if err := svc.Block(ctx, viewer, sender); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread {
|
||||
t.Error("blocking should instant-read the blocked sender's prior unread message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a
|
||||
// message the blocked user sends is stored and visible to themselves, but is born read for
|
||||
// the blocker, never delivered to them, and hidden from their view.
|
||||
func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil {
|
||||
t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
|
||||
t.Error("the blocked sender's message must be born read for the blocker")
|
||||
}
|
||||
if pub.delivered(blocker, notify.KindChatMessage) {
|
||||
t.Error("the blocked sender's message must not be delivered to the blocker")
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 {
|
||||
t.Errorf("blocker still sees the blocked sender's message: %+v", msgs)
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 {
|
||||
t.Errorf("the blocked sender should see their own message, got %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their
|
||||
// only opponent is someone they have blocked (the composer is hidden client-side too).
|
||||
func TestChatToBlockedIsRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) {
|
||||
t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a
|
||||
// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking
|
||||
// ordinary to them) but is born read and never delivered to the blocker.
|
||||
func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, blocked); err != nil {
|
||||
t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
|
||||
t.Error("the blocked sender's nudge must be born read for the blocker")
|
||||
}
|
||||
if pub.delivered(blocker, notify.KindNudge) {
|
||||
t.Error("the blocked sender's nudge must not be delivered to the blocker")
|
||||
}
|
||||
// The nudge is still recorded, so the cooldown applies (the blocked user notices nothing).
|
||||
if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok {
|
||||
t.Error("the suppressed nudge should still be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the
|
||||
// awaited opponent when they have blocked them.
|
||||
func TestNudgeToBlockedIsRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) {
|
||||
t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so
|
||||
// their other sessions update in place) and never reach the blocked user.
|
||||
func TestBlockPublishesToBlockerOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
blocker, blocked := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if !pub.notified(blocker, notify.NotifyUserBlocked) {
|
||||
t.Error("the blocker should receive a user_blocked event")
|
||||
}
|
||||
if pub.notified(blocked, notify.NotifyUserBlocked) {
|
||||
t.Error("the blocked user must never receive a user_blocked event")
|
||||
}
|
||||
if err := svc.Unblock(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("unblock: %v", err)
|
||||
}
|
||||
if !pub.notified(blocker, notify.NotifyUserUnblocked) {
|
||||
t.Error("the blocker should receive a user_unblocked event")
|
||||
}
|
||||
if pub.notified(blocked, notify.NotifyUserUnblocked) {
|
||||
t.Error("the blocked user must never receive a user_unblocked event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a
|
||||
// block between them (either direction), while an unblocked third player still joins.
|
||||
func TestMatchmakingExcludesBlockedPlayers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
|
||||
soc := newSocialService()
|
||||
mm.SetBlocker(soc)
|
||||
a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if err := soc.Block(ctx, a, b); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
// The block is seen from both sides — the exclusion set unions both directions.
|
||||
if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) {
|
||||
t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked")
|
||||
}
|
||||
|
||||
// b opens a game awaiting an opponent.
|
||||
r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must open a game, not match")
|
||||
}
|
||||
// a must not join b's game (a blocked b): it opens its own instead.
|
||||
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r2.Matched || r2.Game.ID == r1.Game.ID {
|
||||
t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched)
|
||||
}
|
||||
// A third, unblocked player joins b's still-open game (the oldest), proving the exclusion
|
||||
// is specific to the blocked pair, not a blanket refusal.
|
||||
r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue c: %v", err)
|
||||
}
|
||||
if !r3.Matched || r3.Game.ID != r1.Game.ID {
|
||||
t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries
|
||||
// return the full truth in both directions, including a friendship that a block overrides but
|
||||
// does not delete.
|
||||
func TestAdminSocialLists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
a, b := seats[0], seats[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if err := svc.Block(ctx, a, b); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
|
||||
blocks, err := svc.AdminBlocksBy(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("admin blocks by: %v", err)
|
||||
}
|
||||
if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() {
|
||||
t.Errorf("a's blocks = %v, want [b] with a date", blocks)
|
||||
}
|
||||
blockedBy, err := svc.AdminBlockedBy(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("admin blocked by: %v", err)
|
||||
}
|
||||
if len(blockedBy) != 1 || blockedBy[0].AccountID != a {
|
||||
t.Errorf("b's blocked-by = %v, want [a]", blockedBy)
|
||||
}
|
||||
// The friendship survives the block, so it still shows on both admin friend lists.
|
||||
friendsA, err := svc.AdminFriends(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("admin friends: %v", err)
|
||||
}
|
||||
if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() {
|
||||
t.Errorf("a's admin friends = %v, want [b] with a date", friendsA)
|
||||
}
|
||||
if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a {
|
||||
t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
ids, err := soc.BlockedWith(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("blocked with: %v", err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// contains reports whether ids includes want.
|
||||
func contains(ids []uuid.UUID, want uuid.UUID) bool {
|
||||
for _, id := range ids {
|
||||
if id == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
|
||||
// An open (awaiting-opponent) quick game counts.
|
||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
||||
}, time.Now().Add(time.Minute)); err != nil {
|
||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
||||
t.Fatalf("open quick game: %v", err)
|
||||
}
|
||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||
|
||||
@@ -198,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
|
||||
func TestInvitationBlockedInvitee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
social := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
soc := newSocialService()
|
||||
|
||||
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
|
||||
inviter := provisionAccount(t)
|
||||
invitee := provisionAccount(t)
|
||||
if err := social.Block(ctx, invitee, inviter); err != nil {
|
||||
blockedInvitee := provisionAccount(t)
|
||||
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err)
|
||||
}
|
||||
|
||||
// An invitee who has blocked the inviter is instead suppressed: the invitation is created
|
||||
// (no error — the inviter notices nothing), but that invitee is never notified and never
|
||||
// sees it in their list.
|
||||
inviter2 := provisionAccount(t)
|
||||
suppressed := provisionAccount(t)
|
||||
if err := soc.Block(ctx, suppressed, inviter2); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil {
|
||||
t.Fatalf("create with a suppressing invitee = %v, want nil", err)
|
||||
}
|
||||
if pub.notified(suppressed, notify.NotifyInvitation) {
|
||||
t.Error("an invitee who blocked the inviter must not be notified of the invitation")
|
||||
}
|
||||
if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 {
|
||||
t.Errorf("suppressed invitee's invitation list = %v, want empty", list)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func openParams(seed int64) game.CreateParams {
|
||||
|
||||
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
|
||||
t.Helper()
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open game: %v", err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
starter := provisionAccount(t)
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
}
|
||||
|
||||
joiner := provisionAccount(t)
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("join: %v", err)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// delivered reports whether an intent of the given top-level kind (e.g. chat_message,
|
||||
// nudge) was published to user — used to assert that a blocked sender's message or nudge
|
||||
// never reaches the blocker.
|
||||
func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, in := range c.intents {
|
||||
if in.UserID == user && in.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||
// just sits unanswered and later expires — mirroring a human who ignores it.
|
||||
@@ -128,17 +142,53 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
|
||||
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
|
||||
// Block: the addressee has blocked the requester.
|
||||
c, d := provisionAccount(t), provisionAccount(t)
|
||||
if err := svc.Block(ctx, d, c); err != nil {
|
||||
// Block from the requester's own side: a requester who has blocked the addressee is
|
||||
// refused outright (they are the blocker and aware of it). The reverse direction — the
|
||||
// addressee having blocked the requester — is instead silently suppressed, covered by
|
||||
// TestFriendRequestFromBlockedIsSuppressed.
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
c, d := seats[0], seats[1]
|
||||
if err := svc.Block(ctx, c, d); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
|
||||
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
|
||||
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSeversFriendship(t *testing.T) {
|
||||
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the
|
||||
// addressee has blocked the requester, the requester's friend request succeeds silently
|
||||
// (no error — they must not notice the block), is stored, but is never delivered to nor
|
||||
// surfaced for the blocker.
|
||||
func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1]
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil {
|
||||
t.Fatalf("suppressed request = %v, want nil", err)
|
||||
}
|
||||
if pub.notified(blocker, notify.NotifyFriendRequest) {
|
||||
t.Error("blocker must not be notified of a suppressed request")
|
||||
}
|
||||
if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 {
|
||||
t.Errorf("blocker incoming = %v, want none (suppressed)", got)
|
||||
}
|
||||
// The blocked user sees an ordinary outgoing request — no leak of the block.
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker {
|
||||
t.Errorf("blocked outgoing = %v, want [blocker]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a
|
||||
// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is
|
||||
// unchanged (so they never notice), and an unblock cleanly restores the friendship.
|
||||
func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
|
||||
t.Errorf("friendship must be severed by a block, got %v", friends)
|
||||
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
|
||||
t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends)
|
||||
}
|
||||
if err := svc.Unblock(ctx, a, b); err != nil {
|
||||
t.Fatalf("unblock: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
|
||||
t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
|
||||
}
|
||||
intents := make([]notify.Intent, 0, len(recipients))
|
||||
for _, id := range recipients {
|
||||
// An invitee who has blocked the inviter never sees this invitation, so they must not
|
||||
// receive its updates either (an update would otherwise re-add it to their lobby). The
|
||||
// inviter always gets updates; on a lookup error suppress rather than risk a leak.
|
||||
if id != inv.InviterID {
|
||||
if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk {
|
||||
continue
|
||||
}
|
||||
}
|
||||
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
@@ -211,6 +219,11 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
seen := map[uuid.UUID]bool{inviterID: true}
|
||||
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
||||
// created and persisted for them, but they are never notified and never see it (their
|
||||
// friendship with the inviter survived the block, so they can still appear in the
|
||||
// inviter's picker — they must not learn of the block).
|
||||
suppressed := make(map[uuid.UUID]bool)
|
||||
for _, id := range inviteeIDs {
|
||||
if seen[id] {
|
||||
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
|
||||
@@ -222,13 +235,22 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
}
|
||||
return Invitation{}, err
|
||||
}
|
||||
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
|
||||
// A block the inviter placed refuses the invite (the inviter is aware; the picker hides
|
||||
// the invitee anyway). A block the invitee placed on the inviter is instead suppressed.
|
||||
iBlock, err := svc.blocker.Blocks(ctx, inviterID, id)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if blocked {
|
||||
if iBlock {
|
||||
return Invitation{}, ErrInvitationBlocked
|
||||
}
|
||||
theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if theyBlock {
|
||||
suppressed[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
@@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
svc.emitInvitation(ctx, inv, inviteeIDs)
|
||||
// Notify every invitee except those who have blocked the inviter (their copy stays
|
||||
// stored but unseen — the store-but-hide that keeps the block invisible).
|
||||
notifyIDs := inviteeIDs
|
||||
if len(suppressed) > 0 {
|
||||
notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs))
|
||||
for _, id := range inviteeIDs {
|
||||
if !suppressed[id] {
|
||||
notifyIDs = append(notifyIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
svc.emitInvitation(ctx, inv, notifyIDs)
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
@@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hide an invitation whose inviter the viewer has blocked: it stays stored but must
|
||||
// never surface to the blocker. The viewer's own invitations (as inviter) are unaffected.
|
||||
if inv.InviterID != accountID {
|
||||
blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -38,11 +38,15 @@ type RobotProvider interface {
|
||||
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
|
||||
}
|
||||
|
||||
// Blocker reports whether two accounts have a block between them (either
|
||||
// direction). social.Service satisfies it; the lobby uses it to refuse
|
||||
// invitations between blocked accounts.
|
||||
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it.
|
||||
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a
|
||||
// block the invitee placed on the inviter is silently suppressed; auto-match consults
|
||||
// BlockedWith so a block either way keeps the pair out of the same anonymous game.
|
||||
type Blocker interface {
|
||||
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
// Blocks reports whether blocker has blocked blocked (exact direction).
|
||||
Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error)
|
||||
// BlockedWith returns every account in a block with accountID in either direction.
|
||||
BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error)
|
||||
}
|
||||
|
||||
// Auto-match defaults: a casual two-player game on the longest move clock with one
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
||||
// it.
|
||||
type GameMatcher interface {
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
|
||||
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
|
||||
// one; exclude lists account IDs whose open game must never be joined (the caller's
|
||||
// per-user block set), so a block keeps the pair out of the same anonymous game.
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
|
||||
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
|
||||
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
@@ -35,8 +38,9 @@ type GameMatcher interface {
|
||||
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
|
||||
// concurrent use.
|
||||
//
|
||||
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
|
||||
// friends, chat and invitations between known players).
|
||||
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
|
||||
// paired into a game whose waiting opponent they have blocked, or who has blocked them
|
||||
// (either direction), so a block keeps two players apart everywhere.
|
||||
type Matchmaker struct {
|
||||
games GameMatcher
|
||||
robots RobotProvider
|
||||
@@ -44,6 +48,7 @@ type Matchmaker struct {
|
||||
jitter time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
blocker Blocker
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -75,6 +80,16 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
|
||||
// player with someone they have a block with (either direction). It must be called
|
||||
// during startup wiring; the default is no blocker, in which case auto-match pairs
|
||||
// anonymously (no block exclusion).
|
||||
func (m *Matchmaker) SetBlocker(b Blocker) {
|
||||
if b != nil {
|
||||
m.blocker = b
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
|
||||
// in, and whether it already had an opponent (they joined a waiting game) rather than
|
||||
// being freshly opened and still awaiting one.
|
||||
@@ -90,7 +105,17 @@ type EnqueueResult struct {
|
||||
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
|
||||
// that game's waiting starter.
|
||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
|
||||
// Exclude every account the caller has a block with (either direction) from the
|
||||
// candidate open games, so auto-match never pairs a blocked pair into one game.
|
||||
var exclude []uuid.UUID
|
||||
if m.blocker != nil {
|
||||
var err error
|
||||
exclude, err = m.blocker.BlockedWith(ctx, accountID)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
}
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type stubMatcher struct {
|
||||
openErr error
|
||||
openCalls int
|
||||
lastDeadline time.Time
|
||||
lastExclude []uuid.UUID
|
||||
|
||||
expired []game.OpenGame
|
||||
|
||||
@@ -39,9 +40,10 @@ type stubMatcher struct {
|
||||
createParams game.CreateParams
|
||||
}
|
||||
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) {
|
||||
s.openCalls++
|
||||
s.lastDeadline = deadline
|
||||
s.lastExclude = exclude
|
||||
return s.openGame, s.openJoined, s.openErr
|
||||
}
|
||||
|
||||
@@ -95,6 +97,33 @@ type capturePub struct{ intents []notify.Intent }
|
||||
|
||||
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
|
||||
|
||||
// fakeBlocker is a Blocker returning a canned both-direction block set.
|
||||
type fakeBlocker struct {
|
||||
with []uuid.UUID
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil }
|
||||
func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) {
|
||||
return f.with, f.err
|
||||
}
|
||||
|
||||
// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set
|
||||
// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game
|
||||
// whose waiting player has a block with the caller.
|
||||
func TestEnqueueExcludesBlockedStarters(t *testing.T) {
|
||||
caller, blocked := uuid.New(), uuid.New()
|
||||
m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}})
|
||||
if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if !slices.Contains(m.lastExclude, blocked) {
|
||||
t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
|
||||
// (uuid.Nil for a still-empty opponent seat).
|
||||
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
||||
|
||||
@@ -62,6 +62,16 @@ const (
|
||||
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
|
||||
// banner set rides the profile response). In-app only.
|
||||
NotifyBanner = "banner"
|
||||
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
|
||||
// carrying the blocked account, so every one of the blocker's sessions updates the
|
||||
// in-game block/add-friend controls and the struck name in place. It is delivered
|
||||
// only to the blocker — never to the blocked user, who must not learn of the block —
|
||||
// and is in-app only (no out-of-app push: blocking is the viewer's own action).
|
||||
NotifyUserBlocked = "user_blocked"
|
||||
// NotifyUserUnblocked confirms an unblock to the (former) blocker, carrying the
|
||||
// unblocked account, so their open game screens restore the controls and un-strike
|
||||
// the name in place. Like NotifyUserBlocked it reaches only the actor and is in-app only.
|
||||
NotifyUserUnblocked = "user_unblocked"
|
||||
)
|
||||
|
||||
// Intent is one live event destined for a single user. Payload is the
|
||||
|
||||
@@ -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;
|
||||
@@ -389,9 +389,30 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view.Roles = roles
|
||||
}
|
||||
view.KnownRoles = account.KnownRoles
|
||||
if s.social != nil {
|
||||
if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil {
|
||||
view.Blocks = relationRows(rels)
|
||||
}
|
||||
if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil {
|
||||
view.BlockedBy = relationRows(rels)
|
||||
}
|
||||
if rels, err := s.social.AdminFriends(ctx, id); err == nil {
|
||||
view.Friends = relationRows(rels)
|
||||
}
|
||||
}
|
||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||
}
|
||||
|
||||
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
|
||||
// user card renders.
|
||||
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
||||
out := make([]adminconsole.RelationRow, 0, len(rels))
|
||||
for _, r := range rels {
|
||||
out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// consoleUserMessage sends an operator Telegram message to one user.
|
||||
func (s *Server) consoleUserMessage(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -2,19 +2,33 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
|
||||
// is mutual in effect (the social checks apply it both ways) and severs any
|
||||
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
|
||||
// account-ref resolution.
|
||||
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block is asymmetric:
|
||||
// the blocker stops seeing everything from the blocked user (chat, nudge, requests,
|
||||
// invitations, matchmaking) while the blocked user notices nothing; it overrides — but does
|
||||
// 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.
|
||||
@@ -34,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
|
||||
}
|
||||
@@ -67,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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AdminRelation is one entry in the admin user card's blocks / blocked-by / friends lists:
|
||||
// the other account, its display name, and when the relationship was recorded. It is the
|
||||
// unfiltered truth — the asymmetric block suppression that hides relationships from players
|
||||
// never applies to the operator console.
|
||||
type AdminRelation struct {
|
||||
AccountID uuid.UUID
|
||||
DisplayName string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// AdminBlocksBy returns the accounts blockerID has blocked, with display names and block
|
||||
// times, newest first — the admin card's "blocks" list.
|
||||
func (svc *Service) AdminBlocksBy(ctx context.Context, blockerID uuid.UUID) ([]AdminRelation, error) {
|
||||
return svc.store.adminBlocks(ctx, blockerID, true)
|
||||
}
|
||||
|
||||
// AdminBlockedBy returns the accounts that have blocked blockedID, with names and times,
|
||||
// newest first — the admin card's "blocked by" list.
|
||||
func (svc *Service) AdminBlockedBy(ctx context.Context, blockedID uuid.UUID) ([]AdminRelation, error) {
|
||||
return svc.store.adminBlocks(ctx, blockedID, false)
|
||||
}
|
||||
|
||||
// AdminFriends returns accountID's accepted friendships in either direction, with the other
|
||||
// account's name and when the friendship was accepted, newest first — the admin card's
|
||||
// "friends" list.
|
||||
func (svc *Service) AdminFriends(ctx context.Context, accountID uuid.UUID) ([]AdminRelation, error) {
|
||||
const q = `SELECT other_id, a.display_name, at FROM (
|
||||
SELECT CASE WHEN f.requester_id = $1 THEN f.addressee_id ELSE f.requester_id END AS other_id,
|
||||
COALESCE(f.responded_at, f.created_at) AS at
|
||||
FROM backend.friendships f
|
||||
WHERE f.status = 'accepted' AND (f.requester_id = $1 OR f.addressee_id = $1)
|
||||
) rel
|
||||
JOIN backend.accounts a ON a.account_id = rel.other_id
|
||||
ORDER BY at DESC`
|
||||
return svc.store.adminRelations(ctx, q, accountID)
|
||||
}
|
||||
|
||||
// adminBlocks reads the blocks touching id: when asBlocker the rows where id is the blocker
|
||||
// (the other side is who they blocked), otherwise the rows where id is the blocked (the
|
||||
// other side is who blocked them).
|
||||
func (s *Store) adminBlocks(ctx context.Context, id uuid.UUID, asBlocker bool) ([]AdminRelation, error) {
|
||||
q := `SELECT b.blocked_id, a.display_name, b.created_at
|
||||
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocked_id
|
||||
WHERE b.blocker_id = $1 ORDER BY b.created_at DESC`
|
||||
if !asBlocker {
|
||||
q = `SELECT b.blocker_id, a.display_name, b.created_at
|
||||
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocker_id
|
||||
WHERE b.blocked_id = $1 ORDER BY b.created_at DESC`
|
||||
}
|
||||
return s.adminRelations(ctx, q, id)
|
||||
}
|
||||
|
||||
// adminRelations runs an admin list query of the (account_id, display_name, at) shape.
|
||||
func (s *Store) adminRelations(ctx context.Context, query string, id uuid.UUID) ([]AdminRelation, error) {
|
||||
rows, err := s.db.QueryContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("social: admin relations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminRelation
|
||||
for rows.Next() {
|
||||
var r AdminRelation
|
||||
if err := rows.Scan(&r.AccountID, &r.DisplayName, &r.At); err != nil {
|
||||
return nil, fmt.Errorf("social: scan admin relation: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -10,24 +10,49 @@ import (
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Block records that blockerID has blocked blockedID. Blocking severs any
|
||||
// friendship or pending request between the two and, through the mutual block
|
||||
// checks, suppresses chat visibility and new requests/invitations in both
|
||||
// directions. It is idempotent.
|
||||
// Block records that blockerID has blocked blockedID. The block is asymmetric and
|
||||
// non-destructive: it suppresses everything the blocked user sends toward the blocker
|
||||
// (chat, nudge, friend requests, invitations are persisted but never delivered or
|
||||
// surfaced) and hides the blocked user from the blocker's lists, while the blocked
|
||||
// user notices nothing. It does NOT delete the friendship — an unblock cleanly
|
||||
// restores it — and keeps every stored row. As a courtesy it marks any of the blocked
|
||||
// user's still-unread chat entries in their shared games read at once, so no stale
|
||||
// unread badge lingers for someone the blocker no longer sees. A user_blocked event is
|
||||
// delivered to the blocker (only) so their other sessions update in place. It is idempotent.
|
||||
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
if blockerID == blockedID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
return svc.store.insertBlock(ctx, blockerID, blockedID)
|
||||
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserBlocked, svc.accountRef(ctx, blockedID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unblock removes blockerID's block on blockedID. It is idempotent.
|
||||
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
return svc.store.deleteBlock(ctx, blockerID, blockedID)
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ListBlocks returns the account IDs blockerID has blocked.
|
||||
@@ -35,11 +60,43 @@ func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid
|
||||
return svc.store.listBlocks(ctx, blockerID)
|
||||
}
|
||||
|
||||
// BlockedWith returns every account that has a block with accountID in either
|
||||
// direction (those accountID blocked, plus those who blocked accountID), de-duplicated.
|
||||
// The matchmaker excludes them so a block — either way — keeps the pair out of the same
|
||||
// anonymous auto-match game.
|
||||
func (svc *Service) BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
mine, err := svc.store.listBlocks(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
theirs, err := svc.store.listBlockedBy(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[uuid.UUID]struct{}, len(mine)+len(theirs))
|
||||
out := make([]uuid.UUID, 0, len(mine)+len(theirs))
|
||||
for _, id := range append(mine, theirs...) {
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsBlocked reports whether a block stands between a and b in either direction.
|
||||
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
return svc.store.isBlocked(ctx, a, b)
|
||||
}
|
||||
|
||||
// Blocks reports whether blocker has blocked blocked — the exact direction, not the
|
||||
// symmetric IsBlocked. It backs the directional guards (chat, friend requests,
|
||||
// invitations) that must tell the blocker (refuse) from the blocked (silently suppress).
|
||||
func (svc *Service) Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
|
||||
return svc.store.blockExists(ctx, blocker, blocked)
|
||||
}
|
||||
|
||||
// isBlocked reports whether a block row exists between a and b in either direction.
|
||||
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
@@ -58,14 +115,33 @@ func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertBlock severs any friendship between the pair and inserts the block, in one
|
||||
// transaction; a duplicate block is ignored.
|
||||
// blockExists reports whether blocker has blocked blocked — the exact direction, not
|
||||
// the symmetric isBlocked. The asymmetric block turns on this one-directional test:
|
||||
// the blocker filters out and refuses the blocked user, while the blocked user's own
|
||||
// actions are silently suppressed rather than refused, so they never notice.
|
||||
func (s *Store) blockExists(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
|
||||
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))).
|
||||
LIMIT(1)
|
||||
var row model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("social: block exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertBlock inserts the block and, in the same transaction, marks read every chat
|
||||
// entry the blocked user authored that the blocker had still left unread across their
|
||||
// shared games — so no stale unread badge lingers for someone the blocker no longer
|
||||
// sees. It deliberately leaves the friendship intact (the block overrides it; an
|
||||
// unblock restores it). A duplicate block is ignored.
|
||||
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear friendship on block: %w", err)
|
||||
}
|
||||
ins := table.Blocks.
|
||||
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
|
||||
VALUES(blocker, blocked).
|
||||
@@ -73,6 +149,17 @@ func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) err
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert block: %w", err)
|
||||
}
|
||||
// Clear the blocker's unread bit on every entry the blocked user sent, in any game
|
||||
// they share, resolving the blocker's seat through game_players. The bitwise terms
|
||||
// are cast through int4 so the operators resolve unambiguously (as in markRead).
|
||||
const clearUnread = `UPDATE backend.chat_messages m
|
||||
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
|
||||
FROM backend.game_players p
|
||||
WHERE p.account_id = $1 AND p.game_id = m.game_id
|
||||
AND m.sender_id = $2 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
|
||||
if _, err := tx.ExecContext(ctx, clearUnread, blocker, blocked); err != nil {
|
||||
return fmt.Errorf("clear unread on block: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -104,3 +191,21 @@ func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listBlockedBy returns the accounts that have blocked blocked — the reverse of
|
||||
// listBlocks. The matchmaker unions the two so a block in either direction keeps a
|
||||
// pair out of the same auto-match game.
|
||||
func (s *Store) listBlockedBy(ctx context.Context, blocked uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))
|
||||
var rows []model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list blocked by: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.BlockerID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
+124
-13
@@ -100,17 +100,32 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
if err := Clean(body); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
// A disguised robot opponent never reads chat, so its recipient bit is born clear.
|
||||
// Blocker-side guard: a sender whose only opponents are people they have blocked cannot
|
||||
// post (the composer is hidden client-side; this is the server-side counterpart). In a
|
||||
// multi-player game with a non-blocked opponent the post is allowed and reaches them.
|
||||
if guard, err := svc.senderBlocksEveryOpponent(ctx, seats, senderID); err != nil {
|
||||
return Message{}, err
|
||||
} else if guard {
|
||||
return Message{}, ErrRecipientBlocked
|
||||
}
|
||||
// Recipients whose copy is born read so it never shows as unread: a disguised robot
|
||||
// opponent (never opens the chat) and any recipient who has blocked the sender — the
|
||||
// latter is also dropped from live delivery below, so the block stays invisible to the
|
||||
// blocked sender while the blocker sees nothing.
|
||||
autoRead, err := svc.robotRecipients(ctx, seats, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead))
|
||||
suppressed, err := svc.blockedRecipients(ctx, seats, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, mergeSeatSets(autoRead, suppressed)))
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
svc.metrics.recordChat(ctx, kindMessage)
|
||||
svc.emitChat(seats, senderID, msg)
|
||||
svc.emitChat(seats, senderID, msg, suppressed)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
@@ -138,6 +153,20 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
if idx == toMove {
|
||||
return Message{}, ErrNudgeOnOwnTurn
|
||||
}
|
||||
// The awaited player is the nudge's sole recipient.
|
||||
var target uuid.UUID
|
||||
if toMove >= 0 && toMove < len(seats) {
|
||||
target = seats[toMove]
|
||||
}
|
||||
// Blocker-side guard: a sender who has blocked the awaited player cannot nudge them
|
||||
// (the control is hidden client-side; this is the server-side counterpart).
|
||||
if target != uuid.Nil {
|
||||
if guard, err := svc.store.blockExists(ctx, senderID, target); err != nil {
|
||||
return Message{}, err
|
||||
} else if guard {
|
||||
return Message{}, ErrRecipientBlocked
|
||||
}
|
||||
}
|
||||
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
@@ -153,16 +182,30 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
return Message{}, ErrNudgeTooSoon
|
||||
}
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
|
||||
// Suppress when the awaited player has blocked the sender: the nudge still persists and
|
||||
// counts toward the once-per-hour cooldown (the sender notices nothing) but is born read
|
||||
// (mask 0) and never delivered, so the blocker sees no nudge.
|
||||
var suppressed bool
|
||||
if target != uuid.Nil {
|
||||
suppressed, err = svc.store.blockExists(ctx, target, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
}
|
||||
var mask int16
|
||||
if !suppressed && target != uuid.Nil {
|
||||
mask = int16(1) << uint(toMove)
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, mask)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
svc.metrics.recordChat(ctx, kindNudge)
|
||||
if toMove >= 0 && toMove < len(seats) {
|
||||
if !suppressed && target != uuid.Nil {
|
||||
// Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
|
||||
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
|
||||
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
|
||||
nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName)
|
||||
nudge := notify.Nudge(target, gameID, senderID, senderName)
|
||||
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
|
||||
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
|
||||
}
|
||||
@@ -187,12 +230,13 @@ func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// emitChat pushes a chat message to every seated player except the sender
|
||||
// (best-effort live delivery; the recipients still read it via Messages).
|
||||
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
|
||||
// emitChat pushes a chat message to every seated player except the sender and any
|
||||
// recipient in suppressed — a recipient who has blocked the sender, who must never see
|
||||
// it (best-effort live delivery; the other recipients still read it via Messages).
|
||||
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message, suppressed map[uuid.UUID]bool) {
|
||||
intents := make([]notify.Intent, 0, len(seats))
|
||||
for _, id := range seats {
|
||||
if id == senderID {
|
||||
if id == senderID || suppressed[id] {
|
||||
continue
|
||||
}
|
||||
intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
|
||||
@@ -208,8 +252,9 @@ func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID)
|
||||
}
|
||||
|
||||
// Messages returns the per-game chat visible to viewerID: the viewer must be a
|
||||
// seated player. Messages from a sender the viewer has a block with (either
|
||||
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
|
||||
// seated player. Messages from a sender the viewer has blocked are dropped — one
|
||||
// direction only, so a blocked player still sees the blocker's messages and never
|
||||
// notices the block — and if the viewer has disabled chat only nudges remain.
|
||||
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
|
||||
seats, _, _, err := svc.games.Participants(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -227,7 +272,7 @@ func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([
|
||||
if seat == viewerID {
|
||||
continue
|
||||
}
|
||||
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
|
||||
yes, err := svc.store.blockExists(ctx, viewerID, seat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -303,6 +348,72 @@ func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, send
|
||||
return robots, nil
|
||||
}
|
||||
|
||||
// blockedRecipients returns the seated recipients (every non-empty seat but the sender)
|
||||
// that have blocked the sender. Their copy of a message or nudge is born read and is not
|
||||
// delivered — the store-but-hide that keeps a block invisible to the blocked sender while
|
||||
// denying the blocker any sight of what they send.
|
||||
func (svc *Service) blockedRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
var out map[uuid.UUID]bool
|
||||
for _, id := range seats {
|
||||
if id == uuid.Nil || id == senderID {
|
||||
continue
|
||||
}
|
||||
yes, err := svc.store.blockExists(ctx, id, senderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if yes {
|
||||
if out == nil {
|
||||
out = make(map[uuid.UUID]bool)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// senderBlocksEveryOpponent reports whether senderID has blocked every other seated
|
||||
// player (and there is at least one). It is the blocker-side chat guard: a player whose
|
||||
// only opponents are people they have blocked cannot post. In a multi-player game that
|
||||
// still has a non-blocked opponent it is false, so the player can talk to the others.
|
||||
func (svc *Service) senderBlocksEveryOpponent(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (bool, error) {
|
||||
opponents := 0
|
||||
for _, id := range seats {
|
||||
if id == uuid.Nil || id == senderID {
|
||||
continue
|
||||
}
|
||||
opponents++
|
||||
blocked, err := svc.store.blockExists(ctx, senderID, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !blocked {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return opponents > 0, nil
|
||||
}
|
||||
|
||||
// mergeSeatSets returns the union of two seat sets; either may be nil. It folds the
|
||||
// born-read recipients (disguised robots and recipients who blocked the sender) into one
|
||||
// mask input.
|
||||
func mergeSeatSets(a, b map[uuid.UUID]bool) map[uuid.UUID]bool {
|
||||
if len(b) == 0 {
|
||||
return a
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return b
|
||||
}
|
||||
out := make(map[uuid.UUID]bool, len(a)+len(b))
|
||||
for id := range a {
|
||||
out[id] = true
|
||||
}
|
||||
for id := range b {
|
||||
out[id] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
|
||||
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
|
||||
id, err := uuid.NewV7()
|
||||
|
||||
@@ -51,7 +51,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if requesterID == addresseeID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
|
||||
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
theyBlockMe, err := svc.store.blockExists(ctx, addresseeID, requesterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +66,17 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
}
|
||||
return err
|
||||
}
|
||||
if blocked || addressee.BlockFriendRequests {
|
||||
if iBlockThem {
|
||||
// The requester has blocked the addressee — they are the blocker and aware of it.
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
// When the addressee has blocked the requester, the request still proceeds and persists
|
||||
// by the normal logic below, but it is never delivered and the blocker never sees it
|
||||
// (ListIncomingRequests filters it out) — the blocked requester must not learn of the
|
||||
// block. The flag gates only the notification; it also bypasses the addressee's
|
||||
// block_friend_requests toggle so the suppressed request looks ordinary to the requester.
|
||||
suppressed := theyBlockMe
|
||||
if !suppressed && addressee.BlockFriendRequests {
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
|
||||
@@ -102,7 +116,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
if !suppressed {
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -112,7 +128,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
}
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
if !suppressed {
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -164,15 +182,55 @@ func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID)
|
||||
return svc.store.deleteFriendship(ctx, accountID, otherID)
|
||||
}
|
||||
|
||||
// ListFriends returns the account IDs that are accepted friends of accountID.
|
||||
// ListFriends returns the account IDs that are accepted friends of accountID, with any
|
||||
// the caller has blocked filtered out: a block overrides — but does not delete — the
|
||||
// friendship, so the blocker stops seeing the blocked friend while the blocked user's own
|
||||
// list still shows the blocker (they never notice).
|
||||
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listFriends(ctx, accountID)
|
||||
ids, err := svc.store.listFriends(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.dropBlocked(ctx, accountID, ids)
|
||||
}
|
||||
|
||||
// ListIncomingRequests returns the account IDs that have a live (not yet expired)
|
||||
// pending friend request awaiting accountID's response.
|
||||
// pending friend request awaiting accountID's response, with any from a requester the
|
||||
// caller has blocked filtered out (their suppressed request stays stored but unseen).
|
||||
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
ids, err := svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.dropBlocked(ctx, accountID, ids)
|
||||
}
|
||||
|
||||
// dropBlocked removes from ids every account the viewer has blocked. It keeps the
|
||||
// asymmetric block one-directional: the blocker stops seeing those they blocked in their
|
||||
// friend / incoming-request lists, while the blocked user's lists are never filtered.
|
||||
func (svc *Service) dropBlocked(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
|
||||
if len(ids) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
blockedIDs, err := svc.store.listBlocks(ctx, viewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blockedIDs) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
blocked := make(map[uuid.UUID]struct{}, len(blockedIDs))
|
||||
for _, id := range blockedIDs {
|
||||
blocked[id] = struct{}{}
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, b := blocked[id]; b {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -80,6 +80,10 @@ var (
|
||||
ErrMessageNotFound = errors.New("social: message not found")
|
||||
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
||||
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
||||
// ErrRecipientBlocked is returned when a player tries to chat to, or nudge, someone
|
||||
// they have blocked — the blocker-side guard behind the hidden composer (a blocked
|
||||
// player's own sends are never refused, only silently suppressed, so they never notice).
|
||||
ErrRecipientBlocked = errors.New("social: cannot message a blocked player")
|
||||
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
|
||||
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
|
||||
// ErrEmptyMessage is returned when a chat message is blank after trimming.
|
||||
|
||||
+26
-8
@@ -470,7 +470,9 @@ disguised robot stays indistinguishable from a person.
|
||||
(or joins a different player's) rather than returning the caller's own, so choosing
|
||||
"random opponent" again always starts a new search (bounded by the simultaneous
|
||||
quick-game cap, §9). Matchmaking state is therefore the **open games in the database** (not
|
||||
an in-memory pool), so it survives a restart and stays anonymous (no block check);
|
||||
an in-memory pool), so it survives a restart and stays anonymous beyond one filter — a
|
||||
player is never paired into a game whose waiting opponent they have a per-user block with,
|
||||
in either direction (the enqueue excludes the caller's `BlockedWith` set);
|
||||
concurrent enqueues for one bucket are serialised by a transaction-scoped advisory
|
||||
lock so two callers pair rather than each opening a game. A background **reaper**
|
||||
seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus
|
||||
@@ -506,12 +508,24 @@ disguised robot stays indistinguishable from a person.
|
||||
expires after **30 days** and may be re-sent), or **decline** — a decline is
|
||||
remembered (`status='declined'`) and blocks further requests from that sender,
|
||||
unless they hand them a code, which overrides it. The requester's own cancel still
|
||||
deletes the row; blocking someone severs an existing friendship. (Discovery by
|
||||
friend list or platform deep-link is future work.)
|
||||
deletes the row. (Discovery by friend list or platform deep-link is future work.)
|
||||
- **Block**: two independent **global** account toggles (`block_chat`,
|
||||
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
|
||||
applied mutually: it hides the pair's chat from each other and refuses friend
|
||||
requests and game invitations between them.
|
||||
**asymmetric and non-destructive**: the blocker stops receiving everything **from** the
|
||||
blocked user — chat, nudges, friend requests, game invitations, and auto-match (§6) never
|
||||
pairs them — while the blocked user notices nothing. Their sends still persist by the normal
|
||||
rules but are never delivered or surfaced to the blocker (a directional `blockExists` check
|
||||
drives this: the blocker filters/refuses, the blocked is silently suppressed and born-read),
|
||||
and applying a block also marks read any unread the blocked user had left for the blocker. It
|
||||
**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). 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
|
||||
@@ -693,7 +707,10 @@ opponent card and the resign/chat controls update **in place**; **game-over** ca
|
||||
carry the changed account / invitation, so the client patches its lobby lists in place: **invitation**
|
||||
and **invitation-update** carry the full invitation, and the client upserts a still-pending one and
|
||||
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
|
||||
too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` /
|
||||
too, fresh from any screen without a refetch. The **user-blocked** / **user-unblocked** sub-kinds
|
||||
confirm a per-user block change to the **blocker only** (never the blocked user), carrying the other
|
||||
account, so the blocker's open game screens re-derive the block / add-friend controls and the struck
|
||||
name in place across sessions. The move-commit **response** (`submit_play` / `pass` /
|
||||
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
|
||||
renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby
|
||||
**preloads** the player's ongoing games — each one's `game.state`, `game.history` and saved
|
||||
@@ -720,8 +737,9 @@ localized message with a Mini App deep-link button — only when the recipient h
|
||||
identity and has not confined notifications to the app, so the two channels never duplicate. The
|
||||
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
|
||||
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
|
||||
the connector renders the message and skips the rest — so the in-app-only **invitation-update** (a
|
||||
response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts
|
||||
the connector renders the message and skips the rest — so in-app-only sub-kinds like
|
||||
**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a
|
||||
block-state sync to the blocker) never become a platform push. Operator broadcasts
|
||||
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
|
||||
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
|
||||
cursor-based stream resume stay deferred (single-instance MVP).
|
||||
|
||||
+25
-8
@@ -179,14 +179,31 @@ digits, valid for twelve hours), or send a **request to someone you have played
|
||||
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||||
re-sent), or decline (a decline blocks further requests from you until they hand you
|
||||
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
||||
friendship. In a game, each opponent's score card carries an **add-to-friends 🤝** control (while the
|
||||
move history is open) that mirrors the live relationship: it confirms with a tap on a fading
|
||||
✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is
|
||||
pending or was declined, and **disappears** once you are friends — updating in place the
|
||||
moment the opponent answers, and staying correct across reloads. Block globally — switch off incoming chat
|
||||
and/or friend requests — and block individual players (a per-user block hides that
|
||||
person's chat and stops requests and game invitations both ways; it also ends any
|
||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||
friendship. In a game, each opponent's score card (while the move history is open) carries two controls:
|
||||
an **add-to-friends 🤝** on the right and a **block ✖️** on the left. Each confirms with a tap
|
||||
on a fading ✅ — the card reads *Add friend?* (or *Block?*, in red) in place of the score while
|
||||
confirming — and while one is confirming the other is hidden so they never overlap. The 🤝
|
||||
goes **disabled** while a request is pending or was declined and **disappears** once you are
|
||||
friends; both controls **disappear and the opponent's name is struck through** once you have
|
||||
blocked them. They update in place the moment the relationship changes and stay correct across
|
||||
reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a
|
||||
live event; a transport failure rolls the control back to its prior state.
|
||||
|
||||
Block globally — switch off incoming chat and/or friend requests — or block an **individual
|
||||
player**. A per-user block is **one-directional and silent**: you stop receiving everything
|
||||
**from** that person — chat, nudges, friend requests and game invitations — and the matchmaker
|
||||
never pairs you with them, while they **notice nothing**. Their messages and nudges still send
|
||||
by the normal rules but never reach you (anything that would show as unread for you is marked
|
||||
read at once), and a friend request or invitation they send you is kept but never surfaces. A
|
||||
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.
|
||||
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
|
||||
|
||||
+26
-8
@@ -183,14 +183,32 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол
|
||||
**в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается
|
||||
тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится
|
||||
**неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия —
|
||||
обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
|
||||
чат и/или заявки —
|
||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника (при открытой
|
||||
истории ходов) несёт два контрола: **в друзья 🤝** справа и **блокировка ✖️** слева. Каждый
|
||||
подтверждается тапом по затухающей ✅ — на месте счёта карточка показывает *В друзья?* (или
|
||||
*Блокируем?* красным) во время подтверждения, — и пока подтверждается один, второй скрыт, чтобы
|
||||
они не пересекались. 🤝 становится **неактивным**, пока заявка висит или была отклонена, и
|
||||
**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым**
|
||||
после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после
|
||||
перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым
|
||||
событием; при сбое транспорта контрол возвращается в прежнее состояние.
|
||||
|
||||
Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного
|
||||
игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека
|
||||
всё — чат, nudge, заявки в друзья и приглашения в игру, — и матчмейкер никогда не сводит вас с
|
||||
ним, а он **ничего не замечает**. Его сообщения и nudge по-прежнему отправляются по обычным
|
||||
правилам, но не доходят до вас (всё, что было бы для вас непрочитанным, сразу помечается
|
||||
прочитанным), а присланные им заявка или приглашение сохраняются, но никогда не всплывают. Блок
|
||||
**перекрывает, но не удаляет** имеющуюся дружбу (поэтому можно заблокировать друга, а он
|
||||
продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у
|
||||
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
|
||||
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
|
||||
есть только там.
|
||||
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
|
||||
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
|
||||
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
|
||||
аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что
|
||||
заблокировать себя без соперников нельзя). Чат
|
||||
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
|
||||
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
||||
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
||||
|
||||
+10
-6
@@ -131,10 +131,14 @@ except Login uses `Screen`.
|
||||
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
|
||||
game with "multiple words per turn" off) centres a **"One word per turn"** label between
|
||||
those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that
|
||||
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains a
|
||||
🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that
|
||||
confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed
|
||||
(see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge.
|
||||
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two
|
||||
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
|
||||
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
|
||||
swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
|
||||
armed (see Controls); while one is armed the other hides so they never overlap. Once the
|
||||
opponent is **blocked** both controls go and the **name is struck through** (and the comms-hub
|
||||
chat composer is hidden — only the log remains). The name and score stay centred — the controls
|
||||
are pinned to the card's edges.
|
||||
Unread chat is also badged on the score bar itself, so it shows with the history closed.
|
||||
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
||||
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
||||
@@ -180,8 +184,8 @@ except Login uses `Screen`.
|
||||
tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade
|
||||
under reduce-motion, but the window still holds); a tap on the ✅ within it confirms,
|
||||
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
|
||||
the old press-and-hold popover) and the in-game **add-friend 🤝**. The
|
||||
**Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
||||
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
|
||||
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
||||
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
|
||||
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
||||
button) beside the rack commits the move — no popover, and disabled while the pending word
|
||||
|
||||
@@ -42,9 +42,20 @@ type RedeemResultResp struct {
|
||||
Friend AccountRefResp `json:"friend"`
|
||||
}
|
||||
|
||||
// BlockListResp is the accounts the caller has blocked.
|
||||
// BlockListResp is the accounts the caller has blocked, plus the per-game disguised-robot
|
||||
// blocks (not real accounts).
|
||||
type BlockListResp struct {
|
||||
Blocked []AccountRefResp `json:"blocked"`
|
||||
Robots []RobotBlockResp `json:"robots"`
|
||||
}
|
||||
|
||||
// RobotBlockResp is one per-game disguised-robot block: the row id (to unblock it), the game
|
||||
// name the player saw, and the game + seat it was blocked in.
|
||||
type RobotBlockResp struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
GameID string `json:"game_id"`
|
||||
Seat int `json:"seat"`
|
||||
}
|
||||
|
||||
// StatsResp is a durable account's lifetime statistics. BestMoves breaks the best move
|
||||
@@ -188,10 +199,11 @@ func (c *Client) RedeemFriendCode(ctx context.Context, userID, code string) (Red
|
||||
|
||||
// --- blocks ---
|
||||
|
||||
// Block blocks an account.
|
||||
func (c *Client) Block(ctx context.Context, userID, targetID string) error {
|
||||
// Block blocks an account. A non-empty gameID marks an in-game block, so a disguised-robot
|
||||
// opponent is recorded as a per-game block; it is empty for a settings-screen block.
|
||||
func (c *Client) Block(ctx context.Context, userID, targetID, gameID string) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/v1/user/blocks", userID, "",
|
||||
map[string]string{"account_id": targetID}, nil)
|
||||
map[string]string{"account_id": targetID, "game_id": gameID}, nil)
|
||||
}
|
||||
|
||||
// Unblock removes a block.
|
||||
|
||||
@@ -60,12 +60,35 @@ func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// buildRobotBlockVector builds the BlockList robots vector (per-game disguised-robot blocks).
|
||||
func buildRobotBlockVector(b *flatbuffers.Builder, robots []backendclient.RobotBlockResp) flatbuffers.UOffsetT {
|
||||
offs := make([]flatbuffers.UOffsetT, len(robots))
|
||||
for i, r := range robots {
|
||||
id := b.CreateString(r.ID)
|
||||
name := b.CreateString(r.DisplayName)
|
||||
gid := b.CreateString(r.GameID)
|
||||
fb.RobotBlockRefStart(b)
|
||||
fb.RobotBlockRefAddId(b, id)
|
||||
fb.RobotBlockRefAddDisplayName(b, name)
|
||||
fb.RobotBlockRefAddGameId(b, gid)
|
||||
fb.RobotBlockRefAddSeat(b, int32(r.Seat))
|
||||
offs[i] = fb.RobotBlockRefEnd(b)
|
||||
}
|
||||
fb.BlockListStartRobotsVector(b, len(offs))
|
||||
for i := len(offs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offs[i])
|
||||
}
|
||||
return b.EndVector(len(offs))
|
||||
}
|
||||
|
||||
// encodeBlockList builds a BlockList payload.
|
||||
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector)
|
||||
rv := buildRobotBlockVector(b, r.Robots)
|
||||
fb.BlockListStart(b)
|
||||
fb.BlockListAddBlocked(b, v)
|
||||
fb.BlockListAddRobots(b, rv)
|
||||
b.Finish(fb.BlockListEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
@@ -166,7 +166,9 @@ func blocksListHandler(backend *backendclient.Client) Handler {
|
||||
func blockAddHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsTargetRequest(req.Payload, 0)
|
||||
if err := backend.Block(ctx, req.UserID, string(in.AccountId())); err != nil {
|
||||
// game_id is set only by an in-game block, so a disguised-robot opponent is recorded
|
||||
// per-game; it is empty for a settings-screen block.
|
||||
if err := backend.Block(ctx, req.UserID, string(in.AccountId()), string(in.GameId())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeAck(true), nil
|
||||
|
||||
+18
-2
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -193,6 +193,29 @@ test('game: the add-friend 🤝 confirms with a tap and then reads as sent', asy
|
||||
await expect(add).toBeDisabled();
|
||||
});
|
||||
|
||||
test('game: the block ✖️ confirms in red, then hides both controls and strikes the name', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
|
||||
await page.locator('.scoreboard').click(); // open the history -> 🤝 and ✖️ appear on Ann's card
|
||||
const block = page.getByRole('button', { name: 'Block player' });
|
||||
await expect(block).toBeVisible();
|
||||
await block.click(); // arm: ✖️ -> a fading ✅, the score caption turns to a red "Block?"
|
||||
await expect(page.locator('.sc.blockprompt')).toHaveText('Block?');
|
||||
// While confirming a block the add-friend control is hidden so the two never overlap.
|
||||
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
|
||||
await block.click(); // confirm within the window
|
||||
// The block applied (optimistically): both controls disappear and the name is struck.
|
||||
await expect(page.getByRole('button', { name: 'Block player' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
|
||||
await expect(page.locator('.nm.struck')).toBeVisible();
|
||||
|
||||
// The chat composer is hidden for a blocked opponent — only the log remains, as in a
|
||||
// finished game.
|
||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
||||
await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
waiting = false,
|
||||
nudgeOnCooldown = false,
|
||||
vsAi = false,
|
||||
blocked = false,
|
||||
onsend,
|
||||
onnudge,
|
||||
}: {
|
||||
@@ -38,6 +39,10 @@
|
||||
// vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is
|
||||
// nobody to message or hurry. The fields still show (turn-driven), but disabled.
|
||||
vsAi?: boolean;
|
||||
// blocked hides the whole composer (message field, send and nudge), leaving only the chat
|
||||
// log — as in a finished game — when the viewer has blocked the opponent: there is no one
|
||||
// they will message (the backend rejects it too).
|
||||
blocked?: boolean;
|
||||
onsend: (text: string) => void;
|
||||
onnudge: () => void;
|
||||
} = $props();
|
||||
@@ -65,6 +70,7 @@
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if !blocked}
|
||||
<div class="input">
|
||||
{#if canSend}
|
||||
<input
|
||||
@@ -85,6 +91,7 @@
|
||||
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || vsAi || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
let messages = $state<ChatMessage[]>([]);
|
||||
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). blockedIds are blocked humans (by account); blockedRobotSeats are
|
||||
// the seats of blocked disguised robots in this game (a robot block is per game+seat).
|
||||
let blockedIds = $state(new Set<string>());
|
||||
let blockedRobotSeats = $state(new Set<number>());
|
||||
|
||||
const myId = $derived(app.session?.userId ?? '');
|
||||
const isMyTurn = $derived(
|
||||
@@ -31,6 +36,17 @@
|
||||
const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat);
|
||||
// While the auto-match game still has no opponent, chat and nudge are both disabled.
|
||||
const waiting = $derived(!!view && view.game.status === 'open');
|
||||
// peerBlocked is true when every seated opponent is one the viewer has blocked: the whole
|
||||
// composer is then hidden (a blocked opponent cannot be messaged).
|
||||
const peerBlocked = $derived.by(() => {
|
||||
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) || blockedRobotSeats.has(s.seat))
|
||||
);
|
||||
});
|
||||
const nudgeCooldownSecs = 3600;
|
||||
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
|
||||
// backend stays authoritative, so a move-based reset is left to it.
|
||||
@@ -64,9 +80,21 @@
|
||||
if (initial) handleError(e);
|
||||
}
|
||||
}
|
||||
// loadBlocked refreshes the viewer's blocked set (best-effort); guests have none.
|
||||
async function loadBlocked() {
|
||||
if (app.profile?.isGuest) return;
|
||||
try {
|
||||
const bl = await gateway.blocksList();
|
||||
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 */
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
await loadState(true);
|
||||
await refresh();
|
||||
void loadBlocked();
|
||||
});
|
||||
|
||||
// Live: refresh the message list on a chat / nudge for this game, and reload the state on
|
||||
@@ -84,6 +112,10 @@
|
||||
) {
|
||||
void loadState();
|
||||
}
|
||||
// A block/unblock applied: re-derive whether the composer should be hidden.
|
||||
if (e.kind === 'notify' && (e.sub === 'user_blocked' || e.sub === 'user_unblocked')) {
|
||||
void loadBlocked();
|
||||
}
|
||||
});
|
||||
// Re-evaluate the nudge cooldown on a timer so the control re-enables on time.
|
||||
$effect(() => {
|
||||
@@ -123,6 +155,7 @@
|
||||
{waiting}
|
||||
{nudgeOnCooldown}
|
||||
vsAi={!!view && view.game.vsAi}
|
||||
blocked={peerBlocked}
|
||||
onsend={sendChat}
|
||||
onnudge={nudge}
|
||||
/>
|
||||
|
||||
+111
-11
@@ -240,6 +240,7 @@
|
||||
}
|
||||
void load();
|
||||
void loadFriends();
|
||||
void loadBlocked();
|
||||
});
|
||||
|
||||
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
|
||||
@@ -294,6 +295,11 @@
|
||||
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
|
||||
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
||||
void loadFriends();
|
||||
} else if (e.kind === 'notify' && (e.sub === 'user_blocked' || e.sub === 'user_unblocked')) {
|
||||
// A block/unblock applied (this or another of the viewer's sessions): reconcile the
|
||||
// blocked set — and the friend set, since a block hides a blocked friend — in place.
|
||||
void loadBlocked();
|
||||
void loadFriends();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -869,9 +875,12 @@
|
||||
boardPointers.delete(e.pointerId);
|
||||
boardSwipe = null;
|
||||
}
|
||||
// A closed history clears every per-seat add-friend confirmation.
|
||||
// A closed history clears every per-seat add-friend and block confirmation.
|
||||
$effect(() => {
|
||||
if (!historyShown) addConfirm = {};
|
||||
if (!historyShown) {
|
||||
addConfirm = {};
|
||||
blockConfirm = {};
|
||||
}
|
||||
});
|
||||
|
||||
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
||||
@@ -881,9 +890,18 @@
|
||||
// re-send and disable the 🤝).
|
||||
let friends = $state(new Set<string>());
|
||||
let requested = $state(new Set<string>());
|
||||
// Per-seat "confirming" flag for the 🤝 → ✅ tap-to-confirm (TapConfirm writes it); while
|
||||
// set, that seat's card shows "Add friend?" in place of the score. Reset when history closes.
|
||||
// `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. `blockedRobotSeats` are the seat indices of blocked
|
||||
// disguised-robot opponents in THIS game (a robot block is recorded per game+seat, not by
|
||||
// account, so it never touches the shared robot account or other games).
|
||||
let blocked = $state(new Set<string>());
|
||||
let blockedRobotSeats = $state(new Set<number>());
|
||||
// Per-seat "confirming" flags for the 🤝 → ✅ and ✖️ → ✅ tap-to-confirm (TapConfirm writes them);
|
||||
// while set, that seat's card shows "Add friend?" / "Block?" in place of the score, and the
|
||||
// opposite control is hidden so the two never overlap. Reset when history closes.
|
||||
let addConfirm = $state<Record<number, boolean>>({});
|
||||
let blockConfirm = $state<Record<number, boolean>>({});
|
||||
|
||||
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
|
||||
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
|
||||
@@ -898,12 +916,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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
|
||||
// the same state in place (no flicker).
|
||||
async function addFriend(accountId: string) {
|
||||
const had = requested.has(accountId);
|
||||
requested = new Set([...requested, accountId]);
|
||||
try {
|
||||
await gateway.friendRequest(accountId);
|
||||
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
|
||||
showToast(t('friends.requestSent'));
|
||||
} catch (e) {
|
||||
if (!had) requested = new Set([...requested].filter((id) => id !== accountId));
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
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(s.accountId, id);
|
||||
} catch (e) {
|
||||
if (!hadSeat) blockedRobotSeats = new Set([...blockedRobotSeats].filter((x) => x !== s.seat));
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -932,10 +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 {
|
||||
// Never offer add-friend against an AI opponent.
|
||||
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 && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
|
||||
return (
|
||||
!!s.accountId &&
|
||||
!app.profile?.isGuest &&
|
||||
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(s: { accountId: string; seat: number }): boolean {
|
||||
if (view?.game.vsAi) return false;
|
||||
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -985,9 +1056,22 @@
|
||||
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||
<div class="nm">{seatName(s)}</div>
|
||||
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
|
||||
{#if historyShown && canAddFriend(s.accountId)}
|
||||
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
|
||||
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
||||
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
||||
</div>
|
||||
{#if historyShown && canBlock(s) && !addConfirm[s.seat]}
|
||||
<span class="blockuser">
|
||||
<TapConfirm
|
||||
label={t('friends.blockFromGame')}
|
||||
onConfirming={(v) => (blockConfirm[s.seat] = v)}
|
||||
onconfirm={() => blockUser(s)}
|
||||
>
|
||||
<span class="fico">✖️</span>
|
||||
</TapConfirm>
|
||||
</span>
|
||||
{/if}
|
||||
{#if historyShown && canAddFriend(s) && !blockConfirm[s.seat]}
|
||||
<span class="addfriend">
|
||||
<TapConfirm
|
||||
label={t('friends.addFromGame')}
|
||||
@@ -1426,9 +1510,25 @@
|
||||
transform: translateY(-50%);
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
/* The ✖️ block control mirrors add-friend on the seat's left edge. */
|
||||
.blockuser {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.fico {
|
||||
line-height: 1;
|
||||
}
|
||||
/* A blocked opponent's name is struck through. */
|
||||
.nm.struck {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
/* The "Block?" confirm caption replaces the score in the danger colour (theme-aware). */
|
||||
.sc.blockprompt {
|
||||
color: var(--danger);
|
||||
}
|
||||
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
|
||||
when the history is opened with unread present, rather than carrying a count badge. */
|
||||
.unread-dot {
|
||||
|
||||
@@ -59,6 +59,7 @@ export { PlayTile } from './scrabblefb/play-tile.js';
|
||||
export { Profile } from './scrabblefb/profile.js';
|
||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
||||
export { RedeemResult } from './scrabblefb/redeem-result.js';
|
||||
export { RobotBlockRef } from './scrabblefb/robot-block-ref.js';
|
||||
export { SeatView } from './scrabblefb/seat-view.js';
|
||||
export { Session } from './scrabblefb/session.js';
|
||||
export { StateRequest } from './scrabblefb/state-request.js';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { AccountRef } from '../scrabblefb/account-ref.js';
|
||||
import { RobotBlockRef } from '../scrabblefb/robot-block-ref.js';
|
||||
|
||||
|
||||
export class BlockList {
|
||||
@@ -33,8 +34,18 @@ blockedLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
robots(index: number, obj?:RobotBlockRef):RobotBlockRef|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? (obj || new RobotBlockRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
robotsLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startBlockList(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addBlocked(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset) {
|
||||
@@ -53,14 +64,31 @@ static startBlockedVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addRobots(builder:flatbuffers.Builder, robotsOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, robotsOffset, 0);
|
||||
}
|
||||
|
||||
static createRobotsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startRobotsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endBlockList(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createBlockList(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createBlockList(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset, robotsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
BlockList.startBlockList(builder);
|
||||
BlockList.addBlocked(builder, blockedOffset);
|
||||
BlockList.addRobots(builder, robotsOffset);
|
||||
return BlockList.endBlockList(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class RobotBlockRef {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):RobotBlockRef {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsRobotBlockRef(bb:flatbuffers.ByteBuffer, obj?:RobotBlockRef):RobotBlockRef {
|
||||
return (obj || new RobotBlockRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsRobotBlockRef(bb:flatbuffers.ByteBuffer, obj?:RobotBlockRef):RobotBlockRef {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new RobotBlockRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
id():string|null
|
||||
id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
id(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
displayName():string|null
|
||||
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
displayName(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
gameId():string|null
|
||||
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
seat():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startRobotBlockRef(builder:flatbuffers.Builder) {
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, idOffset, 0);
|
||||
}
|
||||
|
||||
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, displayNameOffset, 0);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, gameIdOffset, 0);
|
||||
}
|
||||
|
||||
static addSeat(builder:flatbuffers.Builder, seat:number) {
|
||||
builder.addFieldInt32(3, seat, 0);
|
||||
}
|
||||
|
||||
static endRobotBlockRef(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createRobotBlockRef(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, gameIdOffset:flatbuffers.Offset, seat:number):flatbuffers.Offset {
|
||||
RobotBlockRef.startRobotBlockRef(builder);
|
||||
RobotBlockRef.addId(builder, idOffset);
|
||||
RobotBlockRef.addDisplayName(builder, displayNameOffset);
|
||||
RobotBlockRef.addGameId(builder, gameIdOffset);
|
||||
RobotBlockRef.addSeat(builder, seat);
|
||||
return RobotBlockRef.endRobotBlockRef(builder);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type {
|
||||
AccountRef,
|
||||
BlockList,
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
@@ -127,8 +128,8 @@ export interface GatewayClient {
|
||||
friendCodeRedeem(code: string): Promise<AccountRef>;
|
||||
|
||||
// --- blocks ---
|
||||
blocksList(): Promise<AccountRef[]>;
|
||||
block(accountId: string): Promise<void>;
|
||||
blocksList(): Promise<BlockList>;
|
||||
block(accountId: string, gameId?: string): Promise<void>;
|
||||
unblock(accountId: string): Promise<void>;
|
||||
|
||||
// --- invitations ---
|
||||
|
||||
+22
-5
@@ -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 {
|
||||
|
||||
@@ -225,6 +225,7 @@ export const en = {
|
||||
'friends.block': 'Block',
|
||||
'friends.add': 'Add a friend',
|
||||
'friends.addFromGame': 'Add to friends',
|
||||
'friends.blockFromGame': 'Block player',
|
||||
'friends.requestSent': 'Friend request sent.',
|
||||
'friends.getCode': 'Show my code',
|
||||
'friends.codeHint': 'Give this code to a friend within 12 hours.',
|
||||
@@ -284,6 +285,7 @@ export const en = {
|
||||
'game.exportGcg': 'Export GCG',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
'game.addFriendShort': 'Add friend?',
|
||||
'game.blockShort': 'Block?',
|
||||
|
||||
'time.minutes': '{n} min',
|
||||
'time.hours': '{n} h',
|
||||
|
||||
@@ -226,6 +226,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'friends.block': 'Заблокировать',
|
||||
'friends.add': 'Добавить друга',
|
||||
'friends.addFromGame': 'В друзья',
|
||||
'friends.blockFromGame': 'Заблокировать',
|
||||
'friends.requestSent': 'Заявка в друзья отправлена.',
|
||||
'friends.getCode': 'Показать мой код',
|
||||
'friends.codeHint': 'Передайте этот код другу в течение 12 часов.',
|
||||
@@ -285,6 +286,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
'game.addFriendShort': 'В друзья?',
|
||||
'game.blockShort': 'Блокируем?',
|
||||
|
||||
'time.minutes': '{n} мин',
|
||||
'time.hours': '{n} ч',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { GatewayError } from '../client';
|
||||
import type {
|
||||
AccountRef,
|
||||
BlockList,
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
@@ -555,10 +556,13 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- blocks ---
|
||||
async blocksList(): Promise<AccountRef[]> {
|
||||
return this.blocks.map((b) => ({ ...b }));
|
||||
async blocksList(): Promise<BlockList> {
|
||||
// The mock models human blocks only (no disguised robots); robots stays empty.
|
||||
return { blocked: this.blocks.map((b) => ({ ...b })), robots: [] };
|
||||
}
|
||||
async block(accountId: string): Promise<void> {
|
||||
async block(accountId: string, _gameId?: string): Promise<void> {
|
||||
// A block hides the blocked person from the blocker's own friends list (the real
|
||||
// ListFriends filters them out), without deleting the underlying friendship.
|
||||
this.friends = this.friends.filter((f) => f.accountId !== accountId);
|
||||
if (!this.blocks.some((b) => b.accountId === accountId)) {
|
||||
this.blocks.push({ accountId, displayName: this.nameFor(accountId) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -8,21 +8,27 @@
|
||||
import { translate } from '../lib/i18n/catalog';
|
||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||
import { shareTelegramLink } from '../lib/telegram';
|
||||
import type { AccountRef, FriendCode } from '../lib/model';
|
||||
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
||||
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
let incoming = $state<AccountRef[]>([]);
|
||||
let blocked = $state<AccountRef[]>([]);
|
||||
// Per-game disguised-robot blocks, shown as distinct entries alongside blocked humans.
|
||||
let robotBlocks = $state<RobotBlockEntry[]>([]);
|
||||
let code = $state<FriendCode | null>(null);
|
||||
let redeemInput = $state('');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
[friends, incoming, blocked] = await Promise.all([
|
||||
const [fl, inc, bl] = await Promise.all([
|
||||
gateway.friendsList(),
|
||||
gateway.friendsIncoming(),
|
||||
gateway.blocksList(),
|
||||
]);
|
||||
friends = fl;
|
||||
incoming = inc;
|
||||
blocked = bl.blocked;
|
||||
robotBlocks = bl.robots;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
@@ -179,7 +185,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if blocked.length}
|
||||
{#if blocked.length || robotBlocks.length}
|
||||
<section>
|
||||
<h3>{t('friends.blockedList')}</h3>
|
||||
{#each blocked as b (b.accountId)}
|
||||
@@ -188,6 +194,12 @@
|
||||
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#each robotBlocks as r (r.id)}
|
||||
<div class="item">
|
||||
<span class="who">{r.displayName}</span>
|
||||
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user