f3768d20f2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
In a dead-drawn endgame — the two most recent journal moves are both passes, so the board and the robot's rack are frozen and the robot is bound to pass again — the robot still waited out its long late-game think time (up to 90 min) before passing, needlessly dragging out a decided game. Shorten that delay to a [0.8, 1.5]x band around the human's last-move think time (the gap between the last two journal entries), clamped to [30s, 8min] and taken as a min with the normal schedule, so the robot never moves slower. A slow human collapses to the 8-min cap; a fast human is tracked, with the floor keeping the robot from passing suspiciously instantly. The anchor reads the move journal only (no schema change), stays deterministic from the seed, and still defers to the sleep window. RobotTurns now carries EndgamePass + OppLastMove, filled by one batched journal query on the scan; the honest-AI single-game trigger keeps the normal path (it moves at once). NextMoveAt (admin ETA) is left as the normal-schedule upper bound.
273 lines
9.4 KiB
Go
273 lines
9.4 KiB
Go
package robot
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/metric"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/game"
|
|
)
|
|
|
|
// Run drives the robot until ctx is cancelled, scanning for due turns every
|
|
// interval. It mirrors the game turn-timeout sweeper and is started once from
|
|
// main; it simply calls Drive on each tick.
|
|
func (s *Service) Run(ctx context.Context, interval time.Duration) {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.Drive(ctx, s.clock())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drive performs one scan: it handles every active game seating a pool robot as
|
|
// of now. Run calls it on a timer; it takes now explicitly so tests and ops can
|
|
// drive a single pass at a chosen instant (mirroring game.Service.SweepTimeouts).
|
|
func (s *Service) Drive(ctx context.Context, now time.Time) {
|
|
turns, err := s.games.RobotTurns(ctx, s.poolIDs())
|
|
if err != nil {
|
|
s.log.Warn("robot scan failed", zap.Error(err))
|
|
return
|
|
}
|
|
for _, rt := range turns {
|
|
if err := s.handle(ctx, rt, now); err != nil {
|
|
s.log.Warn("robot turn failed", zap.String("game", rt.GameID.String()), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// handle resolves the opponent (a two-player auto-match), honours the robot's
|
|
// sleep window, then either makes a move on the robot's turn or considers a
|
|
// proactive nudge on the human's turn. The seat→account mapping is fixed for the
|
|
// game's life, so reading it at a different instant than the scan is consistent;
|
|
// the turn cursor comes from the scan snapshot (rt), and the submit/nudge calls
|
|
// re-validate against the live state and skip benignly if it has moved on.
|
|
func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time) error {
|
|
seats, _, status, err := s.games.Participants(ctx, rt.GameID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != game.StatusActive {
|
|
return nil
|
|
}
|
|
oppID, ok := opponentOf(seats, rt.RobotSeat)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Honest-AI game: the robot moves the instant it is its turn — no sleep window and
|
|
// no proactive nudge (chat and nudge are disabled in these games, and the player
|
|
// chose an opponent that "moves at once"). It still plays to the same per-game
|
|
// strength (playToWin), margin band and occasional off-strategy deviation as the
|
|
// human-mimicry path.
|
|
if rt.VsAI {
|
|
if rt.ToMove == rt.RobotSeat {
|
|
return s.act(ctx, rt, now)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
opp, err := s.accounts.GetByID(ctx, oppID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if asleep(opp.TimeZone, sleepDrift(rt.Seed), now) {
|
|
return nil
|
|
}
|
|
|
|
if rt.ToMove == rt.RobotSeat {
|
|
return s.maybeMove(ctx, rt, oppID, now)
|
|
}
|
|
return s.maybeNudge(ctx, rt, now)
|
|
}
|
|
|
|
// DriveGame handles a single active game seating a pool robot, immediately. It backs
|
|
// the honest-AI fast-move trigger fired by the game service after a vs_ai game is
|
|
// created or advanced; it mirrors Drive's per-game step but for one game (false from
|
|
// the focused read means the game is gone, finished, or holds no pooled robot).
|
|
func (s *Service) DriveGame(ctx context.Context, gameID uuid.UUID, now time.Time) error {
|
|
rt, ok, err := s.games.RobotTurn(ctx, gameID, s.poolIDs())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return s.handle(ctx, rt, now)
|
|
}
|
|
|
|
// driveTimeout bounds a triggered, off-request honest-AI move so a stuck call cannot
|
|
// leak a goroutine. A robot move is a quick in-process computation, so it is generous.
|
|
const driveTimeout = 30 * time.Second
|
|
|
|
// TriggerMove asynchronously drives the robot's move in an honest-AI game, used by the
|
|
// game service's after-commit/after-create hook so the robot replies at once instead of
|
|
// waiting for the next driver scan. It returns immediately and runs on a background
|
|
// context (the originating request's context may already be cancelled); errors are
|
|
// logged. The periodic Drive scan remains the fallback if a trigger is missed.
|
|
func (s *Service) TriggerMove(gameID uuid.UUID) {
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), driveTimeout)
|
|
defer cancel()
|
|
if err := s.DriveGame(ctx, gameID, s.clock()); err != nil {
|
|
s.log.Warn("robot trigger failed", zap.String("game", gameID.String()), zap.Error(err))
|
|
}
|
|
}()
|
|
}
|
|
|
|
// maybeMove acts when the robot's think time has elapsed. A daytime nudge from
|
|
// the opponent during the current turn pulls the move in to the short reply
|
|
// window; otherwise the robot waits out its sampled delay.
|
|
func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.UUID, now time.Time) error {
|
|
delay := moveDelay(rt.Seed, rt.MoveCount)
|
|
if rt.EndgamePass {
|
|
// A dead-drawn endgame (the last two moves are both passes) means the robot is
|
|
// bound to pass again: answer on the shortened schedule scaled to the human's
|
|
// last move, taken as a min so it is never slower than the normal think time.
|
|
if d := endgamePassDelay(rt.Seed, rt.MoveCount, rt.OppLastMove); d < delay {
|
|
delay = d
|
|
}
|
|
}
|
|
if now.Before(rt.TurnStartedAt.Add(delay)) {
|
|
last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, oppID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok || !last.After(rt.TurnStartedAt) {
|
|
return nil // not yet due and no nudge this turn
|
|
}
|
|
if now.Before(last.Add(nudgeReplyDelay(rt.Seed, rt.MoveCount))) {
|
|
return nil // within the reply window
|
|
}
|
|
}
|
|
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.
|
|
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 {
|
|
return err
|
|
} else if ok && last.After(rt.TurnStartedAt) {
|
|
ref = last
|
|
}
|
|
if now.Sub(ref) < proactiveNudgeGap(ref.Sub(rt.TurnStartedAt), rt.Seed) {
|
|
return nil
|
|
}
|
|
if _, err := s.social.Nudge(ctx, rt.GameID, rt.RobotID); err != nil {
|
|
s.log.Debug("robot nudge skipped", zap.String("game", rt.GameID.String()), zap.Error(err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// act reads the live turn, chooses a move by margin — usually toward the robot's
|
|
// per-game intent, but with an occasional off-strategy deviation that fades to none
|
|
// as the bag empties — and submits it. State that has moved on since the scan (a
|
|
// finished game, a turn that is no longer the robot's) surfaces as a benign error
|
|
// and is skipped.
|
|
func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) error {
|
|
st, err := s.games.GameState(ctx, rt.GameID, rt.RobotID)
|
|
if err != nil {
|
|
return skipBenign(err)
|
|
}
|
|
cands, err := s.games.Candidates(ctx, rt.GameID, rt.RobotID)
|
|
if err != nil {
|
|
return skipBenign(err)
|
|
}
|
|
|
|
myScore := st.Game.Seats[st.Seat].Score
|
|
oppScore := bestOpponentScore(st.Game.Seats, st.Seat)
|
|
win := playToWin(rt.Seed)
|
|
if deviates(rt.Seed, rt.MoveCount, st.BagLen) {
|
|
win = !win // an occasional off-strategy move; never once the bag is empty
|
|
}
|
|
d := selectMove(cands, myScore, oppScore, win, defaultBand, st.Rack, st.BagLen)
|
|
|
|
var res game.MoveResult
|
|
switch d.kind {
|
|
case decidePlay:
|
|
res, err = s.games.SubmitPlay(ctx, rt.GameID, rt.RobotID, d.move.Tiles)
|
|
case decideExchange:
|
|
res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange)
|
|
default:
|
|
res, err = s.games.Pass(ctx, rt.GameID, rt.RobotID)
|
|
}
|
|
if err != nil {
|
|
return skipBenign(err)
|
|
}
|
|
s.recordFinish(ctx, rt.GameID, rt.RobotID, res.Game)
|
|
return nil
|
|
}
|
|
|
|
// recordFinish counts and logs a robot game that the robot's move has just
|
|
// finished. account_stats remains the authoritative, complete balance metric
|
|
// (it also captures games the human finishes); this live counter only sees
|
|
// robot-finished games.
|
|
func (s *Service) recordFinish(ctx context.Context, gameID, robotID uuid.UUID, g game.Game) {
|
|
if g.Status != game.StatusFinished {
|
|
return
|
|
}
|
|
result := "draw"
|
|
for _, seat := range g.Seats {
|
|
if seat.IsWinner {
|
|
if seat.AccountID == robotID {
|
|
result = "win"
|
|
} else {
|
|
result = "loss"
|
|
}
|
|
break
|
|
}
|
|
}
|
|
s.finished.Add(ctx, 1, metric.WithAttributes(attribute.String("result", result)))
|
|
s.log.Info("robot game finished",
|
|
zap.String("game", gameID.String()),
|
|
zap.String("result", result),
|
|
zap.String("reason", g.EndReason))
|
|
}
|
|
|
|
// opponentOf returns the account at the single non-robot seat of a two-player
|
|
// auto-match, and false when none differs from the robot seat.
|
|
func opponentOf(seats []uuid.UUID, robotSeat int) (uuid.UUID, bool) {
|
|
for seat, id := range seats {
|
|
if seat != robotSeat {
|
|
return id, true
|
|
}
|
|
}
|
|
return uuid.Nil, false
|
|
}
|
|
|
|
// bestOpponentScore is the highest score among the seats other than the robot's.
|
|
func bestOpponentScore(seats []game.Seat, robotSeat int) int {
|
|
best := 0
|
|
for _, s := range seats {
|
|
if s.Seat != robotSeat && s.Score > best {
|
|
best = s.Score
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// skipBenign swallows the errors that mean the game moved on since the scan (it
|
|
// finished, or it is no longer the robot's turn), so the driver simply tries
|
|
// again next tick.
|
|
func skipBenign(err error) error {
|
|
if errors.Is(err, game.ErrFinished) || errors.Is(err, game.ErrNotYourTurn) || errors.Is(err, game.ErrNotAPlayer) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|