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:
@@ -41,6 +41,8 @@ type gameInsert struct {
|
||||
dropoutTiles string
|
||||
// multipleWordsPerTurn false selects the single-word rule for the game.
|
||||
multipleWordsPerTurn bool
|
||||
// vsAI marks an honest-AI game (games.vs_ai).
|
||||
vsAI bool
|
||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||
// empty string defaults to StatusActive.
|
||||
@@ -130,9 +132,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn)
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
@@ -916,7 +918,7 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
|
||||
}
|
||||
stmt := postgres.SELECT(
|
||||
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt,
|
||||
table.Games.MoveCount, table.Games.Seed,
|
||||
table.Games.MoveCount, table.Games.Seed, table.Games.VsAi,
|
||||
table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).FROM(
|
||||
table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
|
||||
@@ -934,24 +936,85 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
|
||||
}
|
||||
out := make([]RobotTurn, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// The filter matches only the robot's (non-null) seat, so AccountID is set.
|
||||
robotID := uuid.Nil
|
||||
if r.GamePlayers.AccountID != nil {
|
||||
robotID = *r.GamePlayers.AccountID
|
||||
}
|
||||
out = append(out, RobotTurn{
|
||||
GameID: r.Games.GameID,
|
||||
RobotID: robotID,
|
||||
RobotSeat: int(r.GamePlayers.Seat),
|
||||
ToMove: int(r.Games.ToMove),
|
||||
TurnStartedAt: r.Games.TurnStartedAt,
|
||||
MoveCount: int(r.Games.MoveCount),
|
||||
Seed: r.Games.Seed,
|
||||
})
|
||||
out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RobotTurnByGame returns the robot turn for a single active game — the seat held by
|
||||
// one of ids (the robot pool) — and true, or false when the game is not active, holds
|
||||
// no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which
|
||||
// drives one game at once rather than scanning the whole pool (RobotTurns).
|
||||
func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return RobotTurn{}, false, nil
|
||||
}
|
||||
exprs := make([]postgres.Expression, len(ids))
|
||||
for i, id := range ids {
|
||||
exprs[i] = postgres.UUID(id)
|
||||
}
|
||||
stmt := postgres.SELECT(
|
||||
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt,
|
||||
table.Games.MoveCount, table.Games.Seed, table.Games.VsAi,
|
||||
table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).FROM(
|
||||
table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
|
||||
).WHERE(
|
||||
table.Games.GameID.EQ(postgres.UUID(gameID)).
|
||||
AND(table.Games.Status.EQ(postgres.String(StatusActive))).
|
||||
AND(table.GamePlayers.AccountID.IN(exprs...)),
|
||||
).LIMIT(1)
|
||||
|
||||
var rows []struct {
|
||||
model.Games
|
||||
model.GamePlayers
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return RobotTurn{}, false, fmt.Errorf("game: robot turn by game: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return RobotTurn{}, false, nil
|
||||
}
|
||||
return robotTurnFrom(rows[0].Games, rows[0].GamePlayers), true, nil
|
||||
}
|
||||
|
||||
// robotTurnFrom projects a games row joined with the robot's seat into a RobotTurn.
|
||||
// The query matches only the robot's (non-null) seat, so AccountID is set.
|
||||
func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn {
|
||||
robotID := uuid.Nil
|
||||
if p.AccountID != nil {
|
||||
robotID = *p.AccountID
|
||||
}
|
||||
return RobotTurn{
|
||||
GameID: g.GameID,
|
||||
RobotID: robotID,
|
||||
RobotSeat: int(p.Seat),
|
||||
ToMove: int(g.ToMove),
|
||||
TurnStartedAt: g.TurnStartedAt,
|
||||
MoveCount: int(g.MoveCount),
|
||||
Seed: g.Seed,
|
||||
VsAI: g.VsAi,
|
||||
}
|
||||
}
|
||||
|
||||
// GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap
|
||||
// single-column read for the social chat/nudge gate, which must reject both in an
|
||||
// AI game even though it reports status 'active'. ErrNotFound when the game is gone.
|
||||
func (s *Store) GameVsAI(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Games.VsAi).
|
||||
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 false, ErrNotFound
|
||||
}
|
||||
return false, fmt.Errorf("game: get vs_ai %s: %w", id, err)
|
||||
}
|
||||
return row.VsAi, nil
|
||||
}
|
||||
|
||||
// GameSeed returns the bag seed a game was dealt from, used to replay it. The
|
||||
// seed is server-only state and never travels in the public Game view.
|
||||
func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||
@@ -1046,6 +1109,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||
out.VsAI = g.VsAi
|
||||
if g.EndReason != nil {
|
||||
out.EndReason = *g.EndReason
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user