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
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:
@@ -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}
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
</section>
|
||||
<section class="panel"><h2>Seats</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th></tr></thead>
|
||||
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Seats}}
|
||||
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
||||
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td><td>{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}<br><small>next move {{.NextMove}}</small>{{end}}{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user