Merge pull request 'Stage 17 #4: enrich the out-of-app your-turn push + add game-over' (#28) from feature/push-enrichment into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 35s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m10s

This commit was merged in pull request #28.
This commit is contained in:
2026-06-08 23:29:16 +00:00
19 changed files with 657 additions and 52 deletions
+12 -2
View File
@@ -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)
+55 -2
View File
@@ -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())
}
}
+78 -5
View File
@@ -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) {
+31 -4
View File
@@ -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 {
+3
View File
@@ -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
+18 -1
View File
@@ -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) {
+7 -2
View File
@@ -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
+6 -3
View File
@@ -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
+7 -3
View File
@@ -42,9 +42,13 @@ Mini App** авторизует по подписанным `initData` плат
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят
вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления
только в приложении (настройка профиля, **включена по умолчанию**).
события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья)
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по
завершении партии приходит уведомление «конец партии» с твоим результатом и финальным
счётом (счёт читается, твой первым).
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
+1
View File
@@ -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.
+1 -1
View File
@@ -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)
+18 -1
View File
@@ -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
+82
View File
@@ -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()
}
+45 -1
View File
@@ -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()
}
+89 -25
View File
@@ -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: "Открыть",
}
@@ -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
+1
View File
@@ -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';
@@ -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);
}
}
+50 -2
View File
@@ -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);
}
}