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
+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 {
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()