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
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:
@@ -101,15 +101,22 @@ func phaseOf(moveCount int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// recordStarted counts one started game of variant.
|
||||
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
||||
m.started.Add(ctx, 1, variantAttr(v))
|
||||
// recordStarted counts one started game of variant, split by whether it is an
|
||||
// honest-AI game (the vs_ai attribute), so AI and human games chart separately.
|
||||
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
|
||||
// variant.
|
||||
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant) {
|
||||
m.abandoned.Add(ctx, 1, variantAttr(v))
|
||||
// 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, vsAI bool) {
|
||||
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
|
||||
|
||||
@@ -20,10 +20,12 @@ func TestGameMetrics(t *testing.T) {
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
m := newGameMetrics(meter)
|
||||
|
||||
m.recordStarted(ctx, engine.VariantEnglish)
|
||||
m.recordStarted(ctx, engine.VariantEnglish)
|
||||
m.recordStarted(ctx, engine.VariantRussianScrabble)
|
||||
m.recordAbandoned(ctx, engine.VariantErudit)
|
||||
m.recordStarted(ctx, engine.VariantEnglish, false)
|
||||
m.recordStarted(ctx, engine.VariantEnglish, false)
|
||||
m.recordStarted(ctx, engine.VariantRussianScrabble, false)
|
||||
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.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
|
||||
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")
|
||||
if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 {
|
||||
t.Errorf("games_started_total = %v, want scrabble_en:2 scrabble_ru:1", started)
|
||||
if started["scrabble_en"] != 3 || started["scrabble_ru"] != 1 {
|
||||
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 {
|
||||
t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned)
|
||||
// The vs_ai attribute splits AI from human games for the per-kind Grafana panels.
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,10 +87,10 @@ const DefaultTurnTimeout = 24 * time.Hour
|
||||
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
||||
const AIInactivityTimeout = 7 * 24 * time.Hour
|
||||
|
||||
// aiOpponentName is the display marker shown for the robot in an honest-AI game's
|
||||
// out-of-app pushes, so the player who chose an AI game never sees the robot's
|
||||
// human-like pool name. The in-app UI applies the same 🤖 from the game's vs_ai flag.
|
||||
const aiOpponentName = "🤖"
|
||||
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
||||
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
||||
// UI shows 🤖 from the game's vs_ai flag).
|
||||
const aiPlayerName = "AI"
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user