Files
scrabble-game/backend/internal/robot/driver.go
T
Ilia Denisov 3bceafbc12
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
feat(robot): occasional off-strategy deviation, strict in the endgame
The robot followed its per-game playToWin/lose intent on every move, which made
the outcome too predictable. It now flips that intent for a single move on ~20%
of opening/midgame turns (a winning robot eases off, a losing one surges ahead),
so the chosen strategy may not pan out — which favours the human. The chance
tapers linearly to 0 over the last 14 tiles in the bag and is 0 once the bag is
empty, so the endgame follows the chosen strategy strictly.

The decision is deterministic from the seed (mix(seed,"deviate",moveCount)) and
applies to both robot paths via the shared selectMove; the per-game play-to-win
intent the admin card shows is unchanged. Adds deviateProb/deviates helpers and
unit tests (taper bounds + monotonicity, never-in-endgame, determinism, ~20%
distribution); bakes the behaviour into ARCHITECTURE §7, FUNCTIONAL (+_ru),
backend/README, PRERELEASE and PLAN Stage 5.
2026-06-15 21:37:23 +02:00

264 lines
9.0 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 {
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 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
}