From f916d5e0ca744fdff9816af8b2429ab8c25f8662 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 09:42:23 +0200 Subject: [PATCH] Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin game detail now shows, per robot seat, the game's deterministic play-to-win decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the ~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports, account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants (never early, never in the sleep window), PlayToWin export, and an admin render integration test asserting the intent + ETA + target appear. --- backend/internal/account/userlist.go | 13 ++++++ .../templates/pages/game_detail.gohtml | 5 ++- backend/internal/adminconsole/views.go | 11 ++++- backend/internal/game/service.go | 6 +++ backend/internal/game/store.go | 18 ++++++++ backend/internal/inttest/admin_test.go | 39 ++++++++++++++++ backend/internal/robot/strategy.go | 34 ++++++++++++++ backend/internal/robot/strategy_test.go | 31 +++++++++++++ .../internal/server/handlers_admin_console.go | 45 ++++++++++++++++++- 9 files changed, 198 insertions(+), 4 deletions(-) diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index 2f22777..e2c12fc 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -34,6 +34,19 @@ type UserFilter struct { // robotExists is the correlated subquery testing whether account a is a robot. const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')` +// IsRobot reports whether the account is a robot pool member (it carries a robot +// identity). The admin console uses it to label a game's robot seats. +func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`, + accountID).Scan(&ok) + if err != nil { + return false, fmt.Errorf("account: is-robot %s: %w", accountID, err) + } + return ok, nil +} + // userListWhere builds the shared WHERE clause and its positional args (from $1). func userListWhere(f UserFilter) (string, []any) { args := []any{f.Robots} diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 436fbb2..976d691 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -17,13 +17,14 @@

Seats

- + {{range .Seats}} - + {{end}}
SeatPlayerScoreHints usedWinner
SeatPlayerScoreHints usedWinnerRobot
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}
next move {{.NextMove}}{{end}}{{end}}
+{{if .HasRobot}}

Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.

{{end}}
{{end}} {{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 8293dd9..cb92d26 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -145,9 +145,15 @@ type GameDetailView struct { UpdatedAt string FinishedAt string Seats []SeatRow + // HasRobot is true when any seat is a robot, gating the robot-target caption; + // RobotTargetPct is the configured global play-to-win rate, in percent. + HasRobot bool + RobotTargetPct int } -// SeatRow is one seat of a game. +// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's +// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the +// scheduled next-move ETA shown only while it is that robot's turn in an active game. type SeatRow struct { Seat int DisplayName string @@ -155,6 +161,9 @@ type SeatRow struct { Score int HintsUsed int Winner bool + IsRobot bool + RobotIntent string + NextMove string } // ComplaintsView is the paginated complaint review queue. diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index e109929..63c46c1 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -220,6 +220,12 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V return svc.store.GetGameVariant(ctx, gameID) } +// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's +// robot-schedule panel (the deterministic play-to-win intent and next-move ETA). +func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { + return svc.store.RobotSchedule(ctx, gameID) +} + // 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) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index c06c508..b09e5b4 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -651,6 +651,24 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { return row.Seed, 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. +func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { + stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt). + FROM(table.Games). + WHERE(table.Games.GameID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.Games + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return 0, time.Time{}, ErrNotFound + } + return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err) + } + return row.Seed, row.TurnStartedAt, nil +} + // projectGame builds a Game from a games row and its ordered seat rows. func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { variant, err := engine.ParseVariant(g.Variant) diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 3059643..6b77c97 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -167,6 +167,45 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) { } } +// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's +// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17). +func TestConsoleGameDetailRobotSchedule(t *testing.T) { + ctx := context.Background() + svc := newGameService() + robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + human := provisionAccount(t) + // Seat the robot first so it is to move (seat 0), exposing the next-move ETA. + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(), + }) + code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "") + if code != http.StatusOK { + t.Fatalf("game detail = %d, want 200", code) + } + if !strings.Contains(body, "🤖") { + t.Error("robot seat is not marked in the game detail") + } + if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") { + t.Error("robot play-to-win intent missing") + } + if !strings.Contains(body, "next move") { + t.Error("robot is to move but the next-move ETA is missing") + } + if !strings.Contains(body, "~40%") { + t.Error("robot play-to-win target caption missing") + } +} + // consoleDo issues a request to h, optionally with an Origin header, and returns // the status and body. Form bodies are sent as application/x-www-form-urlencoded. func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index 7c9b952..4219863 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -114,6 +114,40 @@ func playToWin(seed int64) bool { return mix(seed, "win")%100 < playToWinPercent } +// PlayToWin exposes the once-per-game play-to-win decision for a game's bag seed, for the +// admin console (it is deterministic and fixed for the whole game). +func PlayToWin(seed int64) bool { return playToWin(seed) } + +// PlayToWinTargetPercent is the configured probability, in percent, that a robot plays to +// win in any given game (the admin console shows it alongside the per-game decision). +const PlayToWinTargetPercent = playToWinPercent + +// NextMoveAt is the deterministic instant the robot is scheduled to play the move at +// moveCount, given when the turn started and the opponent's timezone (which anchors the +// robot's sleep window). It is the sampled think-time delay, deferred to the end of the +// sleep window when it would otherwise land while the robot is asleep. The driver acts on +// a scan tick, so the real move lands at the first scan at or after this instant. It is +// meaningful only on the robot's own turn; the admin console surfaces it as an ETA. +func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time { + t := turnStartedAt.Add(moveDelay(seed, moveCount)) + drift := sleepDrift(seed) + if asleep(opponentTZ, drift, t) { + t = wakeAfter(opponentTZ, drift, t) + } + return t +} + +// wakeAfter returns the first instant at or after t when the robot is awake — the local +// hour reaches sleepEndHour in the opponent's drifted timezone — converted back to UTC. +func wakeAfter(opponentTZ string, drift time.Duration, t time.Time) time.Time { + local := t.In(loadLocation(opponentTZ)).Add(drift) + wake := time.Date(local.Year(), local.Month(), local.Day(), sleepEndHour, 0, 0, 0, local.Location()) + if !wake.After(local) { + wake = wake.Add(24 * time.Hour) + } + return wake.Add(-drift).UTC() +} + // delayBand returns the lower and upper bounds, in minutes, of the move-delay band // for the move at moveCount. It interpolates linearly with game progress (the move // count over avgGameMoves, capped at 1): early moves sit in a short band and late diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index b728d00..91092bb 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -207,6 +207,37 @@ func TestMixDeterministic(t *testing.T) { } } +// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never +// earlier than the sampled think-time delay, and it never lands while the robot is asleep +// (a delay that would fall in the sleep window is deferred to the wake time). +func TestNextMoveAt(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for seed := int64(1); seed <= 500; seed++ { + for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day + start := base.Add(time.Duration(h) * time.Hour) + at := NextMoveAt(seed, 3, start, "UTC") + if at.Before(start.Add(moveDelay(seed, 3))) { + t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at) + } + if asleep("UTC", sleepDrift(seed), at) { + t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at) + } + } + } +} + +// TestPlayToWinExport checks the exported decision matches the internal one and the target. +func TestPlayToWinExport(t *testing.T) { + for seed := int64(1); seed <= 200; seed++ { + if PlayToWin(seed) != playToWin(seed) { + t.Fatalf("PlayToWin(%d) != playToWin", seed) + } + } + if PlayToWinTargetPercent != playToWinPercent { + t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent) + } +} + // plays builds candidate plays carrying only the given scores (ranked as passed). func plays(scores ...int) []engine.MoveRecord { out := make([]engine.MoveRecord, len(scores)) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 91da11c..5631c8b 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -18,6 +18,7 @@ import ( "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/robot" ) // adminPageSize is the page size of the admin console's paginated lists. @@ -248,16 +249,58 @@ func (s *Server) consoleGameDetail(c *gin.Context) { MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt), FinishedAt: fmtTimePtr(g.FinishedAt), } + // Resolve seats and detect robot seats; capture the human opponent's timezone, which + // anchors the robot's sleep window for the next-move ETA. + oppTZ := "" for _, seat := range g.Seats { row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner} - if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil { + acc, accErr := s.accounts.GetByID(ctx, seat.AccountID) + if accErr == nil { row.DisplayName = acc.DisplayName } + if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot { + row.IsRobot = true + view.HasRobot = true + } else if accErr == nil { + oppTZ = acc.TimeZone + } view.Seats = append(view.Seats, row) } + // For each robot seat, surface the game's deterministic play-to-win intent and — while + // it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed. + if view.HasRobot { + view.RobotTargetPct = robot.PlayToWinTargetPercent + if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil { + now := time.Now().UTC() + for i := range view.Seats { + if !view.Seats[i].IsRobot { + continue + } + if robot.PlayToWin(seed) { + view.Seats[i].RobotIntent = "play to win" + } else { + view.Seats[i].RobotIntent = "play to lose" + } + if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat { + view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now) + } + } + } + } s.renderConsole(c, "game_detail", "games", "Game", view) } +// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a +// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)". +func robotETA(at, now time.Time) string { + mins := int(at.Sub(now).Round(time.Minute).Minutes()) + rel := fmt.Sprintf("in ~%d min", mins) + if mins <= 0 { + rel = "due now" + } + return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel) +} + // consoleComplaints renders the paginated complaint review queue. func (s *Server) consoleComplaints(c *gin.Context) { ctx := c.Request.Context()