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
+6 -6
View File
@@ -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 {
+13 -21
View File
@@ -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
+14 -6
View File
@@ -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)
}
}