diff --git a/PLAN.md b/PLAN.md index 5b13332..d122e05 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1425,8 +1425,18 @@ provided cert) at the contour caddy; prod VPN; rollback. `GameActionRequest` → an `Ack`); the lobby drops the card optimistically and keeps the cache in sync. Covered by an integration test (active→`ErrGameActive`, outsider→`ErrNotAPlayer`, per-account visibility, idempotent), a gateway transcode test, and a mock e2e (kebab → ❌). - - **Deferred to the next PR (agreed):** #4 enrich the out-of-app "your turn" / game-end push - with the opponent's name, last word and score. + - **Enriched out-of-app push (#4, shipped):** the "your turn" Telegram notification now names + the opponent and recaps their last move — voiced as the opponent, `«{name}: my move — + «WORD». Score 120:95»` for a scoring play, or a short "swapped / passed, your turn" — and a + new **game-over** notification reports the result + final score when a game ends (any path: + closing play, all-pass, resign, timeout). Scores are **recipient-first** (the reader's + score leads), 2- to 4-player (`120:95:80`). `YourTurnEvent` gained `opponent_name`/ + `last_action`/`last_word`/`score_line` (appended, backward-compatible) and a new + `GameOverEvent` carries `result`/`score_line`; both emit per-recipient from the game commit + (`emitMove`), join the out-of-app whitelist, and render per language (en/ru) in the Telegram + connector. The backend resolves the mover's display name (the score line and result are + built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and + routing tests. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go index 03f6509..33bc73a 100644 --- a/backend/internal/game/emit_test.go +++ b/backend/internal/game/emit_test.go @@ -1,6 +1,7 @@ package game import ( + "context" "slices" "testing" "time" @@ -9,6 +10,7 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" + fb "scrabble/pkg/fbs/scrabblefb" ) // recordingPublisher captures every published intent for assertions. @@ -29,13 +31,17 @@ func TestEmitMoveNotifiesActor(t *testing.T) { ToMove: 1, TurnStartedAt: time.Now(), TurnTimeout: time.Hour, - Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}}, + Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}}, } - svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10}) + svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}) kinds := map[uuid.UUID][]string{} + var yourTurn notify.Intent for _, in := range pub.intents { kinds[in.UserID] = append(kinds[in.UserID], in.Kind) + if in.UserID == opp && in.Kind == notify.KindYourTurn { + yourTurn = in + } } if !slices.Contains(kinds[actor], notify.KindOpponentMoved) { t.Errorf("actor should get opponent_moved, got %v", kinds[actor]) @@ -49,4 +55,51 @@ func TestEmitMoveNotifiesActor(t *testing.T) { if slices.Contains(kinds[actor], notify.KindYourTurn) { t.Errorf("actor is not next to move, should not get your_turn") } + // The your_turn push is enriched: the last move's action and word, and a recipient-first + // score line (the next mover, seat 1, first). The opponent name needs the account store and + // is left empty by this store-less unit (covered at the render layer). + yt := fb.GetRootAsYourTurnEvent(yourTurn.Payload, 0) + if got := string(yt.LastAction()); got != "play" { + t.Errorf("your_turn last_action = %q, want play", got) + } + if got := string(yt.LastWord()); got != "HELLO" { + t.Errorf("your_turn last_word = %q, want HELLO", got) + } + 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) + } +} + +// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat, +// each with its own outcome and a recipient-first final score. +func TestEmitMoveAnnouncesGameOver(t *testing.T) { + winner, loser := uuid.New(), uuid.New() + pub := &recordingPublisher{} + svc := &Service{pub: pub} + g := Game{ + ID: uuid.New(), + Status: StatusFinished, + Players: 2, + EndReason: "out_of_tiles", + Seats: []Seat{{Seat: 0, AccountID: winner, Score: 120, IsWinner: true}, {Seat: 1, AccountID: loser, Score: 95}}, + } + svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}) + + over := map[uuid.UUID]notify.Intent{} + for _, in := range pub.intents { + if in.Kind == notify.KindGameOver { + over[in.UserID] = in + } + } + if len(over) != 2 { + t.Fatalf("game_over should reach both seats, got %d", len(over)) + } + w := fb.GetRootAsGameOverEvent(over[winner].Payload, 0) + if string(w.Result()) != "won" || string(w.ScoreLine()) != "120:95" { + t.Errorf("winner game_over = %q / %q, want won / 120:95", w.Result(), w.ScoreLine()) + } + l := fb.GetRootAsGameOverEvent(over[loser].Payload, 0) + 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()) + } } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 20ef259..1464933 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "slices" + "strconv" "strings" "time" @@ -350,7 +351,7 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game if err != nil { return Game{}, err } - svc.emitMove(post, rec) + svc.emitMove(ctx, post, rec) return post, nil } @@ -361,20 +362,92 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game // out-of-app push), so the actor is not notified out of band about their own move. // Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each // event out to all of the recipient's live streams. -func (svc *Service) emitMove(post Game, rec engine.MoveRecord) { - intents := make([]notify.Intent, 0, len(post.Seats)+1) +func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord) { + intents := make([]notify.Intent, 0, 2*len(post.Seats)) for _, s := range post.Seats { intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) } - if post.Status == StatusActive { + switch post.Status { + case StatusActive: if next, ok := seatAccount(post.Seats, post.ToMove); ok { deadline := post.TurnStartedAt.Add(post.TurnTimeout) - intents = append(intents, notify.YourTurn(next, post.ID, deadline)) + action := rec.Action.String() + word := "" + if action == "play" && len(rec.Words) > 0 { + 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))) + } + 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))) } } svc.pub.Publish(intents...) } +// displayName resolves the display name of the account at the given seat, or "" when the seat +// is absent or the lookup fails (the enriched push then falls back to its plain text). +func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string { + if svc.accounts == nil { + return "" + } + id, ok := seatAccount(seats, seat) + if !ok { + return "" + } + acc, err := svc.accounts.GetByID(ctx, id) + if err != nil { + return "" + } + return acc.DisplayName +} + +// scoreLine formats the running scores with recipientSeat's score first, then the remaining +// seats in seat order, colon-joined (e.g. "120:95:80") — the recipient-first form used in the +// out-of-app notifications. +func scoreLine(g Game, recipientSeat int) string { + n := len(g.Seats) + bySeat := make([]int, n) + for _, s := range g.Seats { + if s.Seat >= 0 && s.Seat < n { + bySeat[s.Seat] = s.Score + } + } + parts := make([]string, 0, n) + if recipientSeat >= 0 && recipientSeat < n { + parts = append(parts, strconv.Itoa(bySeat[recipientSeat])) + } + for seat := 0; seat < n; seat++ { + if seat != recipientSeat { + parts = append(parts, strconv.Itoa(bySeat[seat])) + } + } + return strings.Join(parts, ":") +} + +// seatResult reports the finished-game outcome from recipientSeat's perspective: "draw" when no +// seat is flagged the winner, "won" when recipientSeat is, otherwise "lost". +func seatResult(seats []Seat, recipientSeat int) string { + winner := false + for _, s := range seats { + if s.IsWinner { + winner = true + if s.Seat == recipientSeat { + return "won" + } + } + } + if !winner { + return "draw" + } + return "lost" +} + // seatAccount returns the account seated at the given seat index, or false when // no seat matches (the slice is not assumed to be ordered by seat). func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) { diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 0d443c6..7813cce 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -13,18 +13,45 @@ import ( // the payload with the shared scrabblefb schema. Keeping the encoding here lets // the game/social/lobby services emit events without importing the wire schema. -// YourTurn announces to userID that it is their turn in game gameID, with the -// turn's nominal deadline. -func YourTurn(userID, gameID uuid.UUID, deadline time.Time) Intent { - b := flatbuffers.NewBuilder(64) +// YourTurn announces to userID that it is their turn in game gameID, with the turn's nominal +// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push (Stage +// 17): the player who just moved, their move kind, the main word of a scoring play (empty +// otherwise) and the recipient-first running score line. Empty strings render the plain "your +// turn" text. +func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string) Intent { + b := flatbuffers.NewBuilder(128) gid := b.CreateString(gameID.String()) + name := b.CreateString(opponentName) + action := b.CreateString(lastAction) + word := b.CreateString(lastWord) + score := b.CreateString(scoreLine) fb.YourTurnEventStart(b) fb.YourTurnEventAddGameId(b, gid) fb.YourTurnEventAddDeadlineUnix(b, deadline.Unix()) + fb.YourTurnEventAddOpponentName(b, name) + fb.YourTurnEventAddLastAction(b, action) + fb.YourTurnEventAddLastWord(b, word) + fb.YourTurnEventAddScoreLine(b, score) b.Finish(fb.YourTurnEventEnd(b)) return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()} } +// GameOver announces to userID that game gameID finished. result is the outcome from userID's +// own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both +// feed the out-of-app "game over" push (Stage 17). +func GameOver(userID, gameID uuid.UUID, result, scoreLine string) Intent { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID.String()) + res := b.CreateString(result) + score := b.CreateString(scoreLine) + fb.GameOverEventStart(b) + fb.GameOverEventAddGameId(b, gid) + fb.GameOverEventAddResult(b, res) + fb.GameOverEventAddScoreLine(b, score) + b.Finish(fb.GameOverEventEnd(b)) + return Intent{UserID: userID, Kind: KindGameOver, Payload: b.FinishedBytes(), EventID: eventID()} +} + // OpponentMoved tells userID that seat just committed a move in game gameID, // summarising it (the client refetches the full state). func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 89decb9..3e08b5a 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -27,6 +27,9 @@ const ( // KindNotification is a lightweight "re-poll your lobby counters" signal // (incoming friend requests, invitations) that drives the lobby badge. KindNotification = "notify" + // KindGameOver announces a finished game to each seated player, driving the + // out-of-app "game over" push (Stage 17). + KindGameOver = "game_over" ) // Notification sub-kinds carried in a KindNotification event payload; the client diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index e94ce44..70a7427 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -61,7 +61,7 @@ func TestNopPublisherDiscards(t *testing.T) { func TestYourTurnPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() - in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0)) + in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95") if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" { t.Fatalf("intent metadata wrong: %+v", in) } @@ -72,6 +72,23 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) { if got := ev.DeadlineUnix(); got != 1717000000 { t.Fatalf("deadline = %d, want 1717000000", got) } + if string(ev.OpponentName()) != "Ann" || string(ev.LastAction()) != "play" || + string(ev.LastWord()) != "STOOL" || string(ev.ScoreLine()) != "120:95" { + t.Fatalf("enriched fields wrong: name=%q action=%q word=%q score=%q", + ev.OpponentName(), ev.LastAction(), ev.LastWord(), ev.ScoreLine()) + } +} + +func TestGameOverPayloadRoundTrips(t *testing.T) { + uid, gid := uuid.New(), uuid.New() + in := notify.GameOver(uid, gid, "won", "120:95:80") + if in.UserID != uid || in.Kind != notify.KindGameOver || in.EventID == "" { + t.Fatalf("intent metadata wrong: %+v", in) + } + ev := fb.GetRootAsGameOverEvent(in.Payload, 0) + if string(ev.GameId()) != gid.String() || string(ev.Result()) != "won" || string(ev.ScoreLine()) != "120:95:80" { + t.Fatalf("game_over fields wrong: game=%q result=%q score=%q", ev.GameId(), ev.Result(), ev.ScoreLine()) + } } func TestOpponentMovedPayloadRoundTrips(t *testing.T) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2858f73..7780502 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -485,7 +485,12 @@ in-app only, so the actor gets no out-of-app push for their own move), **chat-me friend-added, friend-declined, invitation or game-started; emitted on a friend-request, on answering one (accept → friend-added, decline → friend-declined — to the original requester, so a game screen watching that opponent re-derives its "add to friends" state, -Stage 17), and on an invitation create or its game start). Event payloads are FlatBuffers-encoded by +Stage 17), and on an invitation create or its game start). Stage 17 added **game-over** (emitted to every +seat from the same game commit when a game finishes — any path: a closing play, all-pass, +resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now +also carries the mover's display name, their last action and the main word of a scoring play, +and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first). +Event payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for match-found and, for the lobby **notification badge** (incoming friend requests + open invitations), the client polls on lobby @@ -499,7 +504,7 @@ back to the interface language — and the `notifications_in_app_only` flag) and 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, nudge, match-found and the invitation / friend-request notify sub-kinds; +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 **operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 48df323..4d00a05 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -41,9 +41,12 @@ sets their offered languages and is the bot their out-of-app notifications come 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, your turn, chat, nudges and a found match. When the app is **closed**, the chosen -out-of-app events (your turn, nudge, a found match, an invitation or friend request) -arrive as a **Telegram notification** instead — unless the player keeps notifications -in the app only (a profile setting, **on by default**). +out-of-app events (your turn, game over, nudge, a found match, an invitation or friend +request) arrive as a **Telegram notification** instead — unless the player keeps +notifications in the app only (a profile setting, **on by default**). The "your turn" +notification names the opponent and recaps their last move — the word and the running score +for a scoring play, or that they swapped or passed — and a finished game sends a "game over" +notification with your result and the final score (scores read with yours first). ### Accounts, linking & merge *(Stage 1 / 11)* First platform contact auto-provisions a durable account. From the profile a player diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 3375dd6..2472c6a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -42,9 +42,13 @@ Mini App** авторизует по подписанным `initData` плат в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие -события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят -вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления -только в приложении (настройка профиля, **включена по умолчанию**). +события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья) +приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил +уведомления только в приложении (настройка профиля, **включена по умолчанию**). +Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и +текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по +завершении партии приходит уведомление «конец партии» с твоим результатом и финальным +счётом (счёт читается, твой первым). ### Аккаунты, привязка и слияние *(Stage 1 / 11)* Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок diff --git a/gateway/internal/connector/routing.go b/gateway/internal/connector/routing.go index babfd8f..97a9e8a 100644 --- a/gateway/internal/connector/routing.go +++ b/gateway/internal/connector/routing.go @@ -8,6 +8,7 @@ var outOfAppKinds = map[string]bool{ "nudge": true, "match_found": true, "notify": true, + "game_over": true, } // OutOfAppKind reports whether a push kind is eligible for out-of-app delivery. diff --git a/gateway/internal/connector/routing_test.go b/gateway/internal/connector/routing_test.go index 7cb62c1..1fba720 100644 --- a/gateway/internal/connector/routing_test.go +++ b/gateway/internal/connector/routing_test.go @@ -3,7 +3,7 @@ package connector import "testing" func TestOutOfAppKind(t *testing.T) { - out := []string{"your_turn", "nudge", "match_found", "notify"} + out := []string{"your_turn", "game_over", "nudge", "match_found", "notify"} for _, k := range out { if !OutOfAppKind(k) { t.Errorf("OutOfAppKind(%q) = false, want true", k) diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b1d5651..1814920 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -472,10 +472,27 @@ table GcgExport { // --- push event payloads --- -// YourTurnEvent signals that it is now the recipient's turn. +// YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the +// out-of-app push (Stage 17): opponent_name is the player who just moved, last_action is their +// move kind ("play"/"pass"/"exchange"/...), last_word is the main word of a scoring play (empty +// otherwise), and score_line is the recipient-first running score (e.g. "120:95:80"). They are +// appended (FlatBuffers-optional), so an older reader that only needs game_id/deadline is unaffected. table YourTurnEvent { game_id:string; deadline_unix:long; + opponent_name:string; + last_action:string; + last_word:string; + score_line:string; +} + +// GameOverEvent signals that a game the recipient is seated in has finished, driving the +// out-of-app "game over" push (Stage 17). result is the outcome from the recipient's own +// perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score. +table GameOverEvent { + game_id:string; + result:string; + score_line:string; } // OpponentMovedEvent summarises a move another seat just committed; the client diff --git a/pkg/fbs/scrabblefb/GameOverEvent.go b/pkg/fbs/scrabblefb/GameOverEvent.go new file mode 100644 index 0000000..e77f00f --- /dev/null +++ b/pkg/fbs/scrabblefb/GameOverEvent.go @@ -0,0 +1,82 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type GameOverEvent struct { + _tab flatbuffers.Table +} + +func GetRootAsGameOverEvent(buf []byte, offset flatbuffers.UOffsetT) *GameOverEvent { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &GameOverEvent{} + x.Init(buf, n+offset) + return x +} + +func FinishGameOverEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsGameOverEvent(buf []byte, offset flatbuffers.UOffsetT) *GameOverEvent { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &GameOverEvent{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedGameOverEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *GameOverEvent) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *GameOverEvent) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *GameOverEvent) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GameOverEvent) Result() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GameOverEvent) ScoreLine() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func GameOverEventStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func GameOverEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func GameOverEventAddResult(builder *flatbuffers.Builder, result flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(result), 0) +} +func GameOverEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(scoreLine), 0) +} +func GameOverEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/YourTurnEvent.go b/pkg/fbs/scrabblefb/YourTurnEvent.go index def7be8..9534d59 100644 --- a/pkg/fbs/scrabblefb/YourTurnEvent.go +++ b/pkg/fbs/scrabblefb/YourTurnEvent.go @@ -61,8 +61,40 @@ func (rcv *YourTurnEvent) MutateDeadlineUnix(n int64) bool { return rcv._tab.MutateInt64Slot(6, n) } +func (rcv *YourTurnEvent) OpponentName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *YourTurnEvent) LastAction() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *YourTurnEvent) LastWord() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *YourTurnEvent) ScoreLine() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func YourTurnEventStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(6) } func YourTurnEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -70,6 +102,18 @@ func YourTurnEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOf func YourTurnEventAddDeadlineUnix(builder *flatbuffers.Builder, deadlineUnix int64) { builder.PrependInt64Slot(1, deadlineUnix, 0) } +func YourTurnEventAddOpponentName(builder *flatbuffers.Builder, opponentName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(opponentName), 0) +} +func YourTurnEventAddLastAction(builder *flatbuffers.Builder, lastAction flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(lastAction), 0) +} +func YourTurnEventAddLastWord(builder *flatbuffers.Builder, lastWord flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(lastWord), 0) +} +func YourTurnEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(scoreLine), 0) +} func YourTurnEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/platform/telegram/internal/render/render.go b/platform/telegram/internal/render/render.go index e6ff16a..396eafa 100644 --- a/platform/telegram/internal/render/render.go +++ b/platform/telegram/internal/render/render.go @@ -1,11 +1,13 @@ // Package render turns a backend push event into a localized Telegram message with -// a Mini App deep-link. Only the out-of-app push set is rendered (your_turn, nudge, -// match_found, and the invitation / friend_request notify sub-kinds); every other +// a Mini App deep-link. Only the out-of-app push set is rendered (your_turn, game_over, +// nudge, match_found, and the invitation / friend_request notify sub-kinds); every other // kind returns ok=false so the connector skips it (the in-app stream still carries // it). package render import ( + "fmt" + "scrabble/pkg/fbs/scrabblefb" "scrabble/platform/telegram/internal/deeplink" ) @@ -29,7 +31,10 @@ func Render(kind string, payload []byte, lang string) (Message, bool) { switch kind { case "your_turn": ev := scrabblefb.GetRootAsYourTurnEvent(payload, 0) - return Message{Text: p.yourTurn, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true + return Message{Text: yourTurnText(ev, p), ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true + case "game_over": + ev := scrabblefb.GetRootAsGameOverEvent(payload, 0) + return Message{Text: gameOverText(ev, p), ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true case "nudge": ev := scrabblefb.GetRootAsNudgeEvent(payload, 0) return Message{Text: p.nudge, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true @@ -48,33 +53,92 @@ func Render(kind string, payload []byte, lang string) (Message, bool) { return Message{}, false } -// phrases is one language's message catalog. +// yourTurnText renders the enriched "your turn" body (Stage 17), voiced as the opponent who +// just moved ("{name}: my move — «WORD». Score 120:95"). It falls back to the plain phrase when +// the opponent name is missing (an older backend, or an unresolved name). +func yourTurnText(ev *scrabblefb.YourTurnEvent, p phrases) string { + name := string(ev.OpponentName()) + if name == "" { + return p.yourTurn + } + switch string(ev.LastAction()) { + case "play": + if word := string(ev.LastWord()); word != "" { + return fmt.Sprintf(p.yourTurnPlay, name, word, string(ev.ScoreLine())) + } + return fmt.Sprintf(p.yourTurnMoved, name) + case "exchange": + return fmt.Sprintf(p.yourTurnExchange, name) + case "pass": + return fmt.Sprintf(p.yourTurnPass, name) + default: + return p.yourTurn + } +} + +// gameOverText renders the "game over" body (Stage 17) from the recipient's own perspective. +func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string { + score := string(ev.ScoreLine()) + switch string(ev.Result()) { + case "won": + return fmt.Sprintf(p.gameOverWon, score) + case "lost": + return fmt.Sprintf(p.gameOverLost, score) + default: + return fmt.Sprintf(p.gameOverDraw, score) + } +} + +// phrases is one language's message catalog. The yourTurn*/gameOver* entries are fmt format +// strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved take (name); +// the gameOver* entries take (scoreLine). type phrases struct { - yourTurn string - nudge string - matchFound string - invitation string - friendRequest string - openGame string - open string + yourTurn string + yourTurnPlay string + yourTurnExchange string + yourTurnPass string + yourTurnMoved string + gameOverWon string + gameOverLost string + gameOverDraw string + nudge string + matchFound string + invitation string + friendRequest string + openGame string + open string } var english = phrases{ - yourTurn: "It's your turn.", - nudge: "You were nudged — it's your turn.", - matchFound: "Your game is ready.", - invitation: "You have a new game invitation.", - friendRequest: "You have a new friend request.", - openGame: "Open game", - open: "Open", + yourTurn: "It's your turn.", + yourTurnPlay: "%s: my move — «%s». Score %s", + yourTurnExchange: "%s: swapping tiles, your turn.", + yourTurnPass: "%s: passing, your turn.", + yourTurnMoved: "%s moved, your turn.", + gameOverWon: "Game over — you won! Score %s", + gameOverLost: "Game over — you lost. Score %s", + gameOverDraw: "Game over — a draw. Score %s", + nudge: "You were nudged — it's your turn.", + matchFound: "Your game is ready.", + invitation: "You have a new game invitation.", + friendRequest: "You have a new friend request.", + openGame: "Open game", + open: "Open", } var russian = phrases{ - yourTurn: "Ваш ход.", - nudge: "Вас поторопили — ваш ход.", - matchFound: "Игра найдена.", - invitation: "Вас пригласили в игру.", - friendRequest: "Вам пришла заявка в друзья.", - openGame: "Открыть игру", - open: "Открыть", + yourTurn: "Ваш ход.", + yourTurnPlay: "%s: мой ход — «%s». Счёт %s", + yourTurnExchange: "%s: меняю фишки, ваш ход.", + yourTurnPass: "%s: пропускаю ход, ваш ход.", + yourTurnMoved: "%s сходил(а), ваш ход.", + gameOverWon: "Игра окончена — вы выиграли! Счёт %s", + gameOverLost: "Игра окончена — вы проиграли. Счёт %s", + gameOverDraw: "Игра окончена — ничья. Счёт %s", + nudge: "Вас поторопили — ваш ход.", + matchFound: "Игра найдена.", + invitation: "Вас пригласили в игру.", + friendRequest: "Вам пришла заявка в друзья.", + openGame: "Открыть игру", + open: "Открыть", } diff --git a/platform/telegram/internal/render/render_test.go b/platform/telegram/internal/render/render_test.go index f6d5606..0a8d662 100644 --- a/platform/telegram/internal/render/render_test.go +++ b/platform/telegram/internal/render/render_test.go @@ -1,6 +1,7 @@ package render import ( + "strings" "testing" flatbuffers "github.com/google/flatbuffers/go" @@ -46,6 +47,86 @@ func notifyPayload(kind string) []byte { return b.FinishedBytes() } +func enrichedYourTurnPayload(id, name, action, word, score string) []byte { + b := flatbuffers.NewBuilder(0) + gid := b.CreateString(id) + n := b.CreateString(name) + a := b.CreateString(action) + w := b.CreateString(word) + s := b.CreateString(score) + scrabblefb.YourTurnEventStart(b) + scrabblefb.YourTurnEventAddGameId(b, gid) + scrabblefb.YourTurnEventAddOpponentName(b, n) + scrabblefb.YourTurnEventAddLastAction(b, a) + scrabblefb.YourTurnEventAddLastWord(b, w) + scrabblefb.YourTurnEventAddScoreLine(b, s) + b.Finish(scrabblefb.YourTurnEventEnd(b)) + return b.FinishedBytes() +} + +func gameOverPayload(id, result, score string) []byte { + b := flatbuffers.NewBuilder(0) + gid := b.CreateString(id) + r := b.CreateString(result) + s := b.CreateString(score) + scrabblefb.GameOverEventStart(b) + scrabblefb.GameOverEventAddGameId(b, gid) + scrabblefb.GameOverEventAddResult(b, r) + scrabblefb.GameOverEventAddScoreLine(b, s) + b.Finish(scrabblefb.GameOverEventEnd(b)) + return b.FinishedBytes() +} + +// TestRenderYourTurnEnriched checks the enriched "your turn" body names the opponent, their word +// and the recipient-first score for a scoring play, and uses the shorter phrase for an exchange. +func TestRenderYourTurnEnriched(t *testing.T) { + en, ok := Render("your_turn", enrichedYourTurnPayload(gameID, "Ann", "play", "STOOL", "120:95"), "en") + if !ok { + t.Fatal("expected ok") + } + for _, want := range []string{"Ann", "STOOL", "120:95"} { + if !strings.Contains(en.Text, want) { + t.Errorf("en play text %q missing %q", en.Text, want) + } + } + ru, _ := Render("your_turn", enrichedYourTurnPayload(gameID, "Аня", "play", "СТОЛ", "120:95"), "ru") + for _, want := range []string{"Аня", "СТОЛ", "120:95", "мой ход"} { + if !strings.Contains(ru.Text, want) { + t.Errorf("ru play text %q missing %q", ru.Text, want) + } + } + ex, _ := Render("your_turn", enrichedYourTurnPayload(gameID, "Ann", "exchange", "", ""), "en") + if !strings.Contains(ex.Text, "Ann") || strings.Contains(ex.Text, "«") { + t.Errorf("en exchange text %q should name the opponent and carry no word", ex.Text) + } +} + +// TestRenderGameOver checks the game_over body reads from the recipient's perspective in both +// languages and carries the game deep-link. +func TestRenderGameOver(t *testing.T) { + cases := []struct{ result, en, ru string }{ + {"won", "won", "выиграли"}, + {"lost", "lost", "проиграли"}, + {"draw", "draw", "ничья"}, + } + for _, tc := range cases { + en, ok := Render("game_over", gameOverPayload(gameID, tc.result, "120:95"), "en") + if !ok { + t.Fatalf("%s: expected ok", tc.result) + } + if en.StartParam != "g"+gameID { + t.Errorf("%s StartParam = %q, want g%s", tc.result, en.StartParam, gameID) + } + if !strings.Contains(strings.ToLower(en.Text), tc.en) || !strings.Contains(en.Text, "120:95") { + t.Errorf("en %s text %q", tc.result, en.Text) + } + ru, _ := Render("game_over", gameOverPayload(gameID, tc.result, "120:95"), "ru") + if !strings.Contains(ru.Text, tc.ru) { + t.Errorf("ru %s text %q missing %q", tc.result, ru.Text, tc.ru) + } + } +} + func TestRenderGameEvents(t *testing.T) { cases := []struct { name, kind string diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 5ea566f..198e0d0 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -23,6 +23,7 @@ export { FriendList } from './scrabblefb/friend-list.js'; export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js'; export { GameActionRequest } from './scrabblefb/game-action-request.js'; export { GameList } from './scrabblefb/game-list.js'; +export { GameOverEvent } from './scrabblefb/game-over-event.js'; export { GameView } from './scrabblefb/game-view.js'; export { GcgExport } from './scrabblefb/gcg-export.js'; export { GuestLoginRequest } from './scrabblefb/guest-login-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/game-over-event.ts b/ui/src/gen/fbs/scrabblefb/game-over-event.ts new file mode 100644 index 0000000..60c6bb7 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/game-over-event.ts @@ -0,0 +1,72 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class GameOverEvent { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):GameOverEvent { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsGameOverEvent(bb:flatbuffers.ByteBuffer, obj?:GameOverEvent):GameOverEvent { + return (obj || new GameOverEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsGameOverEvent(bb:flatbuffers.ByteBuffer, obj?:GameOverEvent):GameOverEvent { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new GameOverEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +gameId():string|null +gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +gameId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +result():string|null +result(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +result(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +scoreLine():string|null +scoreLine(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +scoreLine(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startGameOverEvent(builder:flatbuffers.Builder) { + builder.startObject(3); +} + +static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, gameIdOffset, 0); +} + +static addResult(builder:flatbuffers.Builder, resultOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, resultOffset, 0); +} + +static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, scoreLineOffset, 0); +} + +static endGameOverEvent(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createGameOverEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, resultOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset { + GameOverEvent.startGameOverEvent(builder); + GameOverEvent.addGameId(builder, gameIdOffset); + GameOverEvent.addResult(builder, resultOffset); + GameOverEvent.addScoreLine(builder, scoreLineOffset); + return GameOverEvent.endGameOverEvent(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/your-turn-event.ts b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts index 360fb1d..cb14d04 100644 --- a/ui/src/gen/fbs/scrabblefb/your-turn-event.ts +++ b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts @@ -32,8 +32,36 @@ deadlineUnix():bigint { return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); } +opponentName():string|null +opponentName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +opponentName(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +lastAction():string|null +lastAction(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +lastAction(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +lastWord():string|null +lastWord(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +lastWord(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +scoreLine():string|null +scoreLine(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +scoreLine(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startYourTurnEvent(builder:flatbuffers.Builder) { - builder.startObject(2); + builder.startObject(6); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { @@ -44,15 +72,35 @@ static addDeadlineUnix(builder:flatbuffers.Builder, deadlineUnix:bigint) { builder.addFieldInt64(1, deadlineUnix, BigInt('0')); } +static addOpponentName(builder:flatbuffers.Builder, opponentNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, opponentNameOffset, 0); +} + +static addLastAction(builder:flatbuffers.Builder, lastActionOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, lastActionOffset, 0); +} + +static addLastWord(builder:flatbuffers.Builder, lastWordOffset:flatbuffers.Offset) { + builder.addFieldOffset(4, lastWordOffset, 0); +} + +static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, scoreLineOffset, 0); +} + static endYourTurnEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint):flatbuffers.Offset { +static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset { YourTurnEvent.startYourTurnEvent(builder); YourTurnEvent.addGameId(builder, gameIdOffset); YourTurnEvent.addDeadlineUnix(builder, deadlineUnix); + YourTurnEvent.addOpponentName(builder, opponentNameOffset); + YourTurnEvent.addLastAction(builder, lastActionOffset); + YourTurnEvent.addLastWord(builder, lastWordOffset); + YourTurnEvent.addScoreLine(builder, scoreLineOffset); return YourTurnEvent.endYourTurnEvent(builder); } }