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 } 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) } // 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 { if now.Before(rt.TurnStartedAt.Add(moveDelay(rt.Seed, rt.MoveCount))) { 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 once the human has been idle past the // threshold. The social service enforces the once-per-hour-per-game limit 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 { if now.Sub(rt.TurnStartedAt) < proactiveNudgeIdle { 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 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) d := selectMove(cands, myScore, oppScore, playToWin(rt.Seed), 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.Dir, 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 }