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
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,12 +152,12 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U
|
||||
return s.act(ctx, rt, now)
|
||||
}
|
||||
|
||||
// maybeNudge sends a proactive nudge on a lengthening, randomized schedule (proactiveNudgeGap):
|
||||
// the first lands ~60-90 min into the human's turn, and each one waits longer than the last, so a
|
||||
// long idle turn gets a handful of increasingly-spaced reminders rather than an hourly stream. The
|
||||
// gap is measured from the previous nudge (or the turn start for the first). The social service
|
||||
// still enforces the once-per-game floor and rejects a nudge on the robot's own turn, so any such
|
||||
// rejection is benign.
|
||||
// maybeNudge sends a proactive nudge on a sparse, randomized schedule (proactiveNudgeGap): every
|
||||
// nudge waits a uniform random 9-12 h, so a long idle turn gets only a handful of widely-spaced
|
||||
// reminders rather than an hourly stream. The gap is measured from the previous nudge (or the turn
|
||||
// start for the first). The caller's asleep gate already skips this during the robot's sleep
|
||||
// window, so a due nudge fires at the first scan after wake. The social service still enforces the
|
||||
// once-per-game floor and rejects a nudge on the robot's own turn, so any such rejection is benign.
|
||||
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
|
||||
ref := rt.TurnStartedAt
|
||||
if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil {
|
||||
|
||||
@@ -79,16 +79,14 @@ const (
|
||||
// sleep window relative to the opponent's timezone, in hours.
|
||||
sleepDriftHours = 3
|
||||
|
||||
// The robot proactively nudges the idle human on a lengthening, randomized schedule rather
|
||||
// than an hourly stream: the first nudge lands ~60-90 min into the turn, and each subsequent
|
||||
// gap grows toward 1-6 h the longer the wait drags on, so a long idle turn gets only a handful
|
||||
// of increasingly-spaced reminders. The gap is a uniform sample in [nudgeGapFloorMinutes,
|
||||
// ceil] minutes, where ceil ramps from nudgeGapFirstCeilMinutes to nudgeGapCeilMinutes over
|
||||
// nudgeGapRamp of idle.
|
||||
nudgeGapFloorMinutes = 60.0
|
||||
nudgeGapFirstCeilMinutes = 90.0
|
||||
nudgeGapCeilMinutes = 360.0
|
||||
nudgeGapRamp = 12 * time.Hour
|
||||
// The robot proactively nudges the idle human on a sparse, randomized schedule rather than an
|
||||
// hourly stream: every nudge waits a uniform random 9-12 h after its reference point (the turn
|
||||
// start for the first nudge, the previous nudge thereafter), so even a long-neglected turn
|
||||
// collects only a few widely-spaced reminders. The 3 h window width is the random spread; the
|
||||
// gap does not lengthen with idle time. The driver still skips a nudge that would land in the
|
||||
// robot's sleep window, deferring it to the first scan after wake.
|
||||
nudgeGapLoHours = 9.0
|
||||
nudgeGapHiHours = 12.0
|
||||
)
|
||||
|
||||
// defaultBand is the target resulting score margin after the robot's move: when
|
||||
@@ -263,19 +261,13 @@ func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
|
||||
|
||||
// proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the
|
||||
// human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn).
|
||||
// It is a uniform sample in [nudgeGapFloorMinutes, ceil] minutes, where ceil ramps from
|
||||
// nudgeGapFirstCeilMinutes (a ~60-90 min first gap) up to nudgeGapCeilMinutes (a 1-6 h gap) as
|
||||
// refIdle reaches nudgeGapRamp — so the reminders space out the longer the turn is neglected. It
|
||||
// is deterministic per (seed, refIdle), so the driver computes the same due time on every scan.
|
||||
// It is a uniform sample in [nudgeGapLoHours, nudgeGapHiHours] hours, deterministic per
|
||||
// (seed, refIdle) so the driver computes the same due time on every scan. refIdle only salts the
|
||||
// draw, so each successive nudge of a still-idle turn waits a fresh 9-12 h rather than lengthening.
|
||||
func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration {
|
||||
f := float64(refIdle) / float64(nudgeGapRamp)
|
||||
if f > 1 {
|
||||
f = 1
|
||||
}
|
||||
ceil := nudgeGapFirstCeilMinutes + (nudgeGapCeilMinutes-nudgeGapFirstCeilMinutes)*f
|
||||
u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute))))
|
||||
mins := nudgeGapFloorMinutes + (ceil-nudgeGapFloorMinutes)*u
|
||||
return time.Duration(mins * float64(time.Minute))
|
||||
hours := nudgeGapLoHours + (nudgeGapHiHours-nudgeGapLoHours)*u
|
||||
return time.Duration(hours * float64(time.Hour))
|
||||
}
|
||||
|
||||
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
|
||||
|
||||
@@ -315,13 +315,13 @@ func TestDeviatesDistribution(t *testing.T) {
|
||||
// as the idle grows (the median at 12 h idle exceeds the median at the start).
|
||||
func TestProactiveNudgeGap(t *testing.T) {
|
||||
for seed := int64(1); seed <= 1000; seed++ {
|
||||
if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute {
|
||||
t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed)
|
||||
if first := proactiveNudgeGap(0, seed); first < 9*time.Hour || first > 12*time.Hour {
|
||||
t.Fatalf("first gap %s out of [9h,12h] for seed %d", first, seed)
|
||||
}
|
||||
for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} {
|
||||
g := proactiveNudgeGap(idle, seed)
|
||||
if g < 60*time.Minute || g > 6*time.Hour {
|
||||
t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle)
|
||||
if g < 9*time.Hour || g > 12*time.Hour {
|
||||
t.Fatalf("gap %s out of [9h,12h] for seed %d idle %s", g, seed, idle)
|
||||
}
|
||||
if proactiveNudgeGap(idle, seed) != g {
|
||||
t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
|
||||
@@ -337,8 +337,16 @@ func TestProactiveNudgeGap(t *testing.T) {
|
||||
sort.Float64s(xs)
|
||||
return xs[n/2]
|
||||
}
|
||||
if early, late := median(0), median(12*time.Hour); early >= late {
|
||||
t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late)
|
||||
// The window is flat: the gap distribution does not lengthen with idle time, so the median
|
||||
// stays near the band centre (10.5 h) and barely moves between a fresh turn and a long-idle one.
|
||||
early, late := median(0), median(12*time.Hour)
|
||||
for _, m := range []float64{early, late} {
|
||||
if m < 9*60 || m > 12*60 {
|
||||
t.Fatalf("median gap %.0f min out of [540,720]", m)
|
||||
}
|
||||
}
|
||||
if diff := math.Abs(early - late); diff > 30 {
|
||||
t.Errorf("median gap should not shift with idle (flat window): idle0=%.0f idle12h=%.0f", early, late)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ type gameDTO struct {
|
||||
// entry (message or nudge) in this game. It seeds the lobby and in-game unread badge.
|
||||
// gameDTOFromGame leaves it false (it has no viewer); the handlers fill it.
|
||||
UnreadChat bool `json:"unread_chat"`
|
||||
// UnreadMessages is a per-viewer flag: at least one of the unread entries is a real chat
|
||||
// message (not just a nudge). It colours the badge — set with UnreadChat it is the regular
|
||||
// badge, UnreadChat alone is the softer nudge-only badge. gameDTOFromGame leaves it false.
|
||||
UnreadMessages bool `json:"unread_messages"`
|
||||
}
|
||||
|
||||
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||
|
||||
@@ -443,16 +443,20 @@ func (s *Server) handleListGames(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
memo := map[string]string{}
|
||||
// One query seeds the lobby's per-card unread badge for every listed game.
|
||||
var unread map[uuid.UUID]bool
|
||||
// Two queries seed the lobby's per-card unread badge for every listed game: which games have
|
||||
// any unread entry, and which of those have a real message (so the badge can colour
|
||||
// message-bearing games apart from nudge-only ones).
|
||||
var unread, unreadMsg map[uuid.UUID]bool
|
||||
if s.social != nil {
|
||||
unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
|
||||
unreadMsg, _ = s.social.UnreadMessageGames(c.Request.Context(), uid)
|
||||
}
|
||||
out := make([]gameDTO, 0, len(games))
|
||||
for _, g := range games {
|
||||
dto := gameDTOFromGame(g)
|
||||
s.fillSeatNames(c.Request.Context(), &dto, memo)
|
||||
dto.UnreadChat = unread[g.ID]
|
||||
dto.UnreadMessages = unreadMsg[g.ID]
|
||||
out = append(out, dto)
|
||||
}
|
||||
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
|
||||
@@ -536,8 +540,9 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// setUnreadChat fills the per-game unread-chat flag for viewer uid on one game's DTO,
|
||||
// when the social domain is present. It is best-effort: a lookup error leaves the flag
|
||||
// setUnreadChat fills the per-game unread-chat flags for viewer uid on one game's DTO, when the
|
||||
// social domain is present: UnreadChat for any unread entry and UnreadMessages when one of them is
|
||||
// a real message (so the badge can be coloured). It is best-effort: a lookup error leaves the flag
|
||||
// false (a missing badge) rather than failing the surrounding game response.
|
||||
func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
|
||||
if s.social == nil {
|
||||
@@ -550,4 +555,7 @@ func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
|
||||
if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil {
|
||||
g.UnreadChat = unread
|
||||
}
|
||||
if msg, err := s.social.HasUnreadMessage(ctx, gid, uid); err == nil {
|
||||
g.UnreadMessages = msg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,21 @@ func (svc *Service) HasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (
|
||||
return svc.store.hasUnread(ctx, gameID, viewerID)
|
||||
}
|
||||
|
||||
// UnreadMessageGames returns the subset of UnreadGames whose unread entries include a real chat
|
||||
// message (a 'message' kind, not a nudge), for colouring the lobby's per-card unread badge: a game
|
||||
// present here has an unread message (the regular badge), one only in UnreadGames has nudges alone
|
||||
// (the soft nudge badge).
|
||||
func (svc *Service) UnreadMessageGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
return svc.store.unreadMessageGames(ctx, viewerID)
|
||||
}
|
||||
|
||||
// HasUnreadMessage reports whether viewerID has an unread chat message (a 'message' kind, not a
|
||||
// nudge) in the game, for distinguishing a message badge from a nudge-only one on a single game's
|
||||
// state and move-result views.
|
||||
func (svc *Service) HasUnreadMessage(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
|
||||
return svc.store.hasUnreadMessage(ctx, gameID, viewerID)
|
||||
}
|
||||
|
||||
// readMark is one chat entry that flipped from unread to read, carrying what the
|
||||
// publish-to-read latency metric needs.
|
||||
type readMark struct {
|
||||
@@ -161,6 +176,43 @@ func (s *Store) hasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// unreadMessageGames is unreadGames restricted to real chat messages (kind 'message', not a
|
||||
// nudge), so the caller can tell a message badge from a nudge-only one in one extra query.
|
||||
func (s *Store) unreadMessageGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
const q = `SELECT DISTINCT m.game_id
|
||||
FROM backend.chat_messages m
|
||||
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $1
|
||||
WHERE m.kind = 'message' AND m.unread_seats <> 0 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
|
||||
rows, err := s.db.QueryContext(ctx, q, viewerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("social: unread message games: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[uuid.UUID]bool)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("social: scan unread message games: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// hasUnreadMessage reports whether viewerID has an unread real chat message (kind 'message', not
|
||||
// a nudge) in the one game.
|
||||
func (s *Store) hasUnreadMessage(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
|
||||
const q = `SELECT EXISTS(
|
||||
SELECT 1 FROM backend.chat_messages m
|
||||
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $2
|
||||
WHERE m.game_id = $1 AND m.kind = 'message' AND (m.unread_seats::int & (1 << p.seat::int)) <> 0)`
|
||||
var ok bool
|
||||
if err := s.db.QueryRowContext(ctx, q, gameID, viewerID).Scan(&ok); err != nil {
|
||||
return false, fmt.Errorf("social: has unread message: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// countUnread counts the chat entries with at least one recipient seat still unread,
|
||||
// for the chat_unread_messages gauge.
|
||||
func (s *Store) countUnread(ctx context.Context) (int64, error) {
|
||||
|
||||
Reference in New Issue
Block a user