diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 63c46c1..0010f29 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -226,6 +226,13 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i return svc.store.RobotSchedule(ctx, gameID) } +// LastMoveAt returns the time of an account's most recent move in a game (and whether it +// has moved). The social service uses it to reset the nudge cooldown once a player has +// taken a turn (Stage 17). +func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { + return svc.store.LastMoveAt(ctx, gameID, accountID) +} + // transition validates the actor and turn, applies op under the per-game lock and // commits the result. func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index b09e5b4..d97a2ea 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -651,6 +651,25 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { return row.Seed, nil } +// LastMoveAt returns the time of the account's most recent move in the game and true, or +// the zero time and false when it has not moved. The social service uses it to reset the +// nudge cooldown once the player has taken a turn (Stage 17). +func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { + var at sql.NullTime + err := s.db.QueryRowContext(ctx, + `SELECT MAX(m.created_at) FROM backend.game_moves m + JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat + WHERE m.game_id = $1 AND p.account_id = $2`, + gameID, accountID).Scan(&at) + if err != nil { + return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err) + } + if !at.Valid { + return time.Time{}, false, nil + } + return at.Time, true, nil +} + // RobotSchedule returns a game's bag seed and current turn-start time. The admin console // combines them with the robot strategy to show a robot seat's play-to-win intent and its // next-move ETA. Both are server-only state, never part of the public game view. diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index ac44613..33908fa 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -353,3 +353,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) { t.Fatalf("nudge after window: %v", err) } } + +// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has +// acted (moved or chatted) since their last nudge, even within the hour (Stage 17). +func TestNudgeCooldownResetsOnAction(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gsvc := newGameService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 to move + + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges + t.Fatalf("nudge: %v", err) + } + if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) { + t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err) + } + // Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes. + if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil { + t.Fatalf("seat0 pass: %v", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil { + t.Fatalf("seat1 chat: %v", err) + } + if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil { + t.Fatalf("seat1 pass: %v", err) + } + // Back on the opponent's turn, the cooldown is reset by the action since the nudge. + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { + t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err) + } +} diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 78e6f24..0d9edc8 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -114,7 +114,15 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return Message{}, err } if ok && svc.now().Sub(last) < nudgeInterval { - return Message{}, ErrNudgeTooSoon + // The cooldown resets once the sender has acted (moved or chatted) since the last + // nudge — engagement clears the "don't spam" limit (Stage 17). + acted, err := svc.actedSince(ctx, gameID, senderID, last) + if err != nil { + return Message{}, err + } + if !acted { + return Message{}, ErrNudgeTooSoon + } } msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil) if err != nil { @@ -127,6 +135,22 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return msg, nil } +// actedSince reports whether senderID made a move or posted a chat message in the game +// after t — the events that reset the nudge cooldown (Stage 17). +func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) { + if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil { + return false, err + } else if ok && mv.After(t) { + return true, nil + } + if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil { + return false, err + } else if ok && msg.After(t) { + return true, nil + } + return false, nil +} + // emitChat pushes a chat message to every seated player except the sender // (best-effort live delivery; the recipients still read it via Messages). func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) { @@ -261,6 +285,27 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti return row.CreatedAt, true, nil } +// lastMessageAt returns the time of senderID's most recent non-nudge chat message in +// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale +// nudge no longer blocks a new one (Stage 17). +func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) { + stmt := postgres.SELECT(table.ChatMessages.CreatedAt). + FROM(table.ChatMessages). + WHERE( + table.ChatMessages.GameID.EQ(postgres.UUID(gameID)). + AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))). + AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))), + ).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1) + var row model.ChatMessages + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return time.Time{}, false, nil + } + return time.Time{}, false, fmt.Errorf("social: last message: %w", err) + } + return row.CreatedAt, true, nil +} + // messageFromRow projects a generated row into the public Message. func messageFromRow(r model.ChatMessages) Message { m := Message{ diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 3ea5c28..1f0aaa2 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -28,6 +28,9 @@ type GameReader interface { // SharedGame reports whether two accounts are seated together in any game // (active or finished); it gates the "befriend an opponent" request path. SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) + // LastMoveAt is the time of an account's most recent move in a game (and whether it + // has moved); the nudge cooldown resets once the player has taken a turn. + LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) } // Sentinel errors returned by the service. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 7c07d2a..beafaac 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -95,14 +95,23 @@ // timer while the chat is open, so it re-enables without waiting for a new message. const nudgeCooldownSecs = 3600; let nudgeTick = $state(0); + // Unix seconds of the player's own last move, which resets the nudge cooldown (mirrors the + // backend, Stage 17). A chat reset is read from `messages`; a move is tracked client-side + // (the backend stays authoritative across a reload). + let lastActedAt = $state(0); const nudgeOnCooldown = $derived.by(() => { void nudgeTick; const mine = app.session?.userId ?? ''; - const last = messages.reduce( - (mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx), - 0, - ); - return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs; + let lastNudge = 0; + let lastChat = 0; + for (const m of messages) { + if (m.senderId !== mine) continue; + if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix); + else lastChat = Math.max(lastChat, m.createdAtUnix); + } + if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false; + // Engagement since the nudge clears the cooldown: a chat or a move. + return lastChat <= lastNudge && lastActedAt <= lastNudge; }); async function load() { @@ -361,6 +370,7 @@ busy = true; try { await gateway.submitPlay(id, sub.dir, sub.tiles, variant); + lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown telegramHaptic('success'); zoomed = false; await load(); @@ -381,6 +391,7 @@ busy = true; try { await gateway.pass(id); + lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown await load(); } catch (e) { handleError(e); @@ -461,6 +472,7 @@ busy = true; try { await gateway.exchange(id, tiles, variant); + lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown await load(); } catch (e) { handleError(e);