feat: honest AI opponent in quick game #68

Merged
developer merged 2 commits from feature/ai-opponent into development 2026-06-15 19:05:23 +00:00
12 changed files with 166 additions and 41 deletions
Showing only changes of commit d2a9441287 - Show all commits
+8
View File
@@ -503,3 +503,11 @@ Then Stage 18.
- **Schema/wire change → a contour DB wipe** after merge (`DROP SCHEMA backend CASCADE` + restart, the - **Schema/wire change → a contour DB wipe** after merge (`DROP SCHEMA backend CASCADE` + restart, the
R1/R3 pattern). Bake-back: `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `docs/UI_DESIGN.md`, R1/R3 pattern). Bake-back: `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `docs/UI_DESIGN.md`,
`backend/README.md`, Go Doc comments. `backend/README.md`, Go Doc comments.
- **Post-review refinements (owner, same PR):** (1) the **GCG export labels the robot seat "AI"** rather
than its human-like pool name (`ExportGCG` overrides the name via `accounts.IsRobot`; the in-app 🤖 is
unchanged); (2) honest-AI games **emit no `your_turn`** — the robot replies instantly, so the signal
would arrive with the move and be pointless; `opponent_moved` still advances the UI; (3) the **admin
console surfaces the AI flag** — a **🤖 column** in `/games` and an "AI game" line on the game card
(`GameRow`/`GameDetailView` gain `VsAI`); (4) `games_started_total` / `games_abandoned_total` gain a
**`vs_ai`** attribute and the Grafana *Game domain* dashboard splits started/abandoned into **human**
and **AI** panels.
@@ -7,6 +7,7 @@
<li><b>Variant</b> {{.Variant}}</li> <li><b>Variant</b> {{.Variant}}</li>
<li><b>Dictionary</b> {{.DictVersion}}</li> <li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li> <li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Players</b> {{.Players}}</li> <li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li> <li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li> <li><b>Moves</b> {{.MoveCount}}</li>
@@ -8,11 +8,11 @@
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a> <a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav> </nav>
<table class="list"> <table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead> <thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr> <tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}} {{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
<nav class="pager"> <nav class="pager">
+5 -1
View File
@@ -192,6 +192,8 @@ type GameRow struct {
Status string Status string
Players int Players int
UpdatedAt string UpdatedAt string
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
VsAI bool
} }
// GamesView is the paginated games list, optionally filtered by status. // GamesView is the paginated games list, optionally filtered by status.
@@ -214,7 +216,9 @@ type GameDetailView struct {
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
FinishedAt string FinishedAt string
Seats []SeatRow // VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption; // 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. // RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool HasRobot bool
+13 -6
View File
@@ -101,15 +101,22 @@ func phaseOf(moveCount int) string {
} }
} }
// recordStarted counts one started game of variant. // recordStarted counts one started game of variant, split by whether it is an
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) { // honest-AI game (the vs_ai attribute), so AI and human games chart separately.
m.started.Add(ctx, 1, variantAttr(v)) func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant, vsAI bool) {
m.started.Add(ctx, 1, gameKindAttr(v, vsAI))
} }
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of // recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
// variant. // variant, split by the vs_ai attribute (an AI game's abandon is the 7-day rule).
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant) { func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant, vsAI bool) {
m.abandoned.Add(ctx, 1, variantAttr(v)) m.abandoned.Add(ctx, 1, gameKindAttr(v, vsAI))
}
// gameKindAttr is the (variant, vs_ai) attribute set shared by the started and
// abandoned counters so AI and human games are split on the same labels.
func gameKindAttr(v engine.Variant, vsAI bool) metric.MeasurementOption {
return metric.WithAttributes(attribute.String("variant", v.String()), attribute.Bool("vs_ai", vsAI))
} }
// variantAttr is the shared "variant" attribute option, usable for both Record and // variantAttr is the shared "variant" attribute option, usable for both Record and
+15 -9
View File
@@ -20,10 +20,12 @@ func TestGameMetrics(t *testing.T) {
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newGameMetrics(meter) m := newGameMetrics(meter)
m.recordStarted(ctx, engine.VariantEnglish) m.recordStarted(ctx, engine.VariantEnglish, false)
m.recordStarted(ctx, engine.VariantEnglish) m.recordStarted(ctx, engine.VariantEnglish, false)
m.recordStarted(ctx, engine.VariantRussianScrabble) m.recordStarted(ctx, engine.VariantRussianScrabble, false)
m.recordAbandoned(ctx, engine.VariantErudit) m.recordStarted(ctx, engine.VariantEnglish, true) // an honest-AI game
m.recordAbandoned(ctx, engine.VariantErudit, false)
m.recordAbandoned(ctx, engine.VariantEnglish, true) // an AI game abandoned (the 7-day rule)
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond)) m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond)) m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second) m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
@@ -35,11 +37,15 @@ func TestGameMetrics(t *testing.T) {
} }
started := counterByAttr(t, rm, "games_started_total", "variant") started := counterByAttr(t, rm, "games_started_total", "variant")
if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 { if started["scrabble_en"] != 3 || started["scrabble_ru"] != 1 {
t.Errorf("games_started_total = %v, want scrabble_en:2 scrabble_ru:1", started) t.Errorf("games_started_total = %v, want scrabble_en:3 scrabble_ru:1", started)
} }
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit_ru"] != 1 { // The vs_ai attribute splits AI from human games for the per-kind Grafana panels.
t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned) if byKind := counterByAttr(t, rm, "games_started_total", "vs_ai"); byKind["false"] != 3 || byKind["true"] != 1 {
t.Errorf("games_started_total by vs_ai = %v, want false:3 true:1", byKind)
}
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "vs_ai"); abandoned["false"] != 1 || abandoned["true"] != 1 {
t.Errorf("games_abandoned_total by vs_ai = %v, want false:1 true:1", abandoned)
} }
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 { if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
t.Errorf("game_replay_duration observations = %d, want 1", c) t.Errorf("game_replay_duration observations = %d, want 1", c)
@@ -78,7 +84,7 @@ func counterByAttr(t *testing.T, rm metricdata.ResourceMetrics, name, attr strin
} }
for _, dp := range sum.DataPoints { for _, dp := range sum.DataPoints {
v, _ := dp.Attributes.Value(attribute.Key(attr)) v, _ := dp.Attributes.Value(attribute.Key(attr))
out[v.AsString()] += dp.Value out[v.Emit()] += dp.Value // Emit renders any attribute type (string or bool) to a key
} }
} }
} }
+20 -10
View File
@@ -235,7 +235,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return Game{}, err return Game{}, err
} }
svc.cache.put(id, g, params.Variant.String()) svc.cache.put(id, g, params.Variant.String())
svc.metrics.recordStarted(ctx, params.Variant) svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
created, err := svc.store.GetGame(ctx, id) created, err := svc.store.GetGame(ctx, id)
if err != nil { if err != nil {
return Game{}, err return Game{}, err
@@ -303,7 +303,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
return Game{}, false, err return Game{}, false, err
} }
if created { if created {
svc.metrics.recordStarted(ctx, params.Variant) svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
} }
g, err := svc.store.GetGame(ctx, gameID) g, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
@@ -645,8 +645,9 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
// emitMove publishes the live events for a just-committed move: opponent_moved to // emitMove publishes the live events for a just-committed move: opponent_moved to
// every seat — including the actor's own account, so the mover's other devices (and // every seat — including the actor's own account, so the mover's other devices (and
// their lobby) refresh too — and your_turn to the next mover while the game is still // their lobby) refresh too — and, in human games only, your_turn to the next mover
// active. opponent_moved is in-app only (the gateway never turns it into an // while the game is still active (an honest-AI game suppresses your_turn, since the
// robot replies at once). opponent_moved is in-app only (the gateway never turns it into an
// out-of-app push), so the actor is not notified out of band about their own move. // out-of-app push), so the actor is not notified out of band about their own move.
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each // Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
// event out to all of the recipient's live streams. // event out to all of the recipient's live streams.
@@ -666,7 +667,10 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
lang := post.Variant.Language() lang := post.Variant.Language()
switch post.Status { switch post.Status {
case StatusActive: case StatusActive:
if next, ok := seatAccount(post.Seats, post.ToMove); ok { // Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn"
// notification would arrive together with the AI's move and carry no value — the
// human's UI already advances from opponent_moved. Only human games signal the mover.
if next, ok := seatAccount(post.Seats, post.ToMove); ok && !post.VsAI {
deadline := post.TurnStartedAt.Add(post.TurnTimeout) deadline := post.TurnStartedAt.Add(post.TurnTimeout)
action := rec.Action.String() action := rec.Action.String()
word := "" word := ""
@@ -674,9 +678,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
word = rec.Words[0] word = rec.Words[0]
} }
opponent := svc.displayName(ctx, post.Seats, rec.Player) opponent := svc.displayName(ctx, post.Seats, rec.Player)
if post.VsAI {
opponent = aiOpponentName // the player chose an AI game; show the robot as 🤖, not its pool name
}
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount) yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
yourTurn.Language = lang yourTurn.Language = lang
intents = append(intents, yourTurn) intents = append(intents, yourTurn)
@@ -809,7 +810,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats, cur.VsAI); err != nil { if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats, cur.VsAI); err != nil {
return false, err return false, err
} }
svc.metrics.recordAbandoned(ctx, cur.Variant) svc.metrics.recordAbandoned(ctx, cur.Variant, cur.VsAI)
return true, nil return true, nil
} }
@@ -1210,7 +1211,16 @@ func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, er
if err != nil { if err != nil {
return "", err return "", err
} }
return writeGCG(g, svc.seatNames(ctx, g), moves), nil names := svc.seatNames(ctx, g)
if g.VsAI {
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
for _, s := range g.Seats {
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
names[s.Seat] = aiPlayerName
}
}
}
return writeGCG(g, names, moves), nil
} }
// liveGame returns the live engine.Game for pre, rebuilding it from the journal // liveGame returns the live engine.Game for pre, rebuilding it from the journal
+4 -4
View File
@@ -87,10 +87,10 @@ const DefaultTurnTimeout = 24 * time.Hour
// one of AllowedTurnTimeouts (never offered in the creation UI). // one of AllowedTurnTimeouts (never offered in the creation UI).
const AIInactivityTimeout = 7 * 24 * time.Hour const AIInactivityTimeout = 7 * 24 * time.Hour
// aiOpponentName is the display marker shown for the robot in an honest-AI game's // aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
// out-of-app pushes, so the player who chose an AI game never sees the robot's // game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
// human-like pool name. The in-app UI applies the same 🤖 from the game's vs_ai flag. // UI shows 🤖 from the game's vs_ai flag).
const aiOpponentName = "🤖" const aiPlayerName = "AI"
// CreateParams describes a new game. Seats lists the seated accounts in turn // CreateParams describes a new game. Seats lists the seated accounts in turn
// order (seat 0 moves first); lobby/matchmaking assembles it in a later stage. // order (seat 0 moves first); lobby/matchmaking assembles it in a later stage.
+75
View File
@@ -5,13 +5,16 @@ package inttest
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
) )
@@ -173,6 +176,78 @@ func TestAIGameSevenDayTimeout(t *testing.T) {
} }
} }
// TestAIGameSuppressesYourTurn checks an honest-AI game emits opponent_moved but no your_turn:
// the robot replies instantly, so a "your turn" signal would arrive with the AI's move and be
// pointless (the human's UI advances from opponent_moved).
func TestAIGameSuppressesYourTurn(t *testing.T) {
ctx := context.Background()
svc := newGameService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
seed := openingSeed(t)
g, human, _ := startAIGame(t, svc, robots, true, seed) // human at seat 0, to move
hint, ok := newMirror(t, seed, 2).HintView()
if !ok || len(hint.Tiles) == 0 {
t.Fatal("no opening move for the seed")
}
if _, err := svc.SubmitPlay(ctx, g.ID, human, hint.Tiles); err != nil {
t.Fatalf("human play: %v", err)
}
var opponentMoved, yourTurn int
pub.mu.Lock()
for _, in := range pub.intents {
switch in.Kind {
case notify.KindYourTurn:
yourTurn++
case notify.KindOpponentMoved:
opponentMoved++
}
}
pub.mu.Unlock()
if yourTurn != 0 {
t.Errorf("an AI game must emit no your_turn; got %d", yourTurn)
}
if opponentMoved == 0 {
t.Error("an AI game must still emit opponent_moved so the human's UI advances")
}
}
// TestAIGameGCGLabelsRobotAI checks the GCG export of an honest-AI game labels the robot seat
// "AI" rather than leaking its human-like pool name.
func TestAIGameGCGLabelsRobotAI(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, robotID := startAIGame(t, svc, robots, true, openingSeed(t))
if _, err := svc.Resign(ctx, g.ID, human); err != nil { // finish the game so GCG export is allowed
t.Fatalf("resign: %v", err)
}
gcg, err := svc.ExportGCG(ctx, g.ID)
if err != nil {
t.Fatalf("export gcg: %v", err)
}
if !strings.Contains(gcg, " AI\n") {
t.Errorf("GCG must label the robot seat \"AI\"; got:\n%s", gcg)
}
robot, err := account.NewStore(testDB).GetByID(ctx, robotID)
if err != nil {
t.Fatalf("get robot: %v", err)
}
if strings.Contains(gcg, robot.DisplayName) {
t.Errorf("GCG must not leak the robot's pool name %q; got:\n%s", robot.DisplayName, gcg)
}
}
// TestAIGameChatAndNudgeRejected checks chat and nudge are both refused in a vs_ai game (the // TestAIGameChatAndNudgeRejected checks chat and nudge are both refused in a vs_ai game (the
// opponent is a robot), even though the game reports status 'active'. // opponent is a robot), even though the game reports status 'active'.
func TestAIGameChatAndNudgeRejected(t *testing.T) { func TestAIGameChatAndNudgeRejected(t *testing.T) {
@@ -406,7 +406,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
ID: g.ID.String(), Variant: g.Variant.String(), DictVersion: g.DictVersion, ID: g.ID.String(), Variant: g.Variant.String(), DictVersion: g.DictVersion,
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason, Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
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), VsAI: g.VsAI,
} }
// Resolve seats and detect robot seats; capture the human opponent's timezone, which // Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA. // anchors the robot's sleep window for the next-move ETA.
@@ -1021,7 +1021,7 @@ func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
// gameRow projects a game summary into its console row. // gameRow projects a game summary into its console row.
func gameRow(g game.Game) adminconsole.GameRow { func gameRow(g game.Game) adminconsole.GameRow {
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt)} return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI}
} }
// trimForm returns the trimmed value of a posted form field. // trimForm returns the trimmed value of a posted form field.
+15 -5
View File
@@ -4,24 +4,34 @@
"tags": ["scrabble"], "tags": ["scrabble"],
"timezone": "", "timezone": "",
"schemaVersion": 39, "schemaVersion": 39,
"version": 2, "version": 3,
"refresh": "30s", "refresh": "30s",
"time": { "from": "now-24h", "to": "now" }, "time": { "from": "now-24h", "to": "now" },
"panels": [ "panels": [
{ {
"type": "timeseries", "type": "timeseries",
"title": "Games started / abandoned (rate by variant)", "title": "Games started / abandoned — human (rate by variant)",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [ "targets": [
{ "refId": "A", "expr": "sum(rate(games_started_total[15m])) by (variant)", "legendFormat": "started {{variant}}" }, { "refId": "A", "expr": "sum(rate(games_started_total{vs_ai=\"false\"}[15m])) by (variant)", "legendFormat": "started {{variant}}" },
{ "refId": "B", "expr": "sum(rate(games_abandoned_total[15m])) by (variant)", "legendFormat": "abandoned {{variant}}" } { "refId": "B", "expr": "sum(rate(games_abandoned_total{vs_ai=\"false\"}[15m])) by (variant)", "legendFormat": "abandoned {{variant}}" }
]
},
{
"type": "timeseries",
"title": "Games started / abandoned — AI (rate by variant)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{ "refId": "A", "expr": "sum(rate(games_started_total{vs_ai=\"true\"}[15m])) by (variant)", "legendFormat": "started {{variant}}" },
{ "refId": "B", "expr": "sum(rate(games_abandoned_total{vs_ai=\"true\"}[15m])) by (variant)", "legendFormat": "abandoned {{variant}}" }
] ]
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Robot games finished (rate)", "title": "Robot games finished (rate)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(robot_games_finished_total[15m]))", "legendFormat": "robot games" }] "targets": [{ "refId": "A", "expr": "sum(rate(robot_games_finished_total[15m]))", "legendFormat": "robot games" }]
}, },
+5 -1
View File
@@ -378,7 +378,11 @@ the robot package), with the periodic driver as the fallback. There is **no shor
timeout**; instead the game is created with `turn_timeout_secs = AIInactivityTimeout` (**7 timeout**; instead the game is created with `turn_timeout_secs = AIInactivityTimeout` (**7
days**) and the existing turn-timeout sweeper resigns the overdue seat — since the robot moves days**) and the existing turn-timeout sweeper resigns the overdue seat — since the robot moves
at once, only the human is ever on the clock, so the per-turn timeout doubles as the at once, only the human is ever on the clock, so the per-turn timeout doubles as the
"abandoned after 7 days of inactivity → loss" rule with no new column or sweeper. "abandoned after 7 days of inactivity → loss" rule with no new column or sweeper. An AI
game emits **no `your_turn`** (the instant reply makes it redundant; `opponent_moved`
still advances the UI), its **GCG export labels the robot seat "AI"**, and the
games-started / -abandoned metrics carry a **`vs_ai`** attribute so AI and human games
chart separately (the admin `/games` list and game card also show the AI flag).
The robot keeps **no per-game state**: every choice is derived deterministically The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver