Stage 17 round 6 (#7): reset the nudge cooldown once the player acts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s

The hourly nudge cooldown now clears as soon as the sender has moved or posted a chat
since their last nudge — engagement lifts the 'don't spam' limit. Backend: Nudge checks
game.LastMoveAt + the sender's last non-nudge chat against the last nudge time
(GameReader gains LastMoveAt). UI: nudgeOnCooldown mirrors it — a chat reset is read from
the message list, a move is tracked client-side (lastActedAt on commit/pass/exchange; the
backend stays authoritative across a reload). Integration test covers the reset.
This commit is contained in:
Ilia Denisov
2026-06-07 11:32:08 +02:00
parent 2cb2b57cdb
commit cdf616d6c4
6 changed files with 122 additions and 6 deletions
+7
View File
@@ -226,6 +226,13 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i
return svc.store.RobotSchedule(ctx, gameID) 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 // transition validates the actor and turn, applies op under the per-game lock and
// commits the result. // commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
+19
View File
@@ -651,6 +651,25 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
return row.Seed, nil 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 // 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 // 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. // next-move ETA. Both are server-only state, never part of the public game view.
+30
View File
@@ -353,3 +353,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
t.Fatalf("nudge after window: %v", err) 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)
}
}
+46 -1
View File
@@ -114,7 +114,15 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return Message{}, err return Message{}, err
} }
if ok && svc.now().Sub(last) < nudgeInterval { 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) msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
if err != nil { if err != nil {
@@ -127,6 +135,22 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return msg, nil 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 // emitChat pushes a chat message to every seated player except the sender
// (best-effort live delivery; the recipients still read it via Messages). // (best-effort live delivery; the recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) { 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 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. // messageFromRow projects a generated row into the public Message.
func messageFromRow(r model.ChatMessages) Message { func messageFromRow(r model.ChatMessages) Message {
m := Message{ m := Message{
+3
View File
@@ -28,6 +28,9 @@ type GameReader interface {
// SharedGame reports whether two accounts are seated together in any game // SharedGame reports whether two accounts are seated together in any game
// (active or finished); it gates the "befriend an opponent" request path. // (active or finished); it gates the "befriend an opponent" request path.
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
// 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. // Sentinel errors returned by the service.
+17 -5
View File
@@ -95,14 +95,23 @@
// timer while the chat is open, so it re-enables without waiting for a new message. // timer while the chat is open, so it re-enables without waiting for a new message.
const nudgeCooldownSecs = 3600; const nudgeCooldownSecs = 3600;
let nudgeTick = $state(0); 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(() => { const nudgeOnCooldown = $derived.by(() => {
void nudgeTick; void nudgeTick;
const mine = app.session?.userId ?? ''; const mine = app.session?.userId ?? '';
const last = messages.reduce( let lastNudge = 0;
(mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx), let lastChat = 0;
0, for (const m of messages) {
); if (m.senderId !== mine) continue;
return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs; 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() { async function load() {
@@ -361,6 +370,7 @@
busy = true; busy = true;
try { try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant); await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
telegramHaptic('success'); telegramHaptic('success');
zoomed = false; zoomed = false;
await load(); await load();
@@ -381,6 +391,7 @@
busy = true; busy = true;
try { try {
await gateway.pass(id); await gateway.pass(id);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load(); await load();
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -461,6 +472,7 @@
busy = true; busy = true;
try { try {
await gateway.exchange(id, tiles, variant); await gateway.exchange(id, tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load(); await load();
} catch (e) { } catch (e) {
handleError(e); handleError(e);