feat: sparser robot nudges, typed unread badge, lobby unread bump #87

Merged
developer merged 1 commits from feature/robot-nudge-badge-sort into development 2026-06-19 18:36:34 +00:00
34 changed files with 331 additions and 86 deletions
Showing only changes of commit 6e77de4c1e - Show all commits
+20 -2
View File
@@ -16,7 +16,8 @@ import (
) )
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the // TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
// sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit. // sender), surfaces through HasUnread / UnreadGames and the message-level HasUnreadMessage /
// UnreadMessageGames (it is a real message, not a nudge), and that MarkRead clears the reader's bit.
func TestChatUnreadAndMarkRead(t *testing.T) { func TestChatUnreadAndMarkRead(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() svc := newSocialService()
@@ -37,6 +38,15 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
} else if !set[gameID] { } else if !set[gameID] {
t.Error("UnreadGames should include the game for the recipient") t.Error("UnreadGames should include the game for the recipient")
} }
// A text message also raises the message-level flags (it is not just a nudge).
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[1]); !msg {
t.Error("recipient should have the message flagged as an unread message")
}
if set, err := svc.UnreadMessageGames(ctx, seats[1]); err != nil {
t.Fatalf("unread message games: %v", err)
} else if !set[gameID] {
t.Error("UnreadMessageGames should include the game for a text message")
}
// Reading it clears the recipient's bit and reports one entry marked. // Reading it clears the recipient's bit and reports one entry marked.
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil { if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
@@ -55,7 +65,8 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the // TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge // awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
// targeting invariant, now observable through the unread bitmask. // targeting invariant, now observable through the unread bitmask. It also checks a nudge raises
// HasUnread but not the message-level flags, so the badge can colour it apart from a real message.
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) { func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() svc := newSocialService()
@@ -68,6 +79,13 @@ func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread { if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Error("the awaited seat should have the nudge unread") t.Error("the awaited seat should have the nudge unread")
} }
// A nudge is not a message: it must not raise the message-level flags.
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[0]); msg {
t.Error("a nudge must not be flagged as an unread message")
}
if set, _ := svc.UnreadMessageGames(ctx, seats[0]); set[gameID] {
t.Error("UnreadMessageGames must not include a game whose only unread entry is a nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread { if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
t.Error("a non-awaited waiting seat must not receive the nudge") t.Error("a non-awaited waiting seat must not receive the nudge")
} }
+11 -9
View File
@@ -181,8 +181,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
} }
} }
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the // TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed. // turn: nothing before the 9 h floor of the 9-12 h gap, exactly one once past its 12 h ceiling.
func TestRobotProactiveNudge(t *testing.T) { func TestRobotProactiveNudge(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newGameService() svc := newGameService()
@@ -206,18 +206,20 @@ func TestRobotProactiveNudge(t *testing.T) {
t.Fatalf("create: %v", err) t.Fatalf("create: %v", err)
} }
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No // A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00,
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling. // both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) // sleep window, decides the outcome. No nudge at 8 h idle (before the 9 h floor); exactly one
// at 24 h idle (well past the 12 h ceiling, however the seed drew within 9-12 h).
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
setTurnStarted(t, g.ID, start) setTurnStarted(t, g.ID, start)
robots.Drive(ctx, start.Add(30*time.Minute)) robots.Drive(ctx, start.Add(8*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 0 { if n := countNudges(t, g.ID, robotID); n != 0 {
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n) t.Errorf("robot nudges = %d at 8h idle, want 0 (before the 9h floor)", n)
} }
robots.Drive(ctx, start.Add(2*time.Hour)) robots.Drive(ctx, start.Add(24*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 1 { if n := countNudges(t, g.ID, robotID); n != 1 {
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n) t.Errorf("robot nudges = %d at 24h idle, want 1 (past the 12h ceiling)", n)
} }
} }
+6 -6
View File
@@ -152,12 +152,12 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U
return s.act(ctx, rt, now) return s.act(ctx, rt, now)
} }
// maybeNudge sends a proactive nudge on a lengthening, randomized schedule (proactiveNudgeGap): // maybeNudge sends a proactive nudge on a sparse, randomized schedule (proactiveNudgeGap): every
// the first lands ~60-90 min into the human's turn, and each one waits longer than the last, so a // nudge waits a uniform random 9-12 h, so a long idle turn gets only a handful of widely-spaced
// long idle turn gets a handful of increasingly-spaced reminders rather than an hourly stream. The // reminders rather than an hourly stream. The gap is measured from the previous nudge (or the turn
// gap is measured from the previous nudge (or the turn start for the first). The social service // start for the first). The caller's asleep gate already skips this during the robot's sleep
// still enforces the once-per-game floor and rejects a nudge on the robot's own turn, so any such // window, so a due nudge fires at the first scan after wake. The social service still enforces the
// rejection is benign. // once-per-game floor and rejects a nudge on the robot's own turn, so any such rejection is benign.
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error { func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
ref := rt.TurnStartedAt ref := rt.TurnStartedAt
if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil { if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil {
+13 -21
View File
@@ -79,16 +79,14 @@ const (
// sleep window relative to the opponent's timezone, in hours. // sleep window relative to the opponent's timezone, in hours.
sleepDriftHours = 3 sleepDriftHours = 3
// The robot proactively nudges the idle human on a lengthening, randomized schedule rather // The robot proactively nudges the idle human on a sparse, randomized schedule rather than an
// than an hourly stream: the first nudge lands ~60-90 min into the turn, and each subsequent // hourly stream: every nudge waits a uniform random 9-12 h after its reference point (the turn
// gap grows toward 1-6 h the longer the wait drags on, so a long idle turn gets only a handful // start for the first nudge, the previous nudge thereafter), so even a long-neglected turn
// of increasingly-spaced reminders. The gap is a uniform sample in [nudgeGapFloorMinutes, // collects only a few widely-spaced reminders. The 3 h window width is the random spread; the
// ceil] minutes, where ceil ramps from nudgeGapFirstCeilMinutes to nudgeGapCeilMinutes over // gap does not lengthen with idle time. The driver still skips a nudge that would land in the
// nudgeGapRamp of idle. // robot's sleep window, deferring it to the first scan after wake.
nudgeGapFloorMinutes = 60.0 nudgeGapLoHours = 9.0
nudgeGapFirstCeilMinutes = 90.0 nudgeGapHiHours = 12.0
nudgeGapCeilMinutes = 360.0
nudgeGapRamp = 12 * time.Hour
) )
// defaultBand is the target resulting score margin after the robot's move: when // defaultBand is the target resulting score margin after the robot's move: when
@@ -263,19 +261,13 @@ func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
// proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the // proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the
// human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn). // human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn).
// It is a uniform sample in [nudgeGapFloorMinutes, ceil] minutes, where ceil ramps from // It is a uniform sample in [nudgeGapLoHours, nudgeGapHiHours] hours, deterministic per
// nudgeGapFirstCeilMinutes (a ~60-90 min first gap) up to nudgeGapCeilMinutes (a 1-6 h gap) as // (seed, refIdle) so the driver computes the same due time on every scan. refIdle only salts the
// refIdle reaches nudgeGapRamp — so the reminders space out the longer the turn is neglected. It // draw, so each successive nudge of a still-idle turn waits a fresh 9-12 h rather than lengthening.
// is deterministic per (seed, refIdle), so the driver computes the same due time on every scan.
func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration { func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration {
f := float64(refIdle) / float64(nudgeGapRamp)
if f > 1 {
f = 1
}
ceil := nudgeGapFirstCeilMinutes + (nudgeGapCeilMinutes-nudgeGapFirstCeilMinutes)*f
u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute)))) u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute))))
mins := nudgeGapFloorMinutes + (ceil-nudgeGapFloorMinutes)*u hours := nudgeGapLoHours + (nudgeGapHiHours-nudgeGapLoHours)*u
return time.Duration(mins * float64(time.Minute)) return time.Duration(hours * float64(time.Hour))
} }
// clampMinutes converts a minute count to a duration, clamping it to the hard delay // clampMinutes converts a minute count to a duration, clamping it to the hard delay
+14 -6
View File
@@ -315,13 +315,13 @@ func TestDeviatesDistribution(t *testing.T) {
// as the idle grows (the median at 12 h idle exceeds the median at the start). // as the idle grows (the median at 12 h idle exceeds the median at the start).
func TestProactiveNudgeGap(t *testing.T) { func TestProactiveNudgeGap(t *testing.T) {
for seed := int64(1); seed <= 1000; seed++ { for seed := int64(1); seed <= 1000; seed++ {
if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute { if first := proactiveNudgeGap(0, seed); first < 9*time.Hour || first > 12*time.Hour {
t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed) t.Fatalf("first gap %s out of [9h,12h] for seed %d", first, seed)
} }
for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} { for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} {
g := proactiveNudgeGap(idle, seed) g := proactiveNudgeGap(idle, seed)
if g < 60*time.Minute || g > 6*time.Hour { if g < 9*time.Hour || g > 12*time.Hour {
t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle) t.Fatalf("gap %s out of [9h,12h] for seed %d idle %s", g, seed, idle)
} }
if proactiveNudgeGap(idle, seed) != g { if proactiveNudgeGap(idle, seed) != g {
t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle) t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
@@ -337,8 +337,16 @@ func TestProactiveNudgeGap(t *testing.T) {
sort.Float64s(xs) sort.Float64s(xs)
return xs[n/2] return xs[n/2]
} }
if early, late := median(0), median(12*time.Hour); early >= late { // The window is flat: the gap distribution does not lengthen with idle time, so the median
t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late) // stays near the band centre (10.5 h) and barely moves between a fresh turn and a long-idle one.
early, late := median(0), median(12*time.Hour)
for _, m := range []float64{early, late} {
if m < 9*60 || m > 12*60 {
t.Fatalf("median gap %.0f min out of [540,720]", m)
}
}
if diff := math.Abs(early - late); diff > 30 {
t.Errorf("median gap should not shift with idle (flat window): idle0=%.0f idle12h=%.0f", early, late)
} }
} }
+4
View File
@@ -112,6 +112,10 @@ type gameDTO struct {
// entry (message or nudge) in this game. It seeds the lobby and in-game unread badge. // entry (message or nudge) in this game. It seeds the lobby and in-game unread badge.
// gameDTOFromGame leaves it false (it has no viewer); the handlers fill it. // gameDTOFromGame leaves it false (it has no viewer); the handlers fill it.
UnreadChat bool `json:"unread_chat"` UnreadChat bool `json:"unread_chat"`
// UnreadMessages is a per-viewer flag: at least one of the unread entries is a real chat
// message (not just a nudge). It colours the badge — set with UnreadChat it is the regular
// badge, UnreadChat alone is the softer nudge-only badge. gameDTOFromGame leaves it false.
UnreadMessages bool `json:"unread_messages"`
} }
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as // moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
+12 -4
View File
@@ -443,16 +443,20 @@ func (s *Server) handleListGames(c *gin.Context) {
return return
} }
memo := map[string]string{} memo := map[string]string{}
// One query seeds the lobby's per-card unread badge for every listed game. // Two queries seed the lobby's per-card unread badge for every listed game: which games have
var unread map[uuid.UUID]bool // any unread entry, and which of those have a real message (so the badge can colour
// message-bearing games apart from nudge-only ones).
var unread, unreadMsg map[uuid.UUID]bool
if s.social != nil { if s.social != nil {
unread, _ = s.social.UnreadGames(c.Request.Context(), uid) unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
unreadMsg, _ = s.social.UnreadMessageGames(c.Request.Context(), uid)
} }
out := make([]gameDTO, 0, len(games)) out := make([]gameDTO, 0, len(games))
for _, g := range games { for _, g := range games {
dto := gameDTOFromGame(g) dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo) s.fillSeatNames(c.Request.Context(), &dto, memo)
dto.UnreadChat = unread[g.ID] dto.UnreadChat = unread[g.ID]
dto.UnreadMessages = unreadMsg[g.ID]
out = append(out, dto) out = append(out, dto)
} }
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit}) c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
@@ -536,8 +540,9 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
c.JSON(http.StatusOK, dto) c.JSON(http.StatusOK, dto)
} }
// setUnreadChat fills the per-game unread-chat flag for viewer uid on one game's DTO, // setUnreadChat fills the per-game unread-chat flags for viewer uid on one game's DTO, when the
// when the social domain is present. It is best-effort: a lookup error leaves the flag // social domain is present: UnreadChat for any unread entry and UnreadMessages when one of them is
// a real message (so the badge can be coloured). It is best-effort: a lookup error leaves the flag
// false (a missing badge) rather than failing the surrounding game response. // false (a missing badge) rather than failing the surrounding game response.
func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) { func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
if s.social == nil { if s.social == nil {
@@ -550,4 +555,7 @@ func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil { if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil {
g.UnreadChat = unread g.UnreadChat = unread
} }
if msg, err := s.social.HasUnreadMessage(ctx, gid, uid); err == nil {
g.UnreadMessages = msg
}
} }
+52
View File
@@ -67,6 +67,21 @@ func (svc *Service) HasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (
return svc.store.hasUnread(ctx, gameID, viewerID) return svc.store.hasUnread(ctx, gameID, viewerID)
} }
// UnreadMessageGames returns the subset of UnreadGames whose unread entries include a real chat
// message (a 'message' kind, not a nudge), for colouring the lobby's per-card unread badge: a game
// present here has an unread message (the regular badge), one only in UnreadGames has nudges alone
// (the soft nudge badge).
func (svc *Service) UnreadMessageGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
return svc.store.unreadMessageGames(ctx, viewerID)
}
// HasUnreadMessage reports whether viewerID has an unread chat message (a 'message' kind, not a
// nudge) in the game, for distinguishing a message badge from a nudge-only one on a single game's
// state and move-result views.
func (svc *Service) HasUnreadMessage(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
return svc.store.hasUnreadMessage(ctx, gameID, viewerID)
}
// readMark is one chat entry that flipped from unread to read, carrying what the // readMark is one chat entry that flipped from unread to read, carrying what the
// publish-to-read latency metric needs. // publish-to-read latency metric needs.
type readMark struct { type readMark struct {
@@ -161,6 +176,43 @@ func (s *Store) hasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool
return ok, nil return ok, nil
} }
// unreadMessageGames is unreadGames restricted to real chat messages (kind 'message', not a
// nudge), so the caller can tell a message badge from a nudge-only one in one extra query.
func (s *Store) unreadMessageGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
const q = `SELECT DISTINCT m.game_id
FROM backend.chat_messages m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $1
WHERE m.kind = 'message' AND m.unread_seats <> 0 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
rows, err := s.db.QueryContext(ctx, q, viewerID)
if err != nil {
return nil, fmt.Errorf("social: unread message games: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]bool)
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("social: scan unread message games: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// hasUnreadMessage reports whether viewerID has an unread real chat message (kind 'message', not
// a nudge) in the one game.
func (s *Store) hasUnreadMessage(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
const q = `SELECT EXISTS(
SELECT 1 FROM backend.chat_messages m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $2
WHERE m.game_id = $1 AND m.kind = 'message' AND (m.unread_seats::int & (1 << p.seat::int)) <> 0)`
var ok bool
if err := s.db.QueryRowContext(ctx, q, gameID, viewerID).Scan(&ok); err != nil {
return false, fmt.Errorf("social: has unread message: %w", err)
}
return ok, nil
}
// countUnread counts the chat entries with at least one recipient seat still unread, // countUnread counts the chat entries with at least one recipient seat still unread,
// for the chat_unread_messages gauge. // for the chat_unread_messages gauge.
func (s *Store) countUnread(ctx context.Context) (int64, error) { func (s *Store) countUnread(ctx context.Context) (int64, error) {
+13 -5
View File
@@ -446,9 +446,10 @@ disguised robot stays indistinguishable from a person.
**sleeps 00:0007:00** anchored to the **opponent's** profile timezone with a **sleeps 00:0007:00** anchored to the **opponent's** profile timezone with a
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
rather than running anti-phase; on a daytime nudge it replies near the move's lower rather than running anti-phase; on a daytime nudge it replies near the move's lower
band; it proactively nudges the idle human on a **lengthening, randomized schedule**the band; it proactively nudges the idle human on a **sparse, randomized schedule**every nudge
first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long waits a uniform random **9-12 h** (the first measured from the turn start, each later one from
wait gets a handful of increasingly-spaced nudges rather than an hourly stream. the previous nudge), so a neglected turn gets only a handful of widely-spaced reminders. A nudge
that would fall inside the sleep window is skipped and fires at the first scan after wake.
- **Dead-endgame timing**: once the **two most recent moves are both passes**, the board and the - **Dead-endgame timing**: once the **two most recent moves are both passes**, the board and the
robot's rack are frozen and it is bound to pass again, so the robot drops the long late-game robot's rack are frozen and it is bound to pass again, so the robot drops the long late-game
think time and answers on a **shortened schedule scaled to the human's own last (pass) think think time and answers on a **shortened schedule scaled to the human's own last (pass) think
@@ -570,8 +571,15 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is
inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer
`unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin `unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin
unread filter and the unread gauge all use. On each clear the publish-to-read latency is unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`**
recorded; the read time itself is not retained. (`unread_seats <> 0 AND kind = 'message'`), reports whether any unread entry is a real
message rather than only a nudge, so the **dot is coloured**: the regular danger colour
when a message is unread, a softer amber when only nudges are. Both flags share the
REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge
event raises only `unread_chat`, a message event raises both). The lobby additionally
**floats games with any unread entry to the top** of the your-turn and opponent-turn
sections (the finished section keeps its activity order). On each clear the publish-to-read
latency is recorded; the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email - **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window** and the (confirm-code binding, see §4), **timezone**, the daily **away window** and the
block toggles — all editable through `account.UpdateProfile`, which validates them: block toggles — all editable through `account.UpdateProfile`, which validates them:
+8 -5
View File
@@ -74,8 +74,9 @@ The lobby lists **my games** and offers a bottom tab bar — new game, statistic
**my games** list groups games into three **my games** list groups games into three
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; within the your-turn and
line-separated list. While the lobby is open and a listed game **becomes your turn or opponent-turn sections a game with any unread notification floats to the top (the finished
section keeps its order). It renders as a compact, 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 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**: 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.
@@ -161,7 +162,8 @@ times for most moves, the occasional long one, and a night-time pause that track
player's own day; once a game is clearly decided and both sides are only passing, it stops player's own day; once a game is clearly decided and both sides are only passing, it stops
dragging it out, answering its forced passes at roughly the player's own pace rather than dragging it out, answering its forced passes at roughly the player's own pace rather than
after a long deliberation. It answers a nudge after a long deliberation. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It within a few minutes and, when the player keeps it waiting, nudges back on a sparse
schedule — roughly every 9-12 hours, and never during its night-time pause. It
carries a human-like, language-appropriate name — a fresh one each game, drawn from a wide carries a human-like, language-appropriate name — a fresh one each game, drawn from a wide
international pool of real names and handles, so the arena feels populated by many different international pool of real names and handles, so the arena feels populated by many different
players (a Russian game shows mostly Russian names, never East-Asian scripts; an international players (a Russian game shows mostly Russian names, never East-Asian scripts; an international
@@ -216,8 +218,9 @@ Chat and the word-check tool share one **comms screen** with **💬 chat** / **
tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no
chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the
dictionary closed) the 💬 entry disappears entirely. An unread chat dictionary closed) the 💬 entry disappears entirely. An unread chat
entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game entry — a message **or a nudge** from an opponent — raises a small **dot** next to the game
in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a
softer amber when only nudges are**. Opening the **move history** counts as reading
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
also clears the moment its recipient **takes their move**. also clears the moment its recipient **takes their move**.
+9 -6
View File
@@ -75,8 +75,9 @@ nudge) приходят от бота **этой партии** — по язы
Список **мои игры** разбит на три секции — Список **мои игры** разбит на три секции —
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с соперника и завершённые — самые свежие сверху; внутри секций «твой ход» и «ход соперника»
линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или игра с любым непрочитанным уведомлением всплывает наверх (секция «завершённые» сохраняет
свой порядок). Отображается компактным списком с линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или
завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**: соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
@@ -166,8 +167,9 @@ nudge) приходят от бота **этой партии** — по язы
человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза, человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза,
подстроенная под день игрока; когда партия уже явно решена и обе стороны только пасуют, подстроенная под день игрока; когда партия уже явно решена и обе стороны только пасуют,
он перестаёт её затягивать — отвечает на вынужденные пасы примерно в темпе самого игрока, он перестаёт её затягивать — отвечает на вынужденные пасы примерно в темпе самого игрока,
а не после долгого раздумья. На nudge отвечает за несколько минут и а не после долгого раздумья. На nudge отвечает за несколько минут и, когда игрок
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее держит его в ожидании, сам шлёт nudge редко — примерно раз в 9-12 часов и никогда во время
своей ночной паузы. Носит человекоподобное имя, подходящее
языку партии — каждую партию новое, из широкого международного пула реальных имён и языку партии — каждую партию новое, из широкого международного пула реальных имён и
никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном
русские имена, без восточноазиатских письменностей; в международной — весь спектр); не общается в чате и русские имена, без восточноазиатских письменностей; в международной — весь спектр); не общается в чате и
@@ -222,8 +224,9 @@ nudge) приходят от бота **этой партии** — по язы
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её
экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход
💬 исчезает. Непрочитанная запись чата — 💬 исчезает. Непрочитанная запись чата —
сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в сообщение **или nudge** от соперника — зажигает маленькую **точку** рядом с партией в
**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже **лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
его получатель **делает ход**. его получатель **делает ход**.
+1
View File
@@ -135,6 +135,7 @@ type GameResp struct {
LastActivityUnix int64 `json:"last_activity_unix"` LastActivityUnix int64 `json:"last_activity_unix"`
Seats []SeatResp `json:"seats"` Seats []SeatResp `json:"seats"`
UnreadChat bool `json:"unread_chat"` UnreadChat bool `json:"unread_chat"`
UnreadMessages bool `json:"unread_messages"`
} }
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
+1
View File
@@ -441,6 +441,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
Seats: seats, Seats: seats,
LastActivityUnix: g.LastActivityUnix, LastActivityUnix: g.LastActivityUnix,
UnreadChat: g.UnreadChat, UnreadChat: g.UnreadChat,
UnreadMessages: g.UnreadMessages,
} }
} }
+4 -1
View File
@@ -254,7 +254,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if r.URL.Path != "/api/v1/user/games" { if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path) t.Errorf("unexpected path %q", r.URL.Path)
} }
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`)) _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"unread_messages":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`))
}) })
defer cleanup() defer cleanup()
@@ -279,6 +279,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if !g.UnreadChat() { if !g.UnreadChat() {
t.Error("unread_chat = false, want true (the backend flagged unread for the viewer)") t.Error("unread_chat = false, want true (the backend flagged unread for the viewer)")
} }
if !g.UnreadMessages() {
t.Error("unread_messages = false, want true (the backend flagged an unread message)")
}
var seat fb.SeatView var seat fb.SeatView
g.Seats(&seat, 1) g.Seats(&seat, 1)
if string(seat.DisplayName()) != "Ann" { if string(seat.DisplayName()) != "Ann" {
+4
View File
@@ -76,6 +76,10 @@ table GameView {
// unread_chat is a per-viewer flag: the requesting player has at least one unread chat // unread_chat is a per-viewer flag: the requesting player has at least one unread chat
// entry (message or nudge) in this game. It drives the lobby and in-game unread badge. // entry (message or nudge) in this game. It drives the lobby and in-game unread badge.
unread_chat:bool; unread_chat:bool;
// unread_messages is a per-viewer flag: at least one of the unread entries is a real chat
// message (not just a nudge). With unread_chat it colours the badge — both set is the regular
// badge, unread_chat alone is the softer nudge-only badge.
unread_messages:bool;
} }
// MoveRecord is one decoded move (a committed play, or a hint preview). // MoveRecord is one decoded move (a committed play, or a hint preview).
+16 -1
View File
@@ -197,8 +197,20 @@ func (rcv *GameView) MutateUnreadChat(n bool) bool {
return rcv._tab.MutateBoolSlot(30, n) return rcv._tab.MutateBoolSlot(30, n)
} }
func (rcv *GameView) UnreadMessages() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(32))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *GameView) MutateUnreadMessages(n bool) bool {
return rcv._tab.MutateBoolSlot(32, n)
}
func GameViewStart(builder *flatbuffers.Builder) { func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(14) builder.StartObject(15)
} }
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
@@ -245,6 +257,9 @@ func GameViewAddVsAi(builder *flatbuffers.Builder, vsAi bool) {
func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) { func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) {
builder.PrependBoolSlot(13, unreadChat, false) builder.PrependBoolSlot(13, unreadChat, false)
} }
func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) {
builder.PrependBoolSlot(14, unreadMessages, false)
}
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+4
View File
@@ -46,6 +46,9 @@ type GameView struct {
VsAI bool VsAI bool
// UnreadChat is a per-viewer flag: the requesting player has unread chat in this game. // UnreadChat is a per-viewer flag: the requesting player has unread chat in this game.
UnreadChat bool UnreadChat bool
// UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just
// a nudge), so the badge can be coloured apart from the nudge-only case.
UnreadMessages bool
} }
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank // TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
@@ -165,6 +168,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
fb.GameViewAddVsAi(b, g.VsAI) fb.GameViewAddVsAi(b, g.VsAI)
fb.GameViewAddUnreadChat(b, g.UnreadChat) fb.GameViewAddUnreadChat(b, g.UnreadChat)
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
return fb.GameViewEnd(b) return fb.GameViewEnd(b)
} }
+10 -4
View File
@@ -14,6 +14,7 @@
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board'; import { lastMoveCells, replay } from '../lib/board';
import { badgeKind } from '../lib/unread';
import { historyGrid } from '../lib/history'; import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants'; import { variantNameKey } from '../lib/variants';
@@ -183,7 +184,7 @@
view = st; view = st;
syncWallet(st.walletBalance); syncWallet(st.walletBalance);
// Seed the unread flag from the authoritative state (the live stream only raises it). // Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat); seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
moves = hist.moves; moves = hist.moves;
setCachedGame(id, st, hist.moves, draft); setCachedGame(id, st, hist.moves, draft);
// Mirror the fresh status into the lobby snapshot so returning there shows it without a // Mirror the fresh status into the lobby snapshot so returning there shows it without a
@@ -670,7 +671,7 @@
}; };
// The move result is an authoritative per-viewer view: a nudge the actor just answered by // The move result is an authoritative per-viewer view: a nudge the actor just answered by
// moving is already cleared server-side, so reconcile the unread flag from it. // moving is already cleared server-side, so reconcile the unread flag from it.
seedChatUnread(id, r.game.unreadChat); seedChatUnread(id, r.game.unreadChat, r.game.unreadMessages);
moves = [...moves, r.move]; moves = [...moves, r.move];
// A committed move clears the actor's draft on the server, so clear the cached draft too; // A committed move clears the actor's draft on the server, so clear the cached draft too;
// otherwise a same-session re-entry would briefly re-apply the now-stale composition. // otherwise a same-session re-entry would briefly re-apply the now-stale composition.
@@ -1091,10 +1092,11 @@
above so the markup and logic stay single-sourced (see the {#if landscape} split). --> above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet scoreboardBlock()} {#snippet scoreboardBlock()}
{#if view} {#if view}
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}> <div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if} {#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div> <div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
@@ -1582,13 +1584,17 @@
color: var(--danger); color: var(--danger);
} }
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles) /* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
when the history is opened with unread present, rather than carrying a count badge. */ when the history is opened with unread present, rather than carrying a count badge. Red for an
unread message, a softer amber when only nudges are unread. */
.unread-dot { .unread-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--danger); background: var(--danger);
} }
.unread-dot.nudge {
background: var(--warn);
}
.sbadge-dot { .sbadge-dot {
position: absolute; position: absolute;
top: 4px; top: 4px;
+12 -2
View File
@@ -108,8 +108,13 @@ unreadChat():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
} }
unreadMessages():boolean {
const offset = this.bb!.__offset(this.bb_pos, 32);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) { static startGameView(builder:flatbuffers.Builder) {
builder.startObject(14); builder.startObject(15);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -180,12 +185,16 @@ static addUnreadChat(builder:flatbuffers.Builder, unreadChat:boolean) {
builder.addFieldInt8(13, +unreadChat, +false); builder.addFieldInt8(13, +unreadChat, +false);
} }
static addUnreadMessages(builder:flatbuffers.Builder, unreadMessages:boolean) {
builder.addFieldInt8(14, +unreadMessages, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean):flatbuffers.Offset { static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean):flatbuffers.Offset {
GameView.startGameView(builder); GameView.startGameView(builder);
GameView.addId(builder, idOffset); GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset); GameView.addVariant(builder, variantOffset);
@@ -201,6 +210,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addLastActivityUnix(builder, lastActivityUnix); GameView.addLastActivityUnix(builder, lastActivityUnix);
GameView.addVsAi(builder, vsAi); GameView.addVsAi(builder, vsAi);
GameView.addUnreadChat(builder, unreadChat); GameView.addUnreadChat(builder, unreadChat);
GameView.addUnreadMessages(builder, unreadMessages);
return GameView.endGameView(builder); return GameView.endGameView(builder);
} }
} }
+19 -4
View File
@@ -63,6 +63,10 @@ export const app = $state<{
* game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and * game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and
* raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */ * raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */
chatUnread: Record<string, boolean>; chatUnread: Record<string, boolean>;
/** Per-game flag: at least one unread entry is a real message (not just a nudge), so the dot
* can be coloured apart from the nudge-only case. Same lifecycle as chatUnread; nudge events
* raise only chatUnread, message events raise both. */
messageUnread: Record<string, boolean>;
/** Whether an operator feedback reply awaits the player, for the lobby badge (combined /** Whether an operator feedback reply awaits the player, for the lobby badge (combined
* with friend requests) and the Settings Info badge. */ * with friend requests) and the Settings Info badge. */
feedbackReplyUnread: boolean; feedbackReplyUnread: boolean;
@@ -93,6 +97,7 @@ export const app = $state<{
localeLocked: false, localeLocked: false,
notifications: 0, notifications: 0,
chatUnread: {}, chatUnread: {},
messageUnread: {},
feedbackReplyUnread: false, feedbackReplyUnread: false,
staleInvite: false, staleInvite: false,
welcomeRedeem: false, welcomeRedeem: false,
@@ -170,14 +175,18 @@ export function dismissWelcomeRedeem(): void {
} }
/** /**
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby * seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live * list, a game's state, or a move result): unread is any unread entry, message whether one of them
* stream raises unread (bumpChatUnread) rather than seeding it from a GameView. * is a real message (the badge colour). The live-event GameView omits both, so the live stream
* raises them on receive rather than seeding from a GameView.
*/ */
export function seedChatUnread(gameId: string, unread: boolean): void { export function seedChatUnread(gameId: string, unread: boolean, message: boolean): void {
if ((app.chatUnread[gameId] ?? false) !== unread) { if ((app.chatUnread[gameId] ?? false) !== unread) {
app.chatUnread = { ...app.chatUnread, [gameId]: unread }; app.chatUnread = { ...app.chatUnread, [gameId]: unread };
} }
if ((app.messageUnread[gameId] ?? false) !== message) {
app.messageUnread = { ...app.messageUnread, [gameId]: message };
}
} }
/** /**
@@ -192,6 +201,7 @@ export function seedChatUnread(gameId: string, unread: boolean): void {
export function markChatRead(gameId: string): void { export function markChatRead(gameId: string): void {
if (!app.chatUnread[gameId]) return; if (!app.chatUnread[gameId]) return;
app.chatUnread = { ...app.chatUnread, [gameId]: false }; app.chatUnread = { ...app.chatUnread, [gameId]: false };
if (app.messageUnread[gameId]) app.messageUnread = { ...app.messageUnread, [gameId]: false };
void gateway.markChatRead(gameId).catch(() => {}); void gateway.markChatRead(gameId).catch(() => {});
} }
@@ -262,6 +272,11 @@ function openStream(): void {
router.route.params.id === e.message.gameId; router.route.params.id === e.message.gameId;
if (!inComms) { if (!inComms) {
app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true }; app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true };
// Only a real message colours the badge; a nudge delivered as a chat_message must not
// raise the message flag (the separate nudge event below also leaves it untouched).
if (e.message.kind !== 'nudge') {
app.messageUnread = { ...app.messageUnread, [e.message.gameId]: true };
}
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') {
+2
View File
@@ -253,6 +253,7 @@ function decodeGameView(g: fb.GameView): GameView {
lastActivityUnix: Number(g.lastActivityUnix()), lastActivityUnix: Number(g.lastActivityUnix()),
vsAi: g.vsAi(), vsAi: g.vsAi(),
unreadChat: g.unreadChat(), unreadChat: g.unreadChat(),
unreadMessages: g.unreadMessages(),
seats, seats,
}; };
} }
@@ -849,6 +850,7 @@ function emptyGame(): GameView {
lastActivityUnix: 0, lastActivityUnix: 0,
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
seats: [], seats: [],
}; };
} }
+1
View File
@@ -9,6 +9,7 @@ function gameView(id: string): GameView {
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status: 'active', status: 'active',
players: 2, players: 2,
toMove: 0, toMove: 0,
+1
View File
@@ -10,6 +10,7 @@ function gameView(moveCount: number, over = false): GameView {
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status: over ? 'finished' : 'active', status: over ? 'finished' : 'active',
players: 2, players: 2,
toMove: 1, toMove: 1,
+1
View File
@@ -26,6 +26,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status, status,
players: 2, players: 2,
toMove, toMove,
+24
View File
@@ -19,6 +19,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status, status,
players: 2, players: 2,
toMove, toMove,
@@ -40,6 +41,7 @@ describe('groupGames', () => {
game('c', 'finished', 0, 100), game('c', 'finished', 0, 100),
], ],
ME, ME,
{},
); );
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']); expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']); expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
@@ -57,12 +59,33 @@ describe('groupGames', () => {
game('f_old', 'finished', 0, 100), game('f_old', 'finished', 0, 100),
], ],
ME, ME,
{},
); );
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']); expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']); expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']); expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
}); });
it('bumps unread games first in the active sections, leaving finished by activity', () => {
const g = groupGames(
[
game('y_read', 'active', 0, 50), // my turn, oldest, read
game('y_unread', 'active', 0, 200), // my turn, newest, unread -> bumped above y_read
game('t_read', 'active', 1, 200), // their turn, newest, read
game('t_unread', 'active', 1, 50), // their turn, oldest, unread -> bumped above t_read
game('f_unread', 'finished', 0, 100),
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
],
ME,
{ y_unread: true, t_unread: true, f_unread: true },
);
// Unread is the primary key, so it overrides the within-section activity order.
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_unread', 'y_read']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_unread', 't_read']);
// Finished ignores unread: still newest-first by activity.
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_unread']);
});
it('isMyTurn is false for a finished game even at my seat', () => { it('isMyTurn is false for a finished game even at my seat', () => {
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false); expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
@@ -76,6 +99,7 @@ describe('groupGames', () => {
game('open_wait', 'open', 1, 100), // the empty seat's turn game('open_wait', 'open', 1, 100), // the empty seat's turn
], ],
ME, ME,
{},
); );
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']); expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']); expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
+13 -6
View File
@@ -43,11 +43,16 @@ export interface LobbyGroups {
} }
/** /**
* groupGames partitions games for myId into the three lobby sections and orders each: the * groupGames partitions games for myId into the three lobby sections and orders each. In the two
* your-turn games by ascending last activity (the longest-waiting first), the opponent-turn * active sections a game with an unread notification (unread[g.id], any chat entry or nudge) sorts
* and finished games by descending last activity (the most recent first). * first, then by last activity: your-turn ascending (the longest-waiting first), opponent-turn
* descending (the most recent first). Finished games ignore unread and stay descending by activity.
*/ */
export function groupGames(games: GameView[], myId: string): LobbyGroups { export function groupGames(
games: GameView[],
myId: string,
unread: Record<string, boolean>,
): LobbyGroups {
const yourTurn: GameView[] = []; const yourTurn: GameView[] = [];
const theirTurn: GameView[] = []; const theirTurn: GameView[] = [];
const finished: GameView[] = []; const finished: GameView[] = [];
@@ -56,8 +61,10 @@ export function groupGames(games: GameView[], myId: string): LobbyGroups {
else if (isMyTurn(g, myId)) yourTurn.push(g); else if (isMyTurn(g, myId)) yourTurn.push(g);
else theirTurn.push(g); else theirTurn.push(g);
} }
yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix); // Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); const rank = (g: GameView) => (unread[g.id] ? 1 : 0);
yourTurn.sort((a, b) => rank(b) - rank(a) || a.lastActivityUnix - b.lastActivityUnix);
theirTurn.sort((a, b) => rank(b) - rank(a) || b.lastActivityUnix - a.lastActivityUnix);
finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
return { yourTurn, theirTurn, finished }; return { yourTurn, theirTurn, finished };
} }
+2
View File
@@ -182,6 +182,7 @@ export class MockGateway implements GatewayClient {
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: true, vsAi: true,
unreadChat: false, unreadChat: false,
unreadMessages: false,
seats: [ seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
@@ -214,6 +215,7 @@ export class MockGateway implements GatewayClient {
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
seats: [ seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
+3
View File
@@ -181,6 +181,7 @@ function activeGame(): MockGame {
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status: 'active', status: 'active',
players: 2, players: 2,
toMove: 0, toMove: 0,
@@ -218,6 +219,7 @@ function finishedG2(): MockGame {
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status: 'finished', status: 'finished',
players: 2, players: 2,
toMove: 0, toMove: 0,
@@ -256,6 +258,7 @@ function finishedG3(): MockGame {
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status: 'finished', status: 'finished',
players: 2, players: 2,
toMove: 0, toMove: 0,
+4
View File
@@ -51,6 +51,10 @@ export interface GameView {
* nudge) in this game. Set on the authoritative REST views (lobby list, game state, * nudge) in this game. Set on the authoritative REST views (lobby list, game state,
* move result); the live-event GameView leaves it false (events bump unread instead). */ * move result); the live-event GameView leaves it false (events bump unread instead). */
unreadChat: boolean; unreadChat: boolean;
/** Per-viewer flag: at least one unread entry is a real message (not just a nudge), so the
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
* unreadChat; the live-event GameView leaves it false. */
unreadMessages: boolean;
seats: Seat[]; seats: Seat[];
} }
+1
View File
@@ -21,6 +21,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status, status,
players: 2, players: 2,
toMove: 0, toMove: 0,
+1
View File
@@ -18,6 +18,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false, vsAi: false,
unreadChat: false, unreadChat: false,
unreadMessages: false,
status, status,
players: seats.length, players: seats.length,
toMove, toMove,
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { badgeKind } from './unread';
describe('badgeKind', () => {
it('shows nothing when nothing is unread', () => {
expect(badgeKind(false, false)).toBeNull();
// message without unread is inconsistent input; the unread guard still wins.
expect(badgeKind(false, true)).toBeNull();
});
it('is a nudge badge when only nudges are unread', () => {
expect(badgeKind(true, false)).toBe('nudge');
});
it('is a message badge when a real message is unread', () => {
expect(badgeKind(true, true)).toBe('message');
});
});
+17
View File
@@ -0,0 +1,17 @@
// Pure resolution of the per-game unread dot's kind, kept out of the .svelte components so it can
// be unit-tested. A game's dot is shown when anything is unread; its colour distinguishes a real
// chat message (the regular danger colour) from nudge-only unread (a softer nudge colour).
/** BadgeKind is the unread dot to show for a game, or null when nothing is unread. */
export type BadgeKind = 'message' | 'nudge' | null;
/**
* badgeKind picks the unread dot for a game from its two per-viewer flags: null when nothing is
* unread, 'message' when an unread real chat message is present (the regular colour), else 'nudge'
* when only nudges are unread (the softer colour). message implies unread, so a stray message=true
* with unread=false still shows nothing.
*/
export function badgeKind(unread: boolean, message: boolean): BadgeKind {
if (!unread) return null;
return message ? 'message' : 'nudge';
}
+10 -4
View File
@@ -9,6 +9,7 @@
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result'; import { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
@@ -35,7 +36,7 @@
games = list.games; games = list.games;
// Seed the per-game unread badges from the authoritative list. The live stream only raises // Seed the per-game unread badges from the authoritative list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here. // unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat); for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
atGameLimit = list.atGameLimit; atGameLimit = list.atGameLimit;
if (!guest) { if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
@@ -75,7 +76,7 @@
}); });
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId)); const groups = $derived(groupGames(games, myId, app.chatUnread));
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished" // 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 // while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
@@ -253,6 +254,7 @@
<h2>{t(group.h as 'lobby.yourTurn')}</h2> <h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list"> <div class="list">
{#each group.list as g (g.id)} {#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}> <div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished} {#if group.finished}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button> <button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
@@ -267,7 +269,7 @@
<span class="info"> <span class="info">
<span class="who"> <span class="who">
<span class="who-name">{opponents(g) || '—'}</span> <span class="who-name">{opponents(g) || '—'}</span>
{#if app.chatUnread[g.id]}<span class="unread-dot"></span>{/if} {#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span> </span>
<span class="sub">{scoreline(g)}</span> <span class="sub">{scoreline(g)}</span>
</span> </span>
@@ -458,7 +460,8 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* A small red dot beside the opponent name: this game has an unread chat entry. */ /* A small dot beside the opponent name: this game has an unread chat entry. Red for an unread
message, a softer amber when only nudges are unread. */
.unread-dot { .unread-dot {
flex: 0 0 auto; flex: 0 0 auto;
width: 8px; width: 8px;
@@ -466,6 +469,9 @@
border-radius: 50%; border-radius: 50%;
background: var(--danger); background: var(--danger);
} }
.unread-dot.nudge {
background: var(--warn);
}
.sub { .sub {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);