feat(chat): a message to a disguised robot opponent is born read
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s

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.
This commit is contained in:
Ilia Denisov
2026-06-17 11:46:59 +02:00
parent a7f3df9346
commit 20f2a5a011
3 changed files with 80 additions and 6 deletions
+41
View File
@@ -5,9 +5,13 @@ package inttest
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social" "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 // TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
// breakdown of the message card. // breakdown of the message card.
func TestAdminChatUnreadFilterAndDetail(t *testing.T) { func TestAdminChatUnreadFilterAndDetail(t *testing.T) {
+36 -5
View File
@@ -100,7 +100,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if err := Clean(body); err != nil { if err := Clean(body); err != nil {
return Message{}, err 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 { if err != nil {
return Message{}, err 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 // 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 // seated recipient (by seat index) other than the sender, except recipients in autoRead —
// skipped. With at most four seats the result always fits the smallint column. // a disguised robot opponent, whose message is born read since it never opens the chat.
func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 { // 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 var mask int16
for i, id := range seats { for i, id := range seats {
if id == uuid.Nil || id == senderID { if id == uuid.Nil || id == senderID || autoRead[id] {
continue continue
} }
mask |= int16(1) << uint(i) mask |= int16(1) << uint(i)
@@ -272,6 +279,30 @@ func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 {
return mask 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. // 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) { func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
id, err := uuid.NewV7() id, err := uuid.NewV7()
+3 -1
View File
@@ -524,7 +524,9 @@ disguised robot stays indistinguishable from a person.
- **Read receipts**: each `chat_messages` row carries an `unread_seats` bitmask — a set - **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). 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 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 (`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 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 recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is