diff --git a/PLAN.md b/PLAN.md index f4b0b22..7ccf1c1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1461,6 +1461,20 @@ provided cert) at the contour caddy; prod VPN; rollback. game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge, friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live. + - **Nudge defects (owner-reported, shipped):** two from a live contour game. + **(A) Frequency** — the robot's proactive nudge fired hourly for 12 h+ (12 h idle threshold + + the 1 h cooldown, uncapped). Replaced with a **lengthening, randomized schedule** in the + robot strategy/driver: the first nudge ~60-90 min into the human's turn, each later gap + growing toward 1-6 h (the gap is a uniform sample in `[60 min, ceil]`, `ceil` ramping from + 90 min to 6 h over 12 h of idle, measured from the previous nudge), so a long wait gets a + handful of increasingly-spaced reminders. **(B) Language** — the out-of-app push routed by + the recipient's **global `service_language`** (last-login-wins), so after re-logging through + 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 deliverability. Covered by the + `proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`, + emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go index 31fa09c..dba62a6 100644 --- a/backend/internal/engine/engine.go +++ b/backend/internal/engine/engine.go @@ -47,6 +47,16 @@ func (v Variant) String() string { return "unknown" } +// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for +// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the +// matching per-language Telegram bot — by the game, not the recipient's last-login bot (Stage 17). +func (v Variant) Language() string { + if v == VariantEnglish { + return "en" + } + return "ru" +} + // ruleset returns the scrabble-solver ruleset backing the variant and true, or // (nil, false) for an unrecognised variant. func (v Variant) ruleset() (*rules.Ruleset, bool) { diff --git a/backend/internal/engine/variant_test.go b/backend/internal/engine/variant_test.go new file mode 100644 index 0000000..a175e00 --- /dev/null +++ b/backend/internal/engine/variant_test.go @@ -0,0 +1,19 @@ +package engine + +import "testing" + +// TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app +// push by the game itself (English -> en, the Russian variants -> ru), rather than the recipient's +// last-login bot (Stage 17). +func TestVariantLanguage(t *testing.T) { + cases := map[Variant]string{ + VariantEnglish: "en", + VariantRussianScrabble: "ru", + VariantErudit: "ru", + } + for v, want := range cases { + if got := v.Language(); got != want { + t.Errorf("%s.Language() = %q, want %q", v, got, want) + } + } +} diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go index 33bc73a..c9f7acf 100644 --- a/backend/internal/game/emit_test.go +++ b/backend/internal/game/emit_test.go @@ -68,6 +68,10 @@ func TestEmitMoveNotifiesActor(t *testing.T) { if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0 t.Errorf("your_turn score_line = %q, want 13:19", got) } + // Routed out-of-app by the game's language (the default Variant is English). + if yourTurn.Language != "en" { + t.Errorf("your_turn language = %q, want en", yourTurn.Language) + } } // TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat, @@ -102,4 +106,7 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) { if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" { t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine()) } + if over[winner].Language != "en" || over[loser].Language != "en" { + t.Errorf("game_over languages = %q/%q, want en/en", over[winner].Language, over[loser].Language) + } } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 1464933..bf1e068 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -222,6 +222,17 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V return svc.store.GetGameVariant(ctx, gameID) } +// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a +// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot +// (Stage 17). +func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) { + v, err := svc.GameVariant(ctx, gameID) + if err != nil { + return "", err + } + return v.Language(), nil +} + // RobotSchedule returns a game's bag seed and turn-start time, for the admin console's // robot-schedule panel (the deterministic play-to-win intent and next-move ETA). func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { @@ -367,6 +378,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco for _, s := range post.Seats { intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) } + // Game pushes are routed out-of-app by the game's own language, not the recipient's + // last-login bot (Stage 17). + lang := post.Variant.Language() switch post.Status { case StatusActive: if next, ok := seatAccount(post.Seats, post.ToMove); ok { @@ -377,14 +391,18 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco word = rec.Words[0] } opponent := svc.displayName(ctx, post.Seats, rec.Player) - intents = append(intents, notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove))) + yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove)) + yourTurn.Language = lang + intents = append(intents, yourTurn) } case StatusFinished: // The game just ended (any path: a closing play, all-pass, resign or timeout). Tell every // seat, each with their own perspective + recipient-first score, so an offline player gets // an out-of-app "game over" push (online players take it from the in-app refresh). for _, s := range post.Seats { - intents = append(intents, notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat))) + over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat)) + over.Language = lang + intents = append(intents, over) } } svc.pub.Publish(intents...) diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index f8ef7fa..c079b70 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -207,8 +207,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { } } -// TestRobotProactiveNudge checks the robot nudges the human after the idle -// threshold on the human's turn. +// 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. func TestRobotProactiveNudge(t *testing.T) { ctx := context.Background() svc := newGameService() @@ -232,14 +232,18 @@ func TestRobotProactiveNudge(t *testing.T) { t.Fatalf("create: %v", err) } - // Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for - // every drift. - start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + // 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) setTurnStarted(t, g.ID, start) - robots.Drive(ctx, start.Add(13*time.Hour)) + robots.Drive(ctx, start.Add(30*time.Minute)) + if n := countNudges(t, g.ID, robotID); n != 0 { + t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n) + } + robots.Drive(ctx, start.Add(2*time.Hour)) if n := countNudges(t, g.ID, robotID); n != 1 { - t.Errorf("robot nudges = %d, want 1 after 13h idle on the human's turn", n) + t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n) } } diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index b171ce6..666ed89 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -502,6 +502,32 @@ func TestRespondPublishesToRequester(t *testing.T) { } } +// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so +// it is delivered by the game's bot rather than the recipient's last-login bot (Stage 17). +func TestNudgeRoutedByGameLanguage(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + + gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { + t.Fatalf("nudge: %v", err) + } + found := false + for _, in := range pub.intents { + if in.Kind == notify.KindNudge { + found = true + if in.Language != "en" { + t.Errorf("nudge language = %q, want en (the game's language)", in.Language) + } + } + } + if !found { + t.Fatal("no nudge intent published") + } +} + // TestAdminListMessages checks the admin moderation list (Stage 17): real messages only // (nudges excluded), the game / sender pins, the sender glob masks, and the source label. func TestAdminListMessages(t *testing.T) { diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 2342291..56bcd11 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -76,9 +76,12 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) { // emitMatchFound pushes match_found to every seat of a freshly started game. // Emitting to a robot seat is harmless (no client subscription exists for it). func (m *Matchmaker) emitMatchFound(g game.Game) { + lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17) intents := make([]notify.Intent, 0, len(g.Seats)) for _, s := range g.Seats { - intents = append(intents, notify.MatchFound(s.AccountID, g.ID)) + mf := notify.MatchFound(s.AccountID, g.ID) + mf.Language = lang + intents = append(intents, mf) } m.pub.Publish(intents...) } diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 3e08b5a..e793bd2 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -52,6 +52,11 @@ type Intent struct { Kind string Payload []byte EventID string + // Language routes an out-of-app push to a specific per-language bot (Stage 17): for a + // game event it is the game's language ("en"/"ru"), so the notification comes from the + // game's bot rather than the recipient's last-login bot. Empty falls back to the + // recipient's service language at the gateway. + Language string } // Publisher accepts live-event intents. Implementations must be safe for diff --git a/backend/internal/pushgrpc/server.go b/backend/internal/pushgrpc/server.go index 8a3dcad..5bb4661 100644 --- a/backend/internal/pushgrpc/server.go +++ b/backend/internal/pushgrpc/server.go @@ -54,10 +54,11 @@ func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStre return nil } ev := &pushv1.Event{ - UserId: in.UserID.String(), - Kind: in.Kind, - Payload: in.Payload, - EventId: in.EventID, + UserId: in.UserID.String(), + Kind: in.Kind, + Payload: in.Payload, + EventId: in.EventID, + Language: in.Language, } if err := stream.Send(ev); err != nil { return err diff --git a/backend/internal/robot/driver.go b/backend/internal/robot/driver.go index 083da77..2a0e1ae 100644 --- a/backend/internal/robot/driver.go +++ b/backend/internal/robot/driver.go @@ -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 { diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index 4219863..5ac917a 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -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 { diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index 91092bb..f1d2cbe 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -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)) diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 0d9edc8..ecad30c 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -130,7 +130,11 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess } svc.metrics.recordChat(ctx, kindNudge) if toMove >= 0 && toMove < len(seats) { - svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID)) + nudge := notify.Nudge(seats[toMove], gameID, senderID) + if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil { + nudge.Language = lang // route by the game's bot, not the recipient's last-login one (Stage 17) + } + svc.pub.Publish(nudge) } return msg, nil } diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 1f0aaa2..2c3564d 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -31,6 +31,9 @@ type GameReader interface { // LastMoveAt is the time of an account's most recent move in a game (and whether it // has moved); the nudge cooldown resets once the player has taken a turn. LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) + // GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes + // to the game's bot rather than the recipient's last-login bot (Stage 17). + GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) } // Sentinel errors returned by the service. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d1a004a..9328acd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -141,8 +141,10 @@ arrive from a platform rather than completing a mandatory registration). gate (it is a product affordance, not a trust boundary). The service language is **persisted** per account (`accounts.service_language`, updated on every Telegram login — last-login-wins) and routes the user's out-of-app push back through the right - bot (§10); it is distinct from `preferred_language` (the interface language) and from - a game's variant language. Non-Telegram logins (web / email / guest) carry the + bot (§10) — **except a game event, which routes by the game's own language** (its variant → + en/ru, Stage 17), so a game's notification always comes from the game's bot rather than the + recipient's latest login bot. The service language is distinct from `preferred_language` (the + interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default). - The client holds `session_id` in memory for the app session (browser/OS storage is optional and may be unavailable; losing it means re-login). @@ -339,8 +341,9 @@ English game the Latin pool. **sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's rather than running anti-phase; on a daytime nudge it replies near the move's lower - band; it proactively nudges the human after **12 hours** idle (subject to the - once-per-hour chat limit). + band; it proactively nudges the idle human on a **lengthening, randomized schedule** — the + first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long + wait gets a handful of increasingly-spaced nudges rather than an hourly stream (Stage 17). - **Observability**: robot accounts accrue ordinary statistics (§9) — the authoritative balance metric (target ≈ 40% robot wins) — and a `robot_games_finished_total` OTel counter plus a per-finish log give a live view. @@ -507,11 +510,13 @@ missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fal the **gateway** routes from the same firehose: for an event whose recipient has **no live in-app stream** it resolves the backend `/internal/push-target` (their Telegram `external_id`, the **service language** — the bot they last signed in through, falling -back to the interface language — and the `notifications_in_app_only` flag) and asks the -**Telegram connector** to deliver a localized message with a Mini App deep-link -button — only when the recipient has a Telegram identity and has not confined -notifications to the app, so the two channels never duplicate. The connector routes by -that language to the matching bot and renders the message in it. The out-of-app set is +back to the interface language — and the `notifications_in_app_only` flag). A **game** event, +however, carries the **game's own language** on the push (Stage 17), and the gateway routes by +that instead of the service language — so a game's notification always comes from the game's bot, +not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a +localized message with a Mini App deep-link button — only when the recipient has a Telegram +identity and has not confined notifications to the app, so the two channels never duplicate. The +connector routes by that language to the matching bot and renders the message in it. The out-of-app set is your-turn, game-over, nudge, match-found and the invitation / friend-request notify sub-kinds; the connector renders the message and skips the rest. Operator broadcasts (`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 61ce34c..c425027 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -35,8 +35,10 @@ the Telegram colours, and — on first contact — seeds the new account's inter language from the Telegram client. The sign-in service also declares the **game languages** it offers (a set of en/ru, at least one), which gate the New Game variant choice in the lobby. Telegram runs a separate bot per language (an English bot and a -Russian bot, the same player spanning both); the bot a player signed in through both -sets their offered languages and is the bot their out-of-app notifications come from. Guests are session-only with restricted features +Russian bot, the same player spanning both); the bot a player signed in through sets their +offered languages, and their non-game notifications come from it. A **game's** notifications +(your turn, game over, a nudge), though, always come from **that game's** bot — by the game's +language, not whichever bot the player signed in through last. Guests are session-only with restricted features (auto-match only; no friends, stats or history); an abandoned guest that never joined a game and has been idle past the retention window is garbage-collected. While the app is open the client keeps a live stream and receives in-app updates in real time — the opponent's move, diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index aac767b..f82266b 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -36,8 +36,10 @@ Mini App** авторизует по подписанным `initData` плат языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок -охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки и -является тем ботом, от которого приходят его внеприложенческие уведомления. Гость — только сессия, с урезанными функциями (только +охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки, и от него +приходят его **внеигровые** уведомления. А уведомления по **партии** (ваш ход, конец партии, +nudge) приходят от бота **этой партии** — по языку партии, а не по тому боту, через которого +игрок входил последним. Гость — только сессия, с урезанными функциями (только авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 50d2dc4..458b287 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -214,7 +214,7 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H // deliver the event over the platform push channel. Done in a goroutine // so a slow connector never stalls the in-app firehose. if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) { - go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger) + go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), ev.GetLanguage(), logger) } } if !sleep(ctx, pushReconnectDelay) { @@ -227,7 +227,7 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H // Telegram identity and have not confined notifications to the app, asks the // connector to deliver the event. It is best-effort: every failure is logged and // dropped (the in-app stream remains the primary channel). -func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, logger *zap.Logger) { +func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, gameLang string, logger *zap.Logger) { target, err := backend.PushTarget(ctx, userID) if err != nil { logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err)) @@ -236,7 +236,13 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) { return } - if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil { + // A game event carries its own language, so the push comes from the game's bot rather than + // the recipient's last-login bot (Stage 17); other events fall back to the service language. + lang := target.Language + if gameLang != "" { + lang = gameLang + } + if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, lang); err != nil { logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err)) } } diff --git a/pkg/proto/push/v1/push.pb.go b/pkg/proto/push/v1/push.pb.go index 45d49a3..50f9581 100644 --- a/pkg/proto/push/v1/push.pb.go +++ b/pkg/proto/push/v1/push.pb.go @@ -79,11 +79,16 @@ func (x *SubscribeRequest) GetGatewayId() string { // FlatBuffers-encoded body for that kind. event_id is a correlation id the // gateway carries through to the client envelope. type Event struct { - state protoimpl.MessageState `protogen:"open.v1"` - UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` - Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` - EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + // language routes an out-of-app push to a specific per-language bot (Stage 17): for a game + // event it carries the game's language ("en"/"ru") so the notification comes from the game's + // bot rather than the recipient's last-login bot. Empty falls back to the recipient's service + // language at the gateway. + Language string `protobuf:"bytes,5,opt,name=language,proto3" json:"language,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -146,6 +151,13 @@ func (x *Event) GetEventId() string { return "" } +func (x *Event) GetLanguage() string { + if x != nil { + return x.Language + } + return "" +} + var File_push_v1_push_proto protoreflect.FileDescriptor const file_push_v1_push_proto_rawDesc = "" + @@ -153,12 +165,13 @@ const file_push_v1_push_proto_rawDesc = "" + "\x12push/v1/push.proto\x12\x10scrabble.push.v1\"1\n" + "\x10SubscribeRequest\x12\x1d\n" + "\n" + - "gateway_id\x18\x01 \x01(\tR\tgatewayId\"i\n" + + "gateway_id\x18\x01 \x01(\tR\tgatewayId\"\x85\x01\n" + "\x05Event\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" + "\apayload\x18\x03 \x01(\fR\apayload\x12\x19\n" + - "\bevent_id\x18\x04 \x01(\tR\aeventId2R\n" + + "\bevent_id\x18\x04 \x01(\tR\aeventId\x12\x1a\n" + + "\blanguage\x18\x05 \x01(\tR\blanguage2R\n" + "\x04Push\x12J\n" + "\tSubscribe\x12\".scrabble.push.v1.SubscribeRequest\x1a\x17.scrabble.push.v1.Event0\x01B#Z!scrabble/pkg/proto/push/v1;pushv1b\x06proto3" diff --git a/pkg/proto/push/v1/push.proto b/pkg/proto/push/v1/push.proto index 40fbbb2..4cd67c7 100644 --- a/pkg/proto/push/v1/push.proto +++ b/pkg/proto/push/v1/push.proto @@ -33,4 +33,9 @@ message Event { string kind = 2; bytes payload = 3; string event_id = 4; + // language routes an out-of-app push to a specific per-language bot (Stage 17): for a game + // event it carries the game's language ("en"/"ru") so the notification comes from the game's + // bot rather than the recipient's last-login bot. Empty falls back to the recipient's service + // language at the gateway. + string language = 5; }