feat: sparser robot nudges, typed unread badge, lobby unread bump
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s

Three owner-requested polish changes:

- robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a
  flat uniform 9-12 h wait before every nudge; the existing sleep-window gate
  still skips and defers a nudge that would land in the robot's night.
- ui: colour the lobby/in-game unread dot by type -- the regular danger colour
  when a chat message is unread, a softer amber (--warn) when only nudges are.
  Adds a per-viewer unread_messages flag (chat_messages.kind='message') across
  the backend DTO, FlatBuffers wire, gateway transcode and the UI store.
- ui: float games with any unread notification to the top of the lobby's
  your-turn and opponent-turn sections (finished keeps its order), reusing the
  existing unread_chat flag.

Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire
field is backward-compatible.
This commit is contained in:
Ilia Denisov
2026-06-19 16:50:48 +02:00
parent c67a5d51f1
commit 6e77de4c1e
34 changed files with 331 additions and 86 deletions
+20 -2
View File
@@ -16,7 +16,8 @@ import (
)
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
// sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit.
// sender), surfaces through HasUnread / UnreadGames and the message-level HasUnreadMessage /
// UnreadMessageGames (it is a real message, not a nudge), and that MarkRead clears the reader's bit.
func TestChatUnreadAndMarkRead(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -37,6 +38,15 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
} else if !set[gameID] {
t.Error("UnreadGames should include the game for the recipient")
}
// A text message also raises the message-level flags (it is not just a nudge).
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[1]); !msg {
t.Error("recipient should have the message flagged as an unread message")
}
if set, err := svc.UnreadMessageGames(ctx, seats[1]); err != nil {
t.Fatalf("unread message games: %v", err)
} else if !set[gameID] {
t.Error("UnreadMessageGames should include the game for a text message")
}
// Reading it clears the recipient's bit and reports one entry marked.
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
@@ -55,7 +65,8 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
// targeting invariant, now observable through the unread bitmask.
// targeting invariant, now observable through the unread bitmask. It also checks a nudge raises
// HasUnread but not the message-level flags, so the badge can colour it apart from a real message.
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -68,6 +79,13 @@ func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Error("the awaited seat should have the nudge unread")
}
// A nudge is not a message: it must not raise the message-level flags.
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[0]); msg {
t.Error("a nudge must not be flagged as an unread message")
}
if set, _ := svc.UnreadMessageGames(ctx, seats[0]); set[gameID] {
t.Error("UnreadMessageGames must not include a game whose only unread entry is a nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
t.Error("a non-awaited waiting seat must not receive the nudge")
}
+11 -9
View File
@@ -181,8 +181,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
}
}
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
// TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's
// turn: nothing before the 9 h floor of the 9-12 h gap, exactly one once past its 12 h ceiling.
func TestRobotProactiveNudge(t *testing.T) {
ctx := context.Background()
svc := newGameService()
@@ -206,18 +206,20 @@ func TestRobotProactiveNudge(t *testing.T) {
t.Fatalf("create: %v", err)
}
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
// A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00,
// both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the
// sleep window, decides the outcome. No nudge at 8 h idle (before the 9 h floor); exactly one
// at 24 h idle (well past the 12 h ceiling, however the seed drew within 9-12 h).
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
setTurnStarted(t, g.ID, start)
robots.Drive(ctx, start.Add(30*time.Minute))
robots.Drive(ctx, start.Add(8*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 0 {
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
t.Errorf("robot nudges = %d at 8h idle, want 0 (before the 9h floor)", n)
}
robots.Drive(ctx, start.Add(2*time.Hour))
robots.Drive(ctx, start.Add(24*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 1 {
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
t.Errorf("robot nudges = %d at 24h idle, want 1 (past the 12h ceiling)", n)
}
}