From 12d128f1cc7971222445087135ae93bdf8170fbc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 19:09:24 +0200 Subject: [PATCH 1/2] feat(nudge): name the sender; blink lobby cards; re-animate toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "waiting for your move" popup was the nudge (chat.nudge), shown without a sender name. Resolve the nudger's per-game seat name server-side and carry it on a new NudgeEvent.sender_name field, so: - the in-app toast reads ": Waiting for your move 🤭" (chat.nudgeBy); - the out-of-app Telegram push names the sender too (render nudgeBy); falling back to the plain phrase when the name is absent (RU mirrored). The your_turn toast already named the opponent and is unchanged. Lobby: when a card transitions into "your turn" or "finished" while the lobby is open, its status emoji blinks twice (two 1s fades); the opponent's-turn change stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer) so overlapping events animate in isolation; suppressed under reduce-motion. Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the previous one and replays its entrance, uniformly on every screen. Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName (integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru, UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated. --- PLAN.md | 6 ++ backend/internal/game/service.go | 17 +++++ backend/internal/inttest/social_test.go | 27 ++++++++ backend/internal/notify/events.go | 8 ++- backend/internal/notify/notify_test.go | 12 ++++ backend/internal/social/chat.go | 5 +- backend/internal/social/social.go | 4 ++ docs/FUNCTIONAL.md | 7 +- docs/FUNCTIONAL_ru.md | 9 ++- docs/UI_DESIGN.md | 7 +- pkg/fbs/scrabble.fbs | 5 +- pkg/fbs/scrabblefb/NudgeEvent.go | 13 +++- platform/telegram/internal/render/render.go | 21 ++++-- .../telegram/internal/render/render_test.go | 28 ++++++++ ui/src/components/Toast.svelte | 22 +++--- ui/src/gen/fbs/scrabblefb/nudge-event.ts | 16 ++++- ui/src/lib/app.svelte.ts | 10 ++- ui/src/lib/codec.test.ts | 18 +++++ ui/src/lib/codec.ts | 2 +- ui/src/lib/i18n.test.ts | 5 ++ ui/src/lib/i18n/en.ts | 3 +- ui/src/lib/i18n/ru.ts | 3 +- ui/src/lib/lobbysort.test.ts | 27 +++++++- ui/src/lib/lobbysort.ts | 23 +++++++ ui/src/lib/model.ts | 2 +- ui/src/screens/Lobby.svelte | 67 ++++++++++++++++++- 26 files changed, 331 insertions(+), 36 deletions(-) diff --git a/PLAN.md b/PLAN.md index 852ad66..a4d5855 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1589,3 +1589,9 @@ cannot submit; three-way admin filter. `account_stats` or a games query), falling back to their interface language (en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing wrong. +- **TODO-7 — animate a lobby card's move between sections (owner's idea, 2026-06).** + When a game changes bucket — *your turn* → *finished*, *opponent's turn* → *your turn*, etc. — + its card simply re-renders in the new section; only the status emoji **blinks twice** (added + alongside the named-nudge + lobby-blink work). A FLIP / slide animation that visibly relocates + the card to its new section would be a nicer touch. Deferred — purely cosmetic, nothing depends + on it. diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 1e21942..a975123 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1144,6 +1144,23 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid. return seats, g.ToMove, g.Status, nil } +// SeatName returns the display name shown for the account seated in the game — the seat's +// captured snapshot, falling back to the account's current name (see seatDisplayName) — or "" +// when the account holds no seat. It lets the social package name a nudge's sender by the same +// per-game identity the rest of the game uses, without exposing the seats. +func (svc *Service) SeatName(ctx context.Context, gameID, accountID uuid.UUID) (string, error) { + g, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return "", err + } + for _, s := range g.Seats { + if s.AccountID == accountID { + return svc.seatDisplayName(ctx, s), nil + } + } + return "", nil +} + // SharedGame reports whether accounts a and b are seated together in any game // (active or finished). It backs the social package's "befriend an opponent" // request gate without exposing the games tables; a self-pair is never shared. diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index e6e263c..971b027 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -382,6 +382,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) { } } +// TestSeatNameResolvesDisplayName checks game.Service.SeatName surfaces a seated account's +// display name — the identity social.Nudge voices the nudge with — and returns "" for an account +// that holds no seat (the render then falls back to the plain phrase). +func TestSeatNameResolvesDisplayName(t *testing.T) { + ctx := context.Background() + gsvc := newGameService() + // ProvisionRobot is the one provisioning path that stamps a known display name (a plain + // provisioned account has none), so the seat resolves to a name we can assert. + named, err := account.NewStore(testDB).ProvisionRobot(ctx, "tg-"+uuid.NewString(), "Ann") + if err != nil { + t.Fatalf("provision named account: %v", err) + } + g, err := gsvc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{provisionAccount(t), named.ID}, + TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if got, err := gsvc.SeatName(ctx, g.ID, named.ID); err != nil || got != "Ann" { + t.Errorf("seated SeatName = (%q, %v), want (\"Ann\", nil)", got, err) + } + if got, err := gsvc.SeatName(ctx, g.ID, provisionAccount(t)); err != nil || got != "" { + t.Errorf("non-seated SeatName = (%q, %v), want (\"\", nil)", got, err) + } +} + // TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has // acted (moved or chatted) since their last nudge, even within the hour. func TestNudgeCooldownResetsOnAction(t *testing.T) { diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 4c97aec..bc9769c 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -96,14 +96,18 @@ func ChatMessage(userID, gameID, senderID uuid.UUID, id, kind, body string, crea return Intent{UserID: userID, Kind: KindChatMessage, Payload: b.FinishedBytes(), EventID: eventID()} } -// Nudge tells userID that fromUserID nudged them in game gameID. -func Nudge(userID, gameID, fromUserID uuid.UUID) Intent { +// Nudge tells userID that fromUserID nudged them in game gameID. senderName is fromUserID's +// seat display name, used to voice the toast and the out-of-app push (": …"); an empty +// name leaves the render to fall back to the plain phrase. +func Nudge(userID, gameID, fromUserID uuid.UUID, senderName string) Intent { b := flatbuffers.NewBuilder(64) gid := b.CreateString(gameID.String()) from := b.CreateString(fromUserID.String()) + name := b.CreateString(senderName) fb.NudgeEventStart(b) fb.NudgeEventAddGameId(b, gid) fb.NudgeEventAddFromUserId(b, from) + fb.NudgeEventAddSenderName(b, name) b.Finish(fb.NudgeEventEnd(b)) return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()} } diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 7680360..5b34d8c 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -83,6 +83,18 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) { } } +func TestNudgePayloadRoundTrips(t *testing.T) { + uid, gid, from := uuid.New(), uuid.New(), uuid.New() + in := notify.Nudge(uid, gid, from, "Ann") + if in.UserID != uid || in.Kind != notify.KindNudge || in.EventID == "" { + t.Fatalf("intent metadata wrong: %+v", in) + } + ev := fb.GetRootAsNudgeEvent(in.Payload, 0) + if string(ev.GameId()) != gid.String() || string(ev.FromUserId()) != from.String() || string(ev.SenderName()) != "Ann" { + t.Fatalf("nudge fields wrong: game=%q from=%q name=%q", ev.GameId(), ev.FromUserId(), ev.SenderName()) + } +} + func TestGameOverPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}} diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 14e685c..8295c86 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -154,7 +154,10 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess } svc.metrics.recordChat(ctx, kindNudge) if toMove >= 0 && toMove < len(seats) { - nudge := notify.Nudge(seats[toMove], gameID, senderID) + // Name the sender by their per-game seat snapshot, so the toast and the out-of-app push + // read ": …"; an unresolved name (best-effort) falls back to the plain phrase. + senderName, _ := svc.games.SeatName(ctx, gameID, senderID) + nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName) 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 } diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 91fb11f..9f5f9eb 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -25,6 +25,10 @@ import ( // private state. type GameReader interface { Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error) + // SeatName is the display name of the account seated in the game (its per-game snapshot, + // falling back to the account's current name), or "" when it holds no seat; it names a + // nudge's sender in the toast and the out-of-app push by the same identity the game uses. + SeatName(ctx context.Context, gameID, accountID uuid.UUID) (string, error) // SharedGame reports whether two accounts are seated together in any game // (active or finished); it gates the "befriend an opponent" request path. SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 725623d..7beb905 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -75,7 +75,9 @@ The lobby lists **my games** and offers a bottom tab bar — new game, statistic sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and orders them so the games awaiting your move come first, the longest-waiting on top, while opponent-turn and finished games are most-recent first; it renders as a compact, -line-separated list. You can **remove a finished game from your own list**: +line-separated list. While the lobby is open and a listed game **becomes your turn or +finishes**, its status icon **blinks twice** to draw the eye (an opponent's-turn change is +silent, applied in place). You can **remove a finished game from your own list**: swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it. The removal is per-account and permanent — the game disappears only from your list and stays in the other players' lists, and there is no undo. The game types offered on **New Game** are @@ -177,7 +179,8 @@ existing friendship). Per-game chat is for quick reactions: messages are short even disguised. You may send **one message per turn, on your own turn**; once it is sent the field gives way to a short caption until your next turn. Nudge the player whose turn is awaited at most once per hour (the -nudge is part of the game chat); the out-of-app push is delivered via the platform. +nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered +via the platform) **name the nudger** (": waiting for your move"). Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index e851e94..ec98a5f 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -76,7 +76,9 @@ nudge) приходят от бота **этой партии** — по язы *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу соперника и завершённые — самые свежие сверху; отображается компактным списком с -линиями-разделителями. Завершённую партию можно **убрать из своего списка**: +линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или +завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход +соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**: проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть **❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий @@ -180,8 +182,9 @@ nudge) приходят от бота **этой партии** — по язы содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего хода. Nudge ожидаемого -соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий -push доставляется через платформу. +соперника — не чаще раза в час (nudge — часть игрового чата); и всплывашка в приложении, +и внеприложенческий push (доставляется через платформу) **называют отправителя** +(«<соперник>: жду вашего хода»). Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует **бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 92841a9..f373846 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -233,7 +233,12 @@ and a long message does not scroll. Lobby rows show two lines (opponents, then result + score) with a large place-based emoji on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 / -IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. +IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. When a listed +game **becomes your turn or finishes** while the lobby is open, that status emoji **blinks +twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the +opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping +events animate in isolation, and the newest bottom toast cancels the previous one and replays +its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). ## Social, account & history surfaces diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index d10d3ad..cecf040 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -606,10 +606,13 @@ table OpponentMovedEvent { bag_len:int; } -// NudgeEvent signals that a player nudged the recipient. +// NudgeEvent signals that a player nudged the recipient. sender_name is the nudger's seat +// display-name snapshot, so the toast and the out-of-app push can name them (added trailing — +// an older reader still reads just game_id + from_user_id, and falls back to the plain phrase). table NudgeEvent { game_id:string; from_user_id:string; + sender_name:string; } // MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the diff --git a/pkg/fbs/scrabblefb/NudgeEvent.go b/pkg/fbs/scrabblefb/NudgeEvent.go index 9b4e907..89f2e28 100644 --- a/pkg/fbs/scrabblefb/NudgeEvent.go +++ b/pkg/fbs/scrabblefb/NudgeEvent.go @@ -57,8 +57,16 @@ func (rcv *NudgeEvent) FromUserId() []byte { return nil } +func (rcv *NudgeEvent) SenderName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func NudgeEventStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(3) } func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -66,6 +74,9 @@ func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffse func NudgeEventAddFromUserId(builder *flatbuffers.Builder, fromUserId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(fromUserId), 0) } +func NudgeEventAddSenderName(builder *flatbuffers.Builder, senderName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(senderName), 0) +} func NudgeEventEnd(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 01dcef0..2ac2800 100644 --- a/platform/telegram/internal/render/render.go +++ b/platform/telegram/internal/render/render.go @@ -37,7 +37,7 @@ func Render(kind string, payload []byte, lang string) (Message, bool) { 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 + return Message{Text: nudgeText(ev, p), ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true case "match_found": ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0) return Message{Text: p.matchFound, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true @@ -76,6 +76,16 @@ func yourTurnText(ev *scrabblefb.YourTurnEvent, p phrases) string { } } +// nudgeText renders the nudge body, voiced as the sender who is waiting ("{name}: Waiting for +// your move 🤭"). It falls back to the plain phrase when the sender name is missing (an older +// backend, or an unresolved name). +func nudgeText(ev *scrabblefb.NudgeEvent, p phrases) string { + if name := string(ev.SenderName()); name != "" { + return fmt.Sprintf(p.nudgeBy, name) + } + return p.nudge +} + // gameOverText renders the "game over" body from the recipient's own perspective. func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string { score := string(ev.ScoreLine()) @@ -89,9 +99,9 @@ func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string { } } -// 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). +// phrases is one language's message catalog. The yourTurn*/gameOver*/nudgeBy entries are fmt +// format strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved and +// nudgeBy take (name); the gameOver* entries take (scoreLine). type phrases struct { yourTurn string yourTurnPlay string @@ -102,6 +112,7 @@ type phrases struct { gameOverLost string gameOverDraw string nudge string + nudgeBy string matchFound string invitation string friendRequest string @@ -119,6 +130,7 @@ var english = phrases{ gameOverLost: "Game over — you lost. Score %s", gameOverDraw: "Game over — a draw. Score %s", nudge: "You were nudged — it's your turn.", + nudgeBy: "%s: Waiting for your move 🤭", matchFound: "Your game is ready.", invitation: "You have a new game invitation.", friendRequest: "You have a new friend request.", @@ -136,6 +148,7 @@ var russian = phrases{ gameOverLost: "Игра окончена — вы проиграли. Счёт %s", gameOverDraw: "Игра окончена — ничья. Счёт %s", nudge: "Вас поторопили — ваш ход.", + nudgeBy: "%s: Жду Вашего хода 🤭", matchFound: "Игра найдена.", invitation: "Вас пригласили в игру.", friendRequest: "Вам пришла заявка в друзья.", diff --git a/platform/telegram/internal/render/render_test.go b/platform/telegram/internal/render/render_test.go index 0a8d662..0d0a5cc 100644 --- a/platform/telegram/internal/render/render_test.go +++ b/platform/telegram/internal/render/render_test.go @@ -29,6 +29,17 @@ func nudgePayload(id string) []byte { return b.FinishedBytes() } +func namedNudgePayload(id, name string) []byte { + b := flatbuffers.NewBuilder(0) + gid := b.CreateString(id) + n := b.CreateString(name) + scrabblefb.NudgeEventStart(b) + scrabblefb.NudgeEventAddGameId(b, gid) + scrabblefb.NudgeEventAddSenderName(b, n) + b.Finish(scrabblefb.NudgeEventEnd(b)) + return b.FinishedBytes() +} + func matchFoundPayload(id string) []byte { b := flatbuffers.NewBuilder(0) gid := b.CreateString(id) @@ -164,6 +175,23 @@ func TestRenderGameEvents(t *testing.T) { } } +// TestRenderNudgeNamed checks the nudge body names the sender when present (both languages) and +// falls back to the plain phrase — no stray format directive — when the name is absent. +func TestRenderNudgeNamed(t *testing.T) { + en, ok := Render("nudge", namedNudgePayload(gameID, "Ann"), "en") + if !ok || !strings.Contains(en.Text, "Ann") { + t.Errorf("en named nudge %q should name the sender", en.Text) + } + ru, _ := Render("nudge", namedNudgePayload(gameID, "Аня"), "ru") + if !strings.Contains(ru.Text, "Аня") { + t.Errorf("ru named nudge %q should name the sender", ru.Text) + } + plain, _ := Render("nudge", nudgePayload(gameID), "en") + if plain.Text == "" || strings.Contains(plain.Text, "%s") { + t.Errorf("unnamed nudge %q should be the plain phrase, not a format string", plain.Text) + } +} + func TestRenderNotify(t *testing.T) { cases := map[string]struct { subKind string diff --git a/ui/src/components/Toast.svelte b/ui/src/components/Toast.svelte index 12ee496..43859b4 100644 --- a/ui/src/components/Toast.svelte +++ b/ui/src/components/Toast.svelte @@ -6,15 +6,19 @@ {#if app.toast} -
- {app.toast.text} -
+ + {#key app.toast.seq} +
+ {app.toast.text} +
+ {/key} {/if}