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