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
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:
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user