feat: AI-game refinements (GCG, your_turn, admin, metrics)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s

Follow-ups on the honest-AI game, same PR:
- GCG export labels the robot seat "AI" instead of its pool name (ExportGCG
  overrides via accounts.IsRobot); the in-app 🤖 is unchanged.
- vs_ai games emit no your_turn (the robot replies instantly, so it would be
  redundant); opponent_moved still advances the UI.
- Admin console shows the AI flag: a 🤖 column in /games and an "AI game" line
  on the game card (GameRow/GameDetailView gain VsAI).
- games_started_total / games_abandoned_total gain a vs_ai attribute; the
  Grafana Game-domain dashboard splits started/abandoned into human and AI
  panels.

Tests: metrics unit (vs_ai split); integration (no your_turn, GCG "AI").
This commit is contained in:
Ilia Denisov
2026-06-15 20:49:49 +02:00
parent aa765a0c06
commit d2a9441287
12 changed files with 166 additions and 41 deletions
+20 -10
View File
@@ -235,7 +235,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return Game{}, err
}
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)
if err != nil {
return Game{}, err
@@ -303,7 +303,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
return Game{}, false, err
}
if created {
svc.metrics.recordStarted(ctx, params.Variant)
svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
}
g, err := svc.store.GetGame(ctx, gameID)
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
// 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
// active. opponent_moved is in-app only (the gateway never turns it into an
// their lobby) refresh too — and, in human games only, your_turn to the next mover
// 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.
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
// 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()
switch post.Status {
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)
action := rec.Action.String()
word := ""
@@ -674,9 +678,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
word = rec.Words[0]
}
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.Language = lang
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 {
return false, err
}
svc.metrics.recordAbandoned(ctx, cur.Variant)
svc.metrics.recordAbandoned(ctx, cur.Variant, cur.VsAI)
return true, nil
}
@@ -1210,7 +1211,16 @@ func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, er
if err != nil {
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