Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card
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 29s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m14s

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.
This commit is contained in:
Ilia Denisov
2026-06-07 09:42:23 +02:00
parent 29d1193a0a
commit f916d5e0ca
9 changed files with 198 additions and 4 deletions
@@ -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()