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