feat: honest AI opponent in quick game
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
New Game's quick game gains an explicit opponent selector — 🤖 AI (default) or 👤 Random player. AI starts a game seated with a pooled robot that joins and moves at once: no per-move timeout (a 7-day inactivity loss reusing the turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown as 🤖 everywhere. The random path (disguised robot) is unchanged. Driven by one game flag (games.vs_ai), set only on AI-started games so the disguised path is never revealed; Matchmaker.StartVsAI seats the robot directly (no open pool); the robot replies event-driven via the game service's after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView.
This commit is contained in:
@@ -39,8 +39,14 @@ type Service struct {
|
||||
clock func() time.Time
|
||||
rng func() int64
|
||||
pub notify.Publisher
|
||||
metrics *gameMetrics
|
||||
log *zap.Logger
|
||||
// aiTrigger, when set, is called after an honest-AI game is created or advanced and
|
||||
// is still on a robot's potential turn, so the robot replies at once instead of
|
||||
// waiting for the next driver scan. It is fire-and-forget (the robot package wires
|
||||
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
|
||||
// these games). Kept as a func so the game package never imports the robot package.
|
||||
aiTrigger func(gameID uuid.UUID)
|
||||
metrics *gameMetrics
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewService constructs a Service. store and accounts wrap the same pool;
|
||||
@@ -75,6 +81,23 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetAITrigger installs the honest-AI fast-move hook called when a vs_ai game is
|
||||
// created or advanced and a robot may now be on the clock. It must be called during
|
||||
// startup wiring; the default (nil) leaves only the periodic driver scan. The robot
|
||||
// package wires its asynchronous TriggerMove here.
|
||||
func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
|
||||
svc.aiTrigger = trigger
|
||||
}
|
||||
|
||||
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
|
||||
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
|
||||
// installed, so callers can invoke it unconditionally after a create or commit.
|
||||
func (svc *Service) triggerAI(g Game) {
|
||||
if svc.aiTrigger != nil && g.VsAI && g.Status == StatusActive {
|
||||
svc.aiTrigger(g.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// activeVersion returns the dictionary version new games currently pin.
|
||||
func (svc *Service) activeVersion() string {
|
||||
svc.verMu.RLock()
|
||||
@@ -143,11 +166,17 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
|
||||
}
|
||||
timeout := params.TurnTimeout
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTurnTimeout
|
||||
}
|
||||
if !allowedTimeout(timeout) {
|
||||
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
|
||||
if params.VsAI {
|
||||
// Honest-AI games use the fixed 7-day inactivity clock, not a user-chosen value
|
||||
// from AllowedTurnTimeouts (it is never offered in the creation UI).
|
||||
timeout = AIInactivityTimeout
|
||||
} else {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTurnTimeout
|
||||
}
|
||||
if !allowedTimeout(timeout) {
|
||||
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
|
||||
}
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(params.Seats))
|
||||
for _, id := range params.Seats {
|
||||
@@ -200,13 +229,21 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
hintsPerPlayer: params.HintsPerPlayer,
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
vsAI: params.VsAI,
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.cache.put(id, g, params.Variant.String())
|
||||
svc.metrics.recordStarted(ctx, params.Variant)
|
||||
return svc.store.GetGame(ctx, id)
|
||||
created, err := svc.store.GetGame(ctx, id)
|
||||
if err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
// Honest-AI game seated with a robot: if the robot moves first, reply at once
|
||||
// (the periodic driver is the fallback). No-op for every human-only game.
|
||||
svc.triggerAI(created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
|
||||
@@ -368,7 +405,7 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats)
|
||||
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats, pre.VsAI)
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
@@ -516,7 +553,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats)
|
||||
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats, pre.VsAI)
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
@@ -546,7 +583,7 @@ func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uui
|
||||
// cursor and scores, and on a game-ending move the finish stamp and statistics.
|
||||
// On a persistence failure it evicts the now-divergent live game so the next
|
||||
// access rebuilds from the journal.
|
||||
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat) (Game, error) {
|
||||
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat, vsAI bool) (Game, error) {
|
||||
now := svc.clock()
|
||||
logLen := len(g.Log())
|
||||
scores := make([]int, g.Players())
|
||||
@@ -577,12 +614,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
c.endReason = "timeout"
|
||||
}
|
||||
c.winner = g.Result().Winner
|
||||
statSeats, err := svc.nonGuestSeats(ctx, seats)
|
||||
if err != nil {
|
||||
svc.cache.remove(gameID)
|
||||
return Game{}, err
|
||||
// Honest-AI games are practice and never touch player statistics (like guest
|
||||
// games); a human game records them for its non-guest seats.
|
||||
if !vsAI {
|
||||
statSeats, err := svc.nonGuestSeats(ctx, seats)
|
||||
if err != nil {
|
||||
svc.cache.remove(gameID)
|
||||
return Game{}, err
|
||||
}
|
||||
c.stats = buildStats(g, statSeats)
|
||||
}
|
||||
c.stats = buildStats(g, statSeats)
|
||||
}
|
||||
if err := svc.store.CommitMove(ctx, c); err != nil {
|
||||
svc.cache.remove(gameID)
|
||||
@@ -596,6 +637,9 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
return Game{}, err
|
||||
}
|
||||
svc.emitMove(ctx, post, rec, g.BagLen())
|
||||
// Honest-AI game still going: nudge the robot to take its turn at once (the
|
||||
// periodic driver is the fallback). No-op for human games and finished ones.
|
||||
svc.triggerAI(post)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
@@ -630,6 +674,9 @@ 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)
|
||||
@@ -759,7 +806,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
|
||||
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)
|
||||
@@ -996,6 +1043,20 @@ func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]Rob
|
||||
return svc.store.RobotTurns(ctx, robotIDs)
|
||||
}
|
||||
|
||||
// RobotTurn returns the robot driver's view of a single active game seating one of
|
||||
// robotIDs, and true, or false when the game holds no pooled robot or is no longer
|
||||
// active. It backs the honest-AI fast-move trigger, which drives just the one game.
|
||||
func (svc *Service) RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (RobotTurn, bool, error) {
|
||||
return svc.store.RobotTurnByGame(ctx, gameID, robotIDs)
|
||||
}
|
||||
|
||||
// VsAI reports whether a game is an honest-AI game (games.vs_ai). The social
|
||||
// service uses it to reject chat and nudge in AI games (which otherwise report
|
||||
// status 'active').
|
||||
func (svc *Service) VsAI(ctx context.Context, gameID uuid.UUID) (bool, error) {
|
||||
return svc.store.GameVsAI(ctx, gameID)
|
||||
}
|
||||
|
||||
// GameState returns a seated player's view of the game: the shared summary plus
|
||||
// their private rack, the bag size and their remaining hint budget.
|
||||
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
|
||||
|
||||
Reference in New Issue
Block a user