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.
|
// 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')`
|
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).
|
// userListWhere builds the shared WHERE clause and its positional args (from $1).
|
||||||
func userListWhere(f UserFilter) (string, []any) {
|
func userListWhere(f UserFilter) (string, []any) {
|
||||||
args := []any{f.Robots}
|
args := []any{f.Robots}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Seats</h2>
|
<section class="panel"><h2>Seats</h2>
|
||||||
<table class="list">
|
<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>
|
<tbody>
|
||||||
{{range .Seats}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -145,9 +145,15 @@ type GameDetailView struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt string
|
FinishedAt string
|
||||||
Seats []SeatRow
|
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 {
|
type SeatRow struct {
|
||||||
Seat int
|
Seat int
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -155,6 +161,9 @@ type SeatRow struct {
|
|||||||
Score int
|
Score int
|
||||||
HintsUsed int
|
HintsUsed int
|
||||||
Winner bool
|
Winner bool
|
||||||
|
IsRobot bool
|
||||||
|
RobotIntent string
|
||||||
|
NextMove string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComplaintsView is the paginated complaint review queue.
|
// 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)
|
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
|
// transition validates the actor and turn, applies op under the per-game lock and
|
||||||
// commits the result.
|
// commits the result.
|
||||||
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
|
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
|
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.
|
// projectGame builds a Game from a games row and its ordered seat rows.
|
||||||
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||||
variant, err := engine.ParseVariant(g.Variant)
|
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
|
// 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.
|
// 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) {
|
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
|
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
|
// 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
|
// 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
|
// 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).
|
// plays builds candidate plays carrying only the given scores (ranked as passed).
|
||||||
func plays(scores ...int) []engine.MoveRecord {
|
func plays(scores ...int) []engine.MoveRecord {
|
||||||
out := make([]engine.MoveRecord, len(scores))
|
out := make([]engine.MoveRecord, len(scores))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/robot"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminPageSize is the page size of the admin console's paginated lists.
|
// 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),
|
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||||
FinishedAt: fmtTimePtr(g.FinishedAt),
|
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 {
|
for _, seat := range g.Seats {
|
||||||
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
|
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
|
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)
|
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)
|
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.
|
// consoleComplaints renders the paginated complaint review queue.
|
||||||
func (s *Server) consoleComplaints(c *gin.Context) {
|
func (s *Server) consoleComplaints(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|||||||
Reference in New Issue
Block a user