diff --git a/backend/internal/inttest/chatread_test.go b/backend/internal/inttest/chatread_test.go index d8fa7ad..3b69423 100644 --- a/backend/internal/inttest/chatread_test.go +++ b/backend/internal/inttest/chatread_test.go @@ -16,7 +16,8 @@ import ( ) // 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) { ctx := context.Background() svc := newSocialService() @@ -37,6 +38,15 @@ func TestChatUnreadAndMarkRead(t *testing.T) { } else if !set[gameID] { 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. 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 // 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) { ctx := context.Background() svc := newSocialService() @@ -68,6 +79,13 @@ func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) { if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !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 { t.Error("a non-awaited waiting seat must not receive the nudge") } diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 5e04982..0a5fda0 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -181,8 +181,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { } } -// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the -// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed. +// TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's +// 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) { ctx := context.Background() svc := newGameService() @@ -206,18 +206,20 @@ func TestRobotProactiveNudge(t *testing.T) { t.Fatalf("create: %v", err) } - // A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No - // nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling. - start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + // A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00, + // both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the + // 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) - 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 { - 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 { - 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) } } diff --git a/backend/internal/robot/driver.go b/backend/internal/robot/driver.go index aa2b33b..b9c4825 100644 --- a/backend/internal/robot/driver.go +++ b/backend/internal/robot/driver.go @@ -152,12 +152,12 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U return s.act(ctx, rt, now) } -// maybeNudge sends a proactive nudge on a lengthening, randomized schedule (proactiveNudgeGap): -// the first lands ~60-90 min into the human's turn, and each one waits longer than the last, so a -// long idle turn gets a handful of increasingly-spaced reminders rather than an hourly stream. The -// gap is measured from the previous nudge (or the turn start for the first). The social service -// still enforces the once-per-game floor and rejects a nudge on the robot's own turn, so any such -// rejection is benign. +// maybeNudge sends a proactive nudge on a sparse, randomized schedule (proactiveNudgeGap): every +// nudge waits a uniform random 9-12 h, so a long idle turn gets only a handful of widely-spaced +// reminders rather than an hourly stream. The gap is measured from the previous nudge (or the turn +// start for the first). The caller's asleep gate already skips this during the robot's sleep +// window, so a due nudge fires at the first scan after wake. The social service still enforces the +// 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 { ref := rt.TurnStartedAt if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil { diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index 07afa93..1afd8dc 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -79,16 +79,14 @@ const ( // sleep window relative to the opponent's timezone, in hours. sleepDriftHours = 3 - // The robot proactively nudges the idle human on a lengthening, randomized schedule rather - // than an hourly stream: the first nudge lands ~60-90 min into the turn, and each subsequent - // gap grows toward 1-6 h the longer the wait drags on, so a long idle turn gets only a handful - // of increasingly-spaced reminders. The gap is a uniform sample in [nudgeGapFloorMinutes, - // ceil] minutes, where ceil ramps from nudgeGapFirstCeilMinutes to nudgeGapCeilMinutes over - // nudgeGapRamp of idle. - nudgeGapFloorMinutes = 60.0 - nudgeGapFirstCeilMinutes = 90.0 - nudgeGapCeilMinutes = 360.0 - nudgeGapRamp = 12 * time.Hour + // The robot proactively nudges the idle human on a sparse, randomized schedule rather than an + // hourly stream: every nudge waits a uniform random 9-12 h after its reference point (the turn + // start for the first nudge, the previous nudge thereafter), so even a long-neglected turn + // collects only a few widely-spaced reminders. The 3 h window width is the random spread; the + // gap does not lengthen with idle time. The driver still skips a nudge that would land in the + // robot's sleep window, deferring it to the first scan after wake. + nudgeGapLoHours = 9.0 + nudgeGapHiHours = 12.0 ) // 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 // 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 -// nudgeGapFirstCeilMinutes (a ~60-90 min first gap) up to nudgeGapCeilMinutes (a 1-6 h gap) as -// refIdle reaches nudgeGapRamp — so the reminders space out the longer the turn is neglected. It -// is deterministic per (seed, refIdle), so the driver computes the same due time on every scan. +// It is a uniform sample in [nudgeGapLoHours, nudgeGapHiHours] hours, deterministic per +// (seed, refIdle) so the driver computes the same due time on every scan. refIdle only salts the +// draw, so each successive nudge of a still-idle turn waits a fresh 9-12 h rather than lengthening. 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)))) - mins := nudgeGapFloorMinutes + (ceil-nudgeGapFloorMinutes)*u - return time.Duration(mins * float64(time.Minute)) + hours := nudgeGapLoHours + (nudgeGapHiHours-nudgeGapLoHours)*u + return time.Duration(hours * float64(time.Hour)) } // clampMinutes converts a minute count to a duration, clamping it to the hard delay diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index f3f5f85..98cb4d9 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -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). func TestProactiveNudgeGap(t *testing.T) { for seed := int64(1); seed <= 1000; seed++ { - if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute { - t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed) + if first := proactiveNudgeGap(0, seed); first < 9*time.Hour || first > 12*time.Hour { + 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} { g := proactiveNudgeGap(idle, seed) - if g < 60*time.Minute || g > 6*time.Hour { - t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle) + if g < 9*time.Hour || g > 12*time.Hour { + t.Fatalf("gap %s out of [9h,12h] for seed %d idle %s", g, seed, idle) } if proactiveNudgeGap(idle, seed) != g { t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle) @@ -337,8 +337,16 @@ func TestProactiveNudgeGap(t *testing.T) { sort.Float64s(xs) return xs[n/2] } - if early, late := median(0), median(12*time.Hour); early >= late { - t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late) + // The window is flat: the gap distribution does not lengthen with idle time, so the median + // 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) } } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index f02828b..68ba1e7 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -112,6 +112,10 @@ type gameDTO struct { // 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. 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 diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index be5e539..13d6df7 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -443,16 +443,20 @@ func (s *Server) handleListGames(c *gin.Context) { return } memo := map[string]string{} - // One query seeds the lobby's per-card unread badge for every listed game. - var unread map[uuid.UUID]bool + // Two queries seed the lobby's per-card unread badge for every listed game: which games have + // 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 { unread, _ = s.social.UnreadGames(c.Request.Context(), uid) + unreadMsg, _ = s.social.UnreadMessageGames(c.Request.Context(), uid) } out := make([]gameDTO, 0, len(games)) for _, g := range games { dto := gameDTOFromGame(g) s.fillSeatNames(c.Request.Context(), &dto, memo) dto.UnreadChat = unread[g.ID] + dto.UnreadMessages = unreadMsg[g.ID] out = append(out, dto) } 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) } -// setUnreadChat fills the per-game unread-chat flag for viewer uid on one game's DTO, -// when the social domain is present. It is best-effort: a lookup error leaves the flag +// setUnreadChat fills the per-game unread-chat flags for viewer uid on one game's DTO, when the +// 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. func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) { 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 { g.UnreadChat = unread } + if msg, err := s.social.HasUnreadMessage(ctx, gid, uid); err == nil { + g.UnreadMessages = msg + } } diff --git a/backend/internal/social/chatread.go b/backend/internal/social/chatread.go index 08571c2..5b1b495 100644 --- a/backend/internal/social/chatread.go +++ b/backend/internal/social/chatread.go @@ -67,6 +67,21 @@ func (svc *Service) HasUnread(ctx context.Context, gameID, viewerID uuid.UUID) ( 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 // publish-to-read latency metric needs. type readMark struct { @@ -161,6 +176,43 @@ func (s *Store) hasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool 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, // for the chat_unread_messages gauge. func (s *Store) countUnread(ctx context.Context) (int64, error) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9595419..aa461fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -446,9 +446,10 @@ disguised robot stays indistinguishable from a person. **sleeps 00:00–07: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 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 - first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long - wait gets a handful of increasingly-spaced nudges rather than an hourly stream. + band; it proactively nudges the idle human on a **sparse, randomized schedule** — every nudge + waits a uniform random **9-12 h** (the first measured from the turn start, each later one from + 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 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 @@ -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 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 filter and the unread gauge all use. On each clear the publish-to-read latency is - recorded; the read time itself is not retained. + unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`** + (`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 (confirm-code binding, see §4), **timezone**, the daily **away window** and the block toggles — all editable through `account.UpdateProfile`, which validates them: diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 43d9591..f76a17c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and orders them so the games awaiting your move come first, the longest-waiting on top, while -opponent-turn and finished games are most-recent first; it renders as a compact, -line-separated list. While the lobby is open and a listed game **becomes your turn or +opponent-turn and finished games are most-recent first; within the your-turn and +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 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. @@ -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 dragging it out, answering its forced passes at roughly the player's own pace rather than 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 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 @@ -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 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 -entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game -in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading +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**, **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 also clears the moment its recipient **takes their move**. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index d15ee77..1124539 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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 также гаснет в момент, когда его получатель **делает ход**. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 4bff004..f9465e5 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -135,6 +135,7 @@ type GameResp struct { LastActivityUnix int64 `json:"last_activity_unix"` Seats []SeatResp `json:"seats"` 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 diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 7b84e57..2b44f2e 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -441,6 +441,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView { Seats: seats, LastActivityUnix: g.LastActivityUnix, UnreadChat: g.UnreadChat, + UnreadMessages: g.UnreadMessages, } } diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index cdd2482..a2545af 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -254,7 +254,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if r.URL.Path != "/api/v1/user/games" { 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() @@ -279,6 +279,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if !g.UnreadChat() { 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 g.Seats(&seat, 1) if string(seat.DisplayName()) != "Ann" { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 53f2917..9fe46cc 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -76,6 +76,10 @@ table GameView { // 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. 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). diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index fd91d9d..0969946 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -197,8 +197,20 @@ func (rcv *GameView) MutateUnreadChat(n bool) bool { 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) { - builder.StartObject(14) + builder.StartObject(15) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { 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) { builder.PrependBoolSlot(13, unreadChat, false) } +func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) { + builder.PrependBoolSlot(14, unreadMessages, false) +} func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 56dba8e..ca58f76 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -46,6 +46,9 @@ type GameView struct { VsAI bool // UnreadChat is a per-viewer flag: the requesting player has unread chat in this game. 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 @@ -165,6 +168,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) fb.GameViewAddVsAi(b, g.VsAI) fb.GameViewAddUnreadChat(b, g.UnreadChat) + fb.GameViewAddUnreadMessages(b, g.UnreadMessages) return fb.GameViewEnd(b) } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 5e5e8d0..6894761 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -14,6 +14,7 @@ import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import { lastMoveCells, replay } from '../lib/board'; + import { badgeKind } from '../lib/unread'; import { historyGrid } from '../lib/history'; import { centre, premiumGrid } from '../lib/premiums'; import { variantNameKey } from '../lib/variants'; @@ -183,7 +184,7 @@ view = st; syncWallet(st.walletBalance); // 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; setCachedGame(id, st, hist.moves, draft); // 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 // 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]; // 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. @@ -1091,10 +1092,11 @@ above so the markup and logic stay single-sourced (see the {#if landscape} split). --> {#snippet scoreboardBlock()} {#if view} + {@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}