From 20f2a5a011e0372b8c89158cbfaf318a9d84e385 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 11:46:59 +0200 Subject: [PATCH] feat(chat): a message to a disguised robot opponent is born read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pooled robot substituted into an ordinary (non-AI) game never opens the chat, so a text message to it would linger unread forever — skewing the unread count and the publish-to-read metric. Clear its recipient bit at PostMessage time (robotRecipients via account.IsRobot), so the message is born read. The human sender never had their own message unread, so this is invisible to them; a nudge to a robot already self-clears when the robot answers by moving. --- backend/internal/inttest/chatread_test.go | 41 +++++++++++++++++++++++ backend/internal/social/chat.go | 41 ++++++++++++++++++++--- docs/ARCHITECTURE.md | 4 ++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/backend/internal/inttest/chatread_test.go b/backend/internal/inttest/chatread_test.go index 8d32d9c..d8fa7ad 100644 --- a/backend/internal/inttest/chatread_test.go +++ b/backend/internal/inttest/chatread_test.go @@ -5,9 +5,13 @@ package inttest import ( "context" "testing" + "time" "github.com/google/uuid" + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" "scrabble/backend/internal/social" ) @@ -116,6 +120,43 @@ func TestNudgeClearedByMove(t *testing.T) { } } +// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled +// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the +// chat, so the message must not linger unread (skewing the count and the read metric). +func TestChatToRobotIsBornRead(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + human := provisionAccount(t) + robot, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-chat-"+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) + } + // The human (seat 0, to move) chats the disguised robot; the message is born read. + msg, err := svc.PostMessage(ctx, g.ID, human, "good luck", "") + if err != nil { + t.Fatalf("post: %v", err) + } + if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: g.ID, UnreadOnly: true}, 50, 0); len(items) != 0 { + t.Errorf("a message to a robot should be born read, but the unread filter listed %d", len(items)) + } + card, err := svc.AdminMessageDetail(ctx, msg.ID) + if err != nil { + t.Fatalf("detail: %v", err) + } + for _, s := range card.Seats { + if s.AccountID == robot.ID && s.Role != "read" { + t.Errorf("robot seat role = %q, want read", s.Role) + } + } +} + // TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read // breakdown of the message card. func TestAdminChatUnreadFilterAndDetail(t *testing.T) { diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 75400ec..9d07b88 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -100,7 +100,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, if err := Clean(body); err != nil { return Message{}, err } - msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID)) + // A disguised robot opponent never reads chat, so its recipient bit is born clear. + 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)) if err != nil { return Message{}, err } @@ -259,12 +264,14 @@ func parseIP(raw string) *string { } // recipientMask is the initial unread bitmask for a text message: a set bit for every -// seated recipient (by seat index) other than the sender. Still-empty (open) seats are -// skipped. With at most four seats the result always fits the smallint column. -func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 { +// seated recipient (by seat index) other than the sender, except recipients in autoRead — +// a disguised robot opponent, whose message is born read since it never opens the chat. +// Still-empty (open) seats are skipped. With at most four seats the result always fits the +// smallint column. +func recipientMask(seats []uuid.UUID, senderID uuid.UUID, autoRead map[uuid.UUID]bool) int16 { var mask int16 for i, id := range seats { - if id == uuid.Nil || id == senderID { + if id == uuid.Nil || id == senderID || autoRead[id] { continue } mask |= int16(1) << uint(i) @@ -272,6 +279,30 @@ func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 { return mask } +// robotRecipients returns the seated recipients (every seat but the sender) that are robots. +// A disguised robot opponent never reads chat, so a message to it is born read (its unread +// bit is never set) — it would otherwise linger unread forever, skewing the unread count and +// the publish-to-read metric. +func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) { + var robots map[uuid.UUID]bool + for _, id := range seats { + if id == uuid.Nil || id == senderID { + continue + } + isRobot, err := svc.accounts.IsRobot(ctx, id) + if err != nil { + return nil, err + } + if isRobot { + if robots == nil { + robots = make(map[uuid.UUID]bool) + } + robots[id] = true + } + } + return robots, nil +} + // 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() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4f2ed7..fa0f803 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -524,7 +524,9 @@ disguised robot stays indistinguishable from a person. - **Read receipts**: each `chat_messages` row carries an `unread_seats` bitmask — a set bit per recipient seat that has **not** yet read it (the sender's own bit is never set). A text message seeds the bits of every seated recipient; a nudge seeds only the awaited - player's. A seat's bit clears when that player **opens the move history or the chat** + player's. A **disguised robot opponent's bit is never set** — it never opens the chat, so + a message to it is **born read** (it would otherwise linger unread forever); a nudge to a + robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat** (`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a history open is not a constant backend call), and a **nudge additionally clears when its recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is