Merge pull request 'feat(nudge): name the sender; blink lobby cards; re-animate toasts' (#75) from feature/nudge-name-lobby-blink into development
This commit was merged in pull request #75.
This commit is contained in:
@@ -1589,3 +1589,9 @@ cannot submit; three-way admin filter.
|
|||||||
`account_stats` or a games query), falling back to their interface language
|
`account_stats` or a games query), falling back to their interface language
|
||||||
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
|
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
|
||||||
wrong.
|
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.
|
||||||
|
|||||||
@@ -1144,6 +1144,23 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.
|
|||||||
return seats, g.ToMove, g.Status, nil
|
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
|
// 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"
|
// (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.
|
// request gate without exposing the games tables; a self-pair is never shared.
|
||||||
|
|||||||
@@ -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
|
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
|
||||||
// acted (moved or chatted) since their last nudge, even within the hour.
|
// acted (moved or chatted) since their last nudge, even within the hour.
|
||||||
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||||
|
|||||||
@@ -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()}
|
return Intent{UserID: userID, Kind: KindChatMessage, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nudge tells userID that fromUserID nudged them in game gameID.
|
// Nudge tells userID that fromUserID nudged them in game gameID. senderName is fromUserID's
|
||||||
func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
|
// seat display name, used to voice the toast and the out-of-app push ("<name>: …"); 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)
|
b := flatbuffers.NewBuilder(64)
|
||||||
gid := b.CreateString(gameID.String())
|
gid := b.CreateString(gameID.String())
|
||||||
from := b.CreateString(fromUserID.String())
|
from := b.CreateString(fromUserID.String())
|
||||||
|
name := b.CreateString(senderName)
|
||||||
fb.NudgeEventStart(b)
|
fb.NudgeEventStart(b)
|
||||||
fb.NudgeEventAddGameId(b, gid)
|
fb.NudgeEventAddGameId(b, gid)
|
||||||
fb.NudgeEventAddFromUserId(b, from)
|
fb.NudgeEventAddFromUserId(b, from)
|
||||||
|
fb.NudgeEventAddSenderName(b, name)
|
||||||
b.Finish(fb.NudgeEventEnd(b))
|
b.Finish(fb.NudgeEventEnd(b))
|
||||||
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestGameOverPayloadRoundTrips(t *testing.T) {
|
||||||
uid, gid := uuid.New(), uuid.New()
|
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}}}
|
summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}}
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
|||||||
}
|
}
|
||||||
svc.metrics.recordChat(ctx, kindNudge)
|
svc.metrics.recordChat(ctx, kindNudge)
|
||||||
if toMove >= 0 && toMove < len(seats) {
|
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 "<name>: …"; 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 {
|
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
|
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import (
|
|||||||
// private state.
|
// private state.
|
||||||
type GameReader interface {
|
type GameReader interface {
|
||||||
Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error)
|
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
|
// SharedGame reports whether two accounts are seated together in any game
|
||||||
// (active or finished); it gates the "befriend an opponent" request path.
|
// (active or finished); it gates the "befriend an opponent" request path.
|
||||||
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
|
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||||
|
|||||||
+5
-2
@@ -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
|
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
|
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,
|
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.
|
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
|
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
|
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
|
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
|
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
|
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** ("<opponent>: waiting for your move").
|
||||||
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
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
|
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.
|
message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened.
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||||
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||||
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||||
линиями-разделителями. Завершённую партию можно **убрать из своего списка**:
|
линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или
|
||||||
|
завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход
|
||||||
|
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
|
||||||
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
||||||
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
||||||
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
|
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
|
||||||
@@ -180,8 +182,9 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
||||||
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
||||||
хода. Nudge ожидаемого
|
хода. Nudge ожидаемого
|
||||||
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
|
соперника — не чаще раза в час (nudge — часть игрового чата); и всплывашка в приложении,
|
||||||
push доставляется через платформу.
|
и внеприложенческий push (доставляется через платформу) **называют отправителя**
|
||||||
|
(«<соперник>: жду вашего хода»).
|
||||||
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
||||||
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
|
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
|
||||||
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
|
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
|
||||||
|
|||||||
+6
-1
@@ -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
|
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 🥉 /
|
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
|
## Social, account & history surfaces
|
||||||
|
|
||||||
|
|||||||
@@ -606,10 +606,13 @@ table OpponentMovedEvent {
|
|||||||
bag_len:int;
|
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 {
|
table NudgeEvent {
|
||||||
game_id:string;
|
game_id:string;
|
||||||
from_user_id:string;
|
from_user_id:string;
|
||||||
|
sender_name:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the
|
// MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ func (rcv *NudgeEvent) FromUserId() []byte {
|
|||||||
return nil
|
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) {
|
func NudgeEventStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
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) {
|
func NudgeEventAddFromUserId(builder *flatbuffers.Builder, fromUserId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(fromUserId), 0)
|
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 {
|
func NudgeEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
return Message{Text: gameOverText(ev, p), ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
|
||||||
case "nudge":
|
case "nudge":
|
||||||
ev := scrabblefb.GetRootAsNudgeEvent(payload, 0)
|
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":
|
case "match_found":
|
||||||
ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0)
|
ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0)
|
||||||
return Message{Text: p.matchFound, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
|
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.
|
// gameOverText renders the "game over" body from the recipient's own perspective.
|
||||||
func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string {
|
func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string {
|
||||||
score := string(ev.ScoreLine())
|
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
|
// phrases is one language's message catalog. The yourTurn*/gameOver*/nudgeBy entries are fmt
|
||||||
// strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved take (name);
|
// format strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved and
|
||||||
// the gameOver* entries take (scoreLine).
|
// nudgeBy take (name); the gameOver* entries take (scoreLine).
|
||||||
type phrases struct {
|
type phrases struct {
|
||||||
yourTurn string
|
yourTurn string
|
||||||
yourTurnPlay string
|
yourTurnPlay string
|
||||||
@@ -102,6 +112,7 @@ type phrases struct {
|
|||||||
gameOverLost string
|
gameOverLost string
|
||||||
gameOverDraw string
|
gameOverDraw string
|
||||||
nudge string
|
nudge string
|
||||||
|
nudgeBy string
|
||||||
matchFound string
|
matchFound string
|
||||||
invitation string
|
invitation string
|
||||||
friendRequest string
|
friendRequest string
|
||||||
@@ -119,6 +130,7 @@ var english = phrases{
|
|||||||
gameOverLost: "Game over — you lost. Score %s",
|
gameOverLost: "Game over — you lost. Score %s",
|
||||||
gameOverDraw: "Game over — a draw. Score %s",
|
gameOverDraw: "Game over — a draw. Score %s",
|
||||||
nudge: "You were nudged — it's your turn.",
|
nudge: "You were nudged — it's your turn.",
|
||||||
|
nudgeBy: "%s: Waiting for your move 🤭",
|
||||||
matchFound: "Your game is ready.",
|
matchFound: "Your game is ready.",
|
||||||
invitation: "You have a new game invitation.",
|
invitation: "You have a new game invitation.",
|
||||||
friendRequest: "You have a new friend request.",
|
friendRequest: "You have a new friend request.",
|
||||||
@@ -136,6 +148,7 @@ var russian = phrases{
|
|||||||
gameOverLost: "Игра окончена — вы проиграли. Счёт %s",
|
gameOverLost: "Игра окончена — вы проиграли. Счёт %s",
|
||||||
gameOverDraw: "Игра окончена — ничья. Счёт %s",
|
gameOverDraw: "Игра окончена — ничья. Счёт %s",
|
||||||
nudge: "Вас поторопили — ваш ход.",
|
nudge: "Вас поторопили — ваш ход.",
|
||||||
|
nudgeBy: "%s: Жду Вашего хода 🤭",
|
||||||
matchFound: "Игра найдена.",
|
matchFound: "Игра найдена.",
|
||||||
invitation: "Вас пригласили в игру.",
|
invitation: "Вас пригласили в игру.",
|
||||||
friendRequest: "Вам пришла заявка в друзья.",
|
friendRequest: "Вам пришла заявка в друзья.",
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ func nudgePayload(id string) []byte {
|
|||||||
return b.FinishedBytes()
|
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 {
|
func matchFoundPayload(id string) []byte {
|
||||||
b := flatbuffers.NewBuilder(0)
|
b := flatbuffers.NewBuilder(0)
|
||||||
gid := b.CreateString(id)
|
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) {
|
func TestRenderNotify(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
subKind string
|
subKind string
|
||||||
|
|||||||
@@ -6,15 +6,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.toast}
|
{#if app.toast}
|
||||||
<div
|
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
|
||||||
class="toast {app.toast.kind}"
|
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
|
||||||
role="status"
|
{#key app.toast.seq}
|
||||||
aria-live="polite"
|
<div
|
||||||
in:fly={{ y: 32, duration: dur }}
|
class="toast {app.toast.kind}"
|
||||||
out:fade={{ duration: dur }}
|
role="status"
|
||||||
>
|
aria-live="polite"
|
||||||
{app.toast.text}
|
in:fly={{ y: 32, duration: dur }}
|
||||||
</div>
|
out:fade={{ duration: dur }}
|
||||||
|
>
|
||||||
|
{app.toast.text}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ fromUserId(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
senderName():string|null
|
||||||
|
senderName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
senderName(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 startNudgeEvent(builder:flatbuffers.Builder) {
|
static startNudgeEvent(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||||
@@ -46,15 +53,20 @@ static addFromUserId(builder:flatbuffers.Builder, fromUserIdOffset:flatbuffers.O
|
|||||||
builder.addFieldOffset(1, fromUserIdOffset, 0);
|
builder.addFieldOffset(1, fromUserIdOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addSenderName(builder:flatbuffers.Builder, senderNameOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, senderNameOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endNudgeEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endNudgeEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createNudgeEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, fromUserIdOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createNudgeEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, fromUserIdOffset:flatbuffers.Offset, senderNameOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
NudgeEvent.startNudgeEvent(builder);
|
NudgeEvent.startNudgeEvent(builder);
|
||||||
NudgeEvent.addGameId(builder, gameIdOffset);
|
NudgeEvent.addGameId(builder, gameIdOffset);
|
||||||
NudgeEvent.addFromUserId(builder, fromUserIdOffset);
|
NudgeEvent.addFromUserId(builder, fromUserIdOffset);
|
||||||
|
NudgeEvent.addSenderName(builder, senderNameOffset);
|
||||||
return NudgeEvent.endNudgeEvent(builder);
|
return NudgeEvent.endNudgeEvent(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import type { BoardLabelMode } from './boardlabels';
|
|||||||
export interface Toast {
|
export interface Toast {
|
||||||
kind: 'error' | 'info';
|
kind: 'error' | 'info';
|
||||||
text: string;
|
text: string;
|
||||||
|
/** A monotonic id bumped on every showToast, so the Toast component re-keys and replays its
|
||||||
|
* entrance — the freshest toast cancels the previous one and starts its own appear cycle. */
|
||||||
|
seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const app = $state<{
|
export const app = $state<{
|
||||||
@@ -95,6 +98,7 @@ export const app = $state<{
|
|||||||
let unsubscribeStream: (() => void) | null = null;
|
let unsubscribeStream: (() => void) | null = null;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let toastSeq = 0;
|
||||||
|
|
||||||
// Background/foreground tracking, to silence the reconnect banner during a normal app
|
// Background/foreground tracking, to silence the reconnect banner during a normal app
|
||||||
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
|
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
|
||||||
@@ -135,7 +139,7 @@ function goForeground(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||||
app.toast = { kind, text };
|
app.toast = { kind, text, seq: ++toastSeq };
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => (app.toast = null), 4000);
|
toastTimer = setTimeout(() => (app.toast = null), 4000);
|
||||||
}
|
}
|
||||||
@@ -223,7 +227,9 @@ function openStream(): void {
|
|||||||
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
||||||
}
|
}
|
||||||
} else if (e.kind === 'nudge') {
|
} else if (e.kind === 'nudge') {
|
||||||
showToast(t('chat.nudge'), 'info');
|
// Name the nudger (their per-game seat name, carried on the event), so the toast reads
|
||||||
|
// "<opponent>: …" like the your-turn toast; fall back to the plain phrase when absent.
|
||||||
|
showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info');
|
||||||
} else if (e.kind === 'your_turn') {
|
} else if (e.kind === 'your_turn') {
|
||||||
// Name the player who moved just before this one (the previous seat in turn order), so the
|
// Name the player who moved just before this one (the previous seat in turn order), so the
|
||||||
// toast reads the same in games with any number of players. Fall back to the bare label when
|
// toast reads the same in games with any number of players. Fall back to the bare label when
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ describe('codec', () => {
|
|||||||
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
|
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes a nudge event carrying the sender name', () => {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const gid = b.createString('g1');
|
||||||
|
const from = b.createString('u2');
|
||||||
|
const name = b.createString('Ann');
|
||||||
|
fb.NudgeEvent.startNudgeEvent(b);
|
||||||
|
fb.NudgeEvent.addGameId(b, gid);
|
||||||
|
fb.NudgeEvent.addFromUserId(b, from);
|
||||||
|
fb.NudgeEvent.addSenderName(b, name);
|
||||||
|
b.finish(fb.NudgeEvent.endNudgeEvent(b));
|
||||||
|
expect(decodeEvent('nudge', b.asUint8Array())).toEqual({
|
||||||
|
kind: 'nudge',
|
||||||
|
gameId: 'g1',
|
||||||
|
fromUserId: 'u2',
|
||||||
|
senderName: 'Ann',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trips a feedback submit and decodes state + unread', () => {
|
it('round-trips a feedback submit and decodes state + unread', () => {
|
||||||
const att = new Uint8Array([1, 2, 3, 4]);
|
const att = new Uint8Array([1, 2, 3, 4]);
|
||||||
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
|
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
|
||||||
|
|||||||
+1
-1
@@ -537,7 +537,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
|||||||
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
|
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
|
||||||
case 'nudge': {
|
case 'nudge': {
|
||||||
const e = fb.NudgeEvent.getRootAsNudgeEvent(bb);
|
const e = fb.NudgeEvent.getRootAsNudgeEvent(bb);
|
||||||
return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()) };
|
return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()), senderName: s(e.senderName()) };
|
||||||
}
|
}
|
||||||
case 'match_found': {
|
case 'match_found': {
|
||||||
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ describe('i18n catalog', () => {
|
|||||||
expect(translate('en', 'game.yourTurnBy', { name: 'Ann' })).toBe('Ann: Your turn!');
|
expect(translate('en', 'game.yourTurnBy', { name: 'Ann' })).toBe('Ann: Your turn!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names the nudger in the cross-game nudge toast', () => {
|
||||||
|
expect(translate('ru', 'chat.nudgeBy', { name: 'Аня' })).toBe('Аня: Жду Вашего хода 🤭');
|
||||||
|
expect(translate('en', 'chat.nudgeBy', { name: 'Ann' })).toBe('Ann: Waiting for your move 🤭');
|
||||||
|
});
|
||||||
|
|
||||||
it('localizes move-history action labels', () => {
|
it('localizes move-history action labels', () => {
|
||||||
expect(translate('ru', 'move.exchange')).toBe('обмен');
|
expect(translate('ru', 'move.exchange')).toBe('обмен');
|
||||||
expect(translate('ru', 'move.pass')).toBe('пас');
|
expect(translate('ru', 'move.pass')).toBe('пас');
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ export const en = {
|
|||||||
|
|
||||||
'chat.placeholder': 'Quick message…',
|
'chat.placeholder': 'Quick message…',
|
||||||
'chat.send': 'Send',
|
'chat.send': 'Send',
|
||||||
'chat.nudge': 'Waiting for your move!',
|
'chat.nudge': 'Waiting for your move 🤭',
|
||||||
|
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
|
||||||
'chat.nudgeAction': 'Nudge',
|
'chat.nudgeAction': 'Nudge',
|
||||||
'chat.awaitingReply': "Waiting for the opponent's reply",
|
'chat.awaitingReply': "Waiting for the opponent's reply",
|
||||||
'chat.empty': 'No messages yet.',
|
'chat.empty': 'No messages yet.',
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
|
|
||||||
'chat.placeholder': 'Короткое сообщение…',
|
'chat.placeholder': 'Короткое сообщение…',
|
||||||
'chat.send': 'Отправить',
|
'chat.send': 'Отправить',
|
||||||
'chat.nudge': 'Жду вашего хода!',
|
'chat.nudge': 'Жду Вашего хода 🤭',
|
||||||
|
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
|
||||||
'chat.nudgeAction': 'Поторопить',
|
'chat.nudgeAction': 'Поторопить',
|
||||||
'chat.awaitingReply': 'Ждём реакцию соперника',
|
'chat.awaitingReply': 'Ждём реакцию соперника',
|
||||||
'chat.empty': 'Сообщений пока нет.',
|
'chat.empty': 'Сообщений пока нет.',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { groupGames, isMyTurn } from './lobbysort';
|
import { gamePhase, groupGames, isMyTurn, shouldBlink } from './lobbysort';
|
||||||
import type { GameView, Seat } from './model';
|
import type { GameView, Seat } from './model';
|
||||||
|
|
||||||
const ME = 'me';
|
const ME = 'me';
|
||||||
@@ -82,3 +82,28 @@ describe('groupGames', () => {
|
|||||||
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true);
|
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('gamePhase', () => {
|
||||||
|
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
|
||||||
|
expect(gamePhase(game('x', 'active', 0, 0), ME)).toBe('mine');
|
||||||
|
expect(gamePhase(game('x', 'active', 1, 0), ME)).toBe('theirs');
|
||||||
|
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine');
|
||||||
|
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs');
|
||||||
|
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldBlink', () => {
|
||||||
|
it('blinks on a transition into mine or finished', () => {
|
||||||
|
expect(shouldBlink('theirs', 'mine')).toBe(true);
|
||||||
|
expect(shouldBlink('mine', 'finished')).toBe(true);
|
||||||
|
expect(shouldBlink('theirs', 'finished')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays quiet on a transition into theirs, a no-op, or a first sighting', () => {
|
||||||
|
expect(shouldBlink('mine', 'theirs')).toBe(false);
|
||||||
|
expect(shouldBlink('mine', 'mine')).toBe(false);
|
||||||
|
expect(shouldBlink(undefined, 'mine')).toBe(false);
|
||||||
|
expect(shouldBlink(undefined, 'finished')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ export function isMyTurn(game: GameView, myId: string): boolean {
|
|||||||
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat;
|
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** LobbyPhase is a game's lobby bucket — which of the three card sections it currently sits in. */
|
||||||
|
export type LobbyPhase = 'mine' | 'theirs' | 'finished';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gamePhase reports a game's lobby bucket for myId: 'finished' for any non-active/open status,
|
||||||
|
* else 'mine' when it is the caller's turn, else 'theirs'. It mirrors groupGames' partition and
|
||||||
|
* drives the per-card blink, which fires when a card transitions into 'mine' or 'finished'.
|
||||||
|
*/
|
||||||
|
export function gamePhase(game: GameView, myId: string): LobbyPhase {
|
||||||
|
if (game.status !== 'active' && game.status !== 'open') return 'finished';
|
||||||
|
return isMyTurn(game, myId) ? 'mine' : 'theirs';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shouldBlink reports whether a card's bucket change should fire the attention blink: only a
|
||||||
|
* transition into 'mine' (it became your turn) or 'finished' (the game ended) does — an
|
||||||
|
* opponent's-turn change is in-place. A first observation (prev === undefined) never blinks, so a
|
||||||
|
* cold render and games arriving from another screen stay quiet.
|
||||||
|
*/
|
||||||
|
export function shouldBlink(prev: LobbyPhase | undefined, next: LobbyPhase): boolean {
|
||||||
|
return prev !== undefined && prev !== next && (next === 'mine' || next === 'finished');
|
||||||
|
}
|
||||||
|
|
||||||
/** LobbyGroups holds the three ordered lobby sections. */
|
/** LobbyGroups holds the three ordered lobby sections. */
|
||||||
export interface LobbyGroups {
|
export interface LobbyGroups {
|
||||||
yourTurn: GameView[];
|
yourTurn: GameView[];
|
||||||
|
|||||||
+1
-1
@@ -307,7 +307,7 @@ export type PushEvent =
|
|||||||
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
|
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
|
||||||
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
|
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
|
||||||
| { kind: 'chat_message'; message: ChatMessage }
|
| { kind: 'chat_message'; message: ChatMessage }
|
||||||
| { kind: 'nudge'; gameId: string; fromUserId: string }
|
| { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string }
|
||||||
| { kind: 'match_found'; gameId: string; state?: StateView }
|
| { kind: 'match_found'; gameId: string; state?: StateView }
|
||||||
| { kind: 'opponent_joined'; gameId: string; state?: StateView }
|
| { kind: 'opponent_joined'; gameId: string; state?: StateView }
|
||||||
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
|
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
|
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
import { resultBadge } from '../lib/result';
|
import { resultBadge } from '../lib/result';
|
||||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||||
import { preloadGames } from '../lib/preload';
|
import { preloadGames } from '../lib/preload';
|
||||||
import { groupGames } from '../lib/lobbysort';
|
import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
||||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||||
|
|
||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
@@ -54,12 +55,58 @@
|
|||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (app.lastEvent) void load();
|
// Refetch on a real game event only — not the 10 s keep-alive heartbeat. Refetching on every
|
||||||
|
// heartbeat turned the lobby into a 10 s poll whose REST view of a just-committed opponent move
|
||||||
|
// could update (and blink) a card seconds before the matching your_turn event/toast arrived
|
||||||
|
// over the slower live stream; gating it keeps the card, its blink and the toast on one event.
|
||||||
|
if (app.lastEvent && app.lastEvent.kind !== 'heartbeat') void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
const myId = $derived(app.session?.userId ?? '');
|
const myId = $derived(app.session?.userId ?? '');
|
||||||
const groups = $derived(groupGames(games, myId));
|
const groups = $derived(groupGames(games, myId));
|
||||||
|
|
||||||
|
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
|
||||||
|
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
|
||||||
|
// in-place — no blink). All blink state is keyed by game id, so overlapping events stay
|
||||||
|
// isolated: every card owns its own blink window and re-key counter, with no shared timer to
|
||||||
|
// race. The nonce re-keys the emoji so a repeat blink on the same card restarts the animation.
|
||||||
|
const blinkingIds = new SvelteSet<string>();
|
||||||
|
const blinkNonce = new SvelteMap<string, number>();
|
||||||
|
const blinkTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const prevPhase = new Map<string, LobbyPhase>();
|
||||||
|
|
||||||
|
function blink(id: string): void {
|
||||||
|
if (app.reduceMotion) return; // a fade-blink is exactly what reduce-motion asks us to drop
|
||||||
|
clearTimeout(blinkTimers.get(id));
|
||||||
|
blinkNonce.set(id, (blinkNonce.get(id) ?? 0) + 1);
|
||||||
|
blinkingIds.add(id);
|
||||||
|
blinkTimers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => {
|
||||||
|
blinkingIds.delete(id);
|
||||||
|
blinkTimers.delete(id);
|
||||||
|
}, 2000), // two 1 s fade cycles
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Diff each game's bucket against the last seen one and blink on a transition into
|
||||||
|
// mine/finished. A first observation seeds the baseline without blinking (see shouldBlink),
|
||||||
|
// so a cold render — or a card arriving from another screen — never blinks.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const g of games) {
|
||||||
|
seen.add(g.id);
|
||||||
|
const phase = gamePhase(g, myId);
|
||||||
|
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
|
||||||
|
prevPhase.set(g.id, phase);
|
||||||
|
}
|
||||||
|
for (const id of prevPhase.keys()) if (!seen.has(id)) prevPhase.delete(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const tmr of blinkTimers.values()) clearTimeout(tmr);
|
||||||
|
});
|
||||||
|
|
||||||
function opponents(g: GameView): string {
|
function opponents(g: GameView): string {
|
||||||
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
|
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
|
||||||
if (g.status === 'open') return t('game.searchingForOpponent');
|
if (g.status === 'open') return t('game.searchingForOpponent');
|
||||||
@@ -209,7 +256,10 @@
|
|||||||
<span class="who">{opponents(g) || '—'}</span>
|
<span class="who">{opponents(g) || '—'}</span>
|
||||||
<span class="sub">{scoreline(g)}</span>
|
<span class="sub">{scoreline(g)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="emoji">{resultBadge(g, myId).emoji}</span>
|
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
|
||||||
|
{#key blinkNonce.get(g.id) ?? 0}
|
||||||
|
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
||||||
|
{/key}
|
||||||
</button>
|
</button>
|
||||||
{#if group.finished}
|
{#if group.finished}
|
||||||
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
||||||
@@ -385,6 +435,21 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
/* A twice-over fade (two 1 s cycles) drawing the eye to a card that just became your turn or
|
||||||
|
finished. Gated in script by app.reduceMotion, so the class is never applied under
|
||||||
|
reduce-motion. */
|
||||||
|
.emoji.blink {
|
||||||
|
animation: lobby-blink 1s ease-in-out 2;
|
||||||
|
}
|
||||||
|
@keyframes lobby-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
.acts {
|
.acts {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user