Stage 17: fix the robot-nudge frequency + per-game push language
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s

Two owner-reported defects from a live contour game.

A. Frequency: the robot's proactive nudge fired hourly for 12h+ (a 12h idle threshold
   then the 1h cooldown, uncapped). Replaced with a lengthening, randomized schedule
   (proactiveNudgeGap): the first nudge ~60-90 min into the human's turn, each later gap
   growing toward 1-6h (uniform sample in [60min, ceil], ceil ramping 90min->6h over 12h
   of idle, measured from the previous nudge), so a long wait gets a handful of
   increasingly-spaced reminders instead of a stream.

B. Language: out-of-app push routed by the recipient's GLOBAL service_language
   (last-login-wins), so after re-logging via the RU bot an English game's nudges came
   from the RU bot. Now a game push (your_turn, game_over, nudge, match_found) carries
   the game's own language (engine.Variant.Language) on push.Event, and the gateway
   routes by it (falling back to service_language for non-game pushes). The New-Game
   variant-gating guarantees the game's bot is one the player has started, so delivery is
   never blocked.

Tests: proactiveNudgeGap unit + retimed TestRobotProactiveNudge; TestVariantLanguage;
emit your_turn/game_over language; TestNudgeRoutedByGameLanguage integration. Docs:
ARCHITECTURE (§7 nudge, §10/§13 routing), FUNCTIONAL (+ _ru), PLAN tracker.
This commit is contained in:
Ilia Denisov
2026-06-09 08:06:58 +02:00
parent 265e442252
commit bf7dca0a09
21 changed files with 257 additions and 45 deletions
+13 -4
View File
@@ -96,11 +96,20 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U
return s.act(ctx, rt, now)
}
// maybeNudge sends a proactive nudge once the human has been idle past the
// threshold. The social service enforces the once-per-hour-per-game limit and
// rejects a nudge on the robot's own turn, so any such rejection is benign.
// 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.
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
if now.Sub(rt.TurnStartedAt) < proactiveNudgeIdle {
ref := rt.TurnStartedAt
if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil {
return err
} else if ok && last.After(rt.TurnStartedAt) {
ref = last
}
if now.Sub(ref) < proactiveNudgeGap(ref.Sub(rt.TurnStartedAt), rt.Seed) {
return nil
}
if _, err := s.social.Nudge(ctx, rt.GameID, rt.RobotID); err != nil {
+27 -3
View File
@@ -55,9 +55,16 @@ const (
// sleep window relative to the opponent's timezone, in hours.
sleepDriftHours = 3
// proactiveNudgeIdle is how long the robot waits on the human's turn before it
// proactively nudges (subject to the social once-per-hour-per-game limit).
proactiveNudgeIdle = 12 * time.Hour
// 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
)
// defaultBand is the target resulting score margin after the robot's move: when
@@ -181,6 +188,23 @@ func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
}
// 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.
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))
}
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
// bounds so an out-of-range band can never produce an absurd think time.
func clampMinutes(mins float64) time.Duration {
+32
View File
@@ -238,6 +238,38 @@ func TestPlayToWinExport(t *testing.T) {
}
}
// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
// 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)
}
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 proactiveNudgeGap(idle, seed) != g {
t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
}
}
}
median := func(idle time.Duration) float64 {
const n = 4000
xs := make([]float64, n)
for s := 0; s < n; s++ {
xs[s] = proactiveNudgeGap(idle, int64(s+1)).Minutes()
}
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)
}
}
// plays builds candidate plays carrying only the given scores (ranked as passed).
func plays(scores ...int) []engine.MoveRecord {
out := make([]engine.MoveRecord, len(scores))