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
56 changed files with 901 additions and 86 deletions
Showing only changes of commit aa765a0c06 - Show all commits
+37
View File
@@ -31,6 +31,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** | | OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** | | AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## Key findings (these reshaped the raw list — read before starting a phase)
@@ -466,3 +467,39 @@ Then Stage 18.
`lobby.tournaments` / `lobby.soon`). No backend/wire/history/GCG change. `lobby.tournaments` / `lobby.soon`). No backend/wire/history/GCG change.
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md` - **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
(+`_ru`). Regression gate: UI `check` + unit + build + bundle budget + e2e (Chromium & WebKit). (+`_ru`). Regression gate: UI `check` + unit + build + bundle budget + e2e (Chromium & WebKit).
- **AI — Honest AI opponent in quick game** (owner ad-hoc, not on the raw TODO list): a second quick-game
opponent the player *knowingly* chooses, distinct from the disguised robot of the random/open path
(which is kept as-is). New Game's quick-game mode replaces the "auto-match" subtitle with a two-button
selector **🤖 AI / 👤 Random player** (the `.seg`/`.opt` segmented style, AI the default); for AI the
move-clock line reads "Loss after 7 days of inactivity" and the "searching" hint is hidden.
- **Locked decisions (interview):** AI move is **event-driven** (the robot replies the instant the
player's move commits; the 30 s driver is the fallback); AI games **do not touch `account_stats`**
(practice, like guests); the **Stage 5 strength logic is reused unchanged** (`playToWin` 40 % from the
seed + margin band); **no per-move timeout — a 7-day inactivity loss** instead; the 7-day line lives on
the New Game screen (the in-game screen has no move-clock line); chat + nudge **disabled**, word-check
kept, add-friend never drawn, opponent shown as **🤖** everywhere.
- **The 7-day rule reuses the existing per-turn timeout:** an AI game is created with
`turn_timeout_secs = AIInactivityTimeout` (7 days) and the existing 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 *is*
the abandon rule (no new column, no new sweeper).
- **One game flag drives everything:** `games.vs_ai` (edited into the R1 baseline — pre-release, so a
contour DB wipe after merge). It is set **only** on AI-started games, so a robot-filled random game keeps
`vs_ai=false` and the disguised opponent is never revealed; the UI derives 🤖 / the gates **from the flag,
never from the opponent account**. New backend path `Matchmaker.StartVsAI` (picks a pooled robot via the
existing `Pick`, creates an **active** seated game via `game.Service.Create`, random seat order) — the AI
request never enters the open pool, so the open-game reaper never touches it. The robot driver gains a
`vs_ai` branch (no sleep, no proactive nudge, zero delay) and a focused `DriveGame`/`TriggerMove` fast
path wired from the game service's after-create/after-commit hook (`SetAITrigger`, a func value so the
game package never imports the robot package). Chat/nudge gated by a new `social` `VsAI` check
(`ErrGameVsAI` → 409 `ai_game`); statistics skipped in `commit` when `vs_ai`.
- **Wire:** `EnqueueRequest` += `vs_ai`, `GameView` += `vs_ai` (trailing FB fields, regenerated Go + TS),
threaded through the backend DTO, the gateway transcode and the `pkg/wire` + `notify` builders.
- **Tests:** `lobby` unit (StartVsAI seats a robot + flags the game; empty pool leaves no game); backend
integration (`ai_game_test.go`: active+seated+vs_ai+7-day clock, robot moves immediately, stats skipped,
7-day timeout resigns the human, chat/nudge rejected); UI codec round-trip (`vs_ai` on enqueue + game
view); e2e (an AI game shows 🤖, no "searching", chat disabled, the dictionary still works) + the
existing quick-match e2e updated to pick **Random player** (the default is now AI).
- **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`,
`backend/README.md`, Go Doc comments.
+10
View File
@@ -59,6 +59,16 @@ elapsed, and the waiting starter is told an opponent took the seat by an in-app
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and **opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
re-enables resign and chat in place. re-enables resign and chat in place.
The same robot also backs an **honest-AI quick game** (`games.vs_ai`), the alternative to the random
path that the player chooses on New Game. `Matchmaker.StartVsAI` picks a pooled robot and creates a
game **already seated and active** (random seat order) — it never enters the open pool. The robot
driver has a `vs_ai` branch (no sleep, no proactive nudge, zero delay) plus a focused
`DriveGame`/`TriggerMove` fast path wired from the game service's after-create/after-commit hook
(`SetAITrigger`), so the robot replies the instant the player moves. AI games keep the same strength
(`playToWin`), have **no per-move timeout** (`turn_timeout_secs = AIInactivityTimeout`, 7 days, so an
abandoned game is lost after a week of inactivity — only the human is ever on the clock), record **no
statistics** (skipped in `commit`), and disable chat/nudge (`social` `VsAI``ErrGameVsAI`).
The backend opens to the edge. The route groups gain their first The backend opens to the edge. The route groups gain their first
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a `/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
+3
View File
@@ -178,6 +178,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
if err := robots.EnsurePool(ctx); err != nil { if err := robots.EnsurePool(ctx); err != nil {
return fmt.Errorf("provision robot pool: %w", err) return fmt.Errorf("provision robot pool: %w", err)
} }
// Honest-AI fast path: a move in a vs_ai game triggers the robot's reply at once
// (the periodic driver below is the fallback). Set after the pool is provisioned.
games.SetAITrigger(robots.TriggerMove)
go robots.Run(ctx, cfg.Robot.DriveInterval) go robots.Run(ctx, cfg.Robot.DriveInterval)
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval)) logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
+1
View File
@@ -51,6 +51,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
+78 -17
View File
@@ -39,8 +39,14 @@ type Service struct {
clock func() time.Time clock func() time.Time
rng func() int64 rng func() int64
pub notify.Publisher pub notify.Publisher
metrics *gameMetrics // aiTrigger, when set, is called after an honest-AI game is created or advanced and
log *zap.Logger // 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; // 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. // activeVersion returns the dictionary version new games currently pin.
func (svc *Service) activeVersion() string { func (svc *Service) activeVersion() string {
svc.verMu.RLock() 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) return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
} }
timeout := params.TurnTimeout timeout := params.TurnTimeout
if timeout == 0 { if params.VsAI {
timeout = DefaultTurnTimeout // 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).
if !allowedTimeout(timeout) { timeout = AIInactivityTimeout
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout) } 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)) seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range 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, hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI,
} }
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil { if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
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)
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 // 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 { if err != nil {
return MoveResult{}, err 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 { if err != nil {
return MoveResult{}, err return MoveResult{}, err
} }
@@ -516,7 +553,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if err != nil { if err != nil {
return MoveResult{}, err 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 { if err != nil {
return MoveResult{}, err 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. // 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 // On a persistence failure it evicts the now-divergent live game so the next
// access rebuilds from the journal. // 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() now := svc.clock()
logLen := len(g.Log()) logLen := len(g.Log())
scores := make([]int, g.Players()) 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.endReason = "timeout"
} }
c.winner = g.Result().Winner c.winner = g.Result().Winner
statSeats, err := svc.nonGuestSeats(ctx, seats) // Honest-AI games are practice and never touch player statistics (like guest
if err != nil { // games); a human game records them for its non-guest seats.
svc.cache.remove(gameID) if !vsAI {
return Game{}, err 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 { if err := svc.store.CommitMove(ctx, c); err != nil {
svc.cache.remove(gameID) svc.cache.remove(gameID)
@@ -596,6 +637,9 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
return Game{}, err return Game{}, err
} }
svc.emitMove(ctx, post, rec, g.BagLen()) 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 return post, nil
} }
@@ -630,6 +674,9 @@ 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)
@@ -759,7 +806,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
if err != nil { if err != nil {
return false, err 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 return false, err
} }
svc.metrics.recordAbandoned(ctx, cur.Variant) 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) 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 // 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. // their private rack, the bag size and their remaining hint budget.
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) { func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
+81 -17
View File
@@ -41,6 +41,8 @@ type gameInsert struct {
dropoutTiles string dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game. // multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool 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 // 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 // seated game, StatusOpen for an auto-match game still awaiting an opponent. An
// empty string defaults to StatusActive. // 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.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, 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, ).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 { if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err) 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( stmt := postgres.SELECT(
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, 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, table.GamePlayers.Seat, table.GamePlayers.AccountID,
).FROM( ).FROM(
table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)), 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)) out := make([]RobotTurn, 0, len(rows))
for _, r := range rows { for _, r := range rows {
// The filter matches only the robot's (non-null) seat, so AccountID is set. out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
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,
})
} }
return out, nil 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 // 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. // 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) { 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, UpdatedAt: g.UpdatedAt,
} }
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.VsAI = g.VsAi
if g.EndReason != nil { if g.EndReason != nil {
out.EndReason = *g.EndReason out.EndReason = *g.EndReason
} }
+23
View File
@@ -79,6 +79,19 @@ var AllowedTurnTimeouts = []time.Duration{
// zero (the owner's default: a full day). // zero (the owner's default: a full day).
const DefaultTurnTimeout = 24 * time.Hour const DefaultTurnTimeout = 24 * time.Hour
// AIInactivityTimeout is the move clock for an honest-AI game (CreateParams.VsAI).
// These games have no short per-move timeout; instead the player loses a game they
// abandon after seven days of inactivity. Because the robot moves immediately, only
// the human is ever on the clock, so the per-turn timeout doubles as the abandon
// rule (the sweeper resigns the overdue seat). It is set programmatically and is not
// 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 = "🤖"
// 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.
type CreateParams struct { type CreateParams struct {
@@ -92,6 +105,10 @@ type CreateParams struct {
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule — // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule —
// only the main word is validated and scored. Russian games default to false. // only the main word is validated and scored. Russian games default to false.
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
// VsAI creates an honest-AI game against a pooled robot (see games.vs_ai): the
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
VsAI bool
} }
// Game is the persisted state of a match: the games row joined with its seats. // Game is the persisted state of a match: the games row joined with its seats.
@@ -115,6 +132,9 @@ type Game struct {
FinishedAt *time.Time FinishedAt *time.Time
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool
} }
// Seat is one player's standing in a game. // Seat is one player's standing in a game.
@@ -220,6 +240,9 @@ type RobotTurn struct {
TurnStartedAt time.Time TurnStartedAt time.Time
MoveCount int MoveCount int
Seed int64 Seed int64
// VsAI is true when the game is an honest-AI game: the driver then makes the
// robot move immediately, with no sleep window and no proactive nudge.
VsAI bool
} }
// Complaint is a word-check complaint in the admin review queue. It is filed // Complaint is a word-check complaint in the admin review queue. It is filed
+194
View File
@@ -0,0 +1,194 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
)
// The AI-game suite covers the honest-AI quick game: a game created already seated with a
// pooled robot (vs_ai), in which the robot moves immediately, the move clock is the 7-day
// inactivity rule, chat and nudge are disabled and no statistics are recorded.
// startAIGame creates an active vs_ai game seating a fresh human and a pooled robot, the human
// at seat 0 when humanFirst (otherwise the robot moves first), and returns it with the two ids.
func startAIGame(t *testing.T, svc *game.Service, robots *robot.Service, humanFirst bool, seed int64) (game.Game, uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
human := provisionAccount(t)
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick robot: %v", err)
}
seats := []uuid.UUID{human, robotID}
if !humanFirst {
seats = []uuid.UUID{robotID, human}
}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, VsAI: true, Seed: seed,
HintsAllowed: true, HintsPerPlayer: 1, MultipleWordsPerTurn: true,
})
if err != nil {
t.Fatalf("create AI game: %v", err)
}
return g, human, robotID
}
// TestAIGameStartSeatsRobotActive checks the matchmaker's AI path creates an active game already
// seated with a robot (no open/wait phase), flagged vs_ai and on the 7-day inactivity clock.
func TestAIGameStartSeatsRobotActive(t *testing.T) {
ctx := context.Background()
robots := newRobotService(t, newGameService())
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
mm := newMatchmaker(t, robots, time.Minute, 0)
human := provisionAccount(t)
res, err := mm.StartVsAI(ctx, human, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("start vs AI: %v", err)
}
g := res.Game
if !res.Matched || g.Status != game.StatusActive {
t.Fatalf("AI game = (matched %v, status %q), want (true, active)", res.Matched, g.Status)
}
if !g.VsAI {
t.Error("an AI game must be flagged vs_ai")
}
if g.TurnTimeout != game.AIInactivityTimeout {
t.Errorf("AI game move clock = %s, want %s", g.TurnTimeout, game.AIInactivityTimeout)
}
var hasHuman, hasRobot bool
for _, s := range g.Seats {
switch {
case s.AccountID == human:
hasHuman = true
case s.AccountID != uuid.Nil:
hasRobot = true
}
}
if len(g.Seats) != 2 || !hasHuman || !hasRobot {
t.Errorf("AI game seats = %+v, want exactly the human and a robot (no empty seat)", g.Seats)
}
}
// TestAIRobotMovesImmediately checks the robot in a vs_ai game moves the instant it is its turn,
// with no sampled delay and no sleep window (the honest-AI fast path the trigger drives).
func TestAIRobotMovesImmediately(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, _, _ := startAIGame(t, svc, robots, false, openingSeed(t)) // robot at seat 0, to move
if err := robots.DriveGame(ctx, g.ID, time.Now()); err != nil {
t.Fatalf("drive AI game: %v", err)
}
after, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if after.MoveCount != 1 {
t.Errorf("after one drive the robot's first move must be in (move count %d, want 1)", after.MoveCount)
}
if after.ToMove == 0 {
t.Error("after the robot's move it must be the human's turn")
}
}
// TestAIGameStatsSkipped checks a vs_ai game records no statistics: a human resign (a loss in a
// normal game) leaves the human with no stats row.
func TestAIGameStatsSkipped(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, _ := startAIGame(t, svc, robots, true, openingSeed(t))
if _, err := svc.Resign(ctx, g.ID, human); err != nil {
t.Fatalf("resign: %v", err)
}
if _, _, _, _, _, found := readStats(t, human); found {
t.Error("an AI game must not record statistics for the human")
}
}
// TestAIGameSevenDayTimeout checks the reused turn-timeout sweeper enforces the 7-day inactivity
// rule: nothing fires within seven days, and past it the human on the clock is resigned and loses.
func TestAIGameSevenDayTimeout(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)) // human at seat 0, to move
// Six days idle: well under the 7-day clock, so the sweeper leaves it active.
setTurnStarted(t, g.ID, time.Now().Add(-6*24*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
if mid, _ := svc.GameByID(ctx, g.ID); mid.Status != game.StatusActive {
t.Fatalf("at six days idle the AI game must still be active, got %q", mid.Status)
}
// Eight days idle: past the clock, so the human (on the clock) is timed out and loses.
setTurnStarted(t, g.ID, time.Now().Add(-8*24*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
done, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if done.Status != game.StatusFinished || done.EndReason != "timeout" {
t.Fatalf("after seven days idle: (status %q, reason %q), want (finished, timeout)", done.Status, done.EndReason)
}
for _, s := range done.Seats {
if s.AccountID == robotID && !s.IsWinner {
t.Error("the robot must win when the human abandons the game")
}
if s.AccountID == human && s.IsWinner {
t.Error("the abandoning human must not win")
}
}
if _, _, _, _, _, found := readStats(t, human); found {
t.Error("a timed-out AI game must not record statistics")
}
}
// 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'.
func TestAIGameChatAndNudgeRejected(t *testing.T) {
ctx := context.Background()
svc := newGameService()
soc := newSocialService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, _ := startAIGame(t, svc, robots, true, openingSeed(t))
if _, err := soc.PostMessage(ctx, g.ID, human, "hi robot", ""); !errors.Is(err, social.ErrGameVsAI) {
t.Errorf("chat in an AI game = %v, want ErrGameVsAI", err)
}
if _, err := soc.Nudge(ctx, g.ID, human); !errors.Is(err, social.ErrGameVsAI) {
t.Errorf("nudge in an AI game = %v, want ErrGameVsAI", err)
}
}
+40
View File
@@ -22,6 +22,9 @@ type GameMatcher interface {
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error)
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error) ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
// Create seats the given accounts in an active game at once; the AI-match path uses
// it to start a game already seated with a robot (no open/wait phase).
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
} }
// Matchmaker turns an auto-match enqueue into a real game the player enters at once: // Matchmaker turns an auto-match enqueue into a real game the player enters at once:
@@ -96,6 +99,29 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
return EnqueueResult{Matched: joined, Game: g}, nil return EnqueueResult{Matched: joined, Game: g}, nil
} }
// StartVsAI creates an honest-AI game for accountID against a pooled robot, returning
// the game the player enters at once. Unlike Enqueue it never opens or waits: it picks
// a robot for the variant, seats the human and the robot in a random order (seat 0
// moves first), and creates an active game flagged vs_ai. The flag makes the robot
// reply immediately and suppresses chat, nudge and statistics; the robot's first move
// (when it is seated first) follows on the game service's fast-move trigger.
func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
robotID, err := m.robots.Pick(variant)
if err != nil {
return EnqueueResult{}, err
}
params := aiMatchParams(variant, multipleWords)
params.Seats = []uuid.UUID{accountID, robotID}
if rand.IntN(2) == 1 {
params.Seats = []uuid.UUID{robotID, accountID}
}
g, err := m.games.Create(ctx, params)
if err != nil {
return EnqueueResult{}, err
}
return EnqueueResult{Matched: true, Game: g}, nil
}
// RunReaper substitutes a robot for any open game past its wait window, scanning every // RunReaper substitutes a robot for any open game past its wait window, scanning every
// interval until ctx is cancelled. It is started once from main. // interval until ctx is cancelled. It is started once from main.
func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
@@ -191,3 +217,17 @@ func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreatePara
MultipleWordsPerTurn: multipleWords, MultipleWordsPerTurn: multipleWords,
} }
} }
// aiMatchParams builds the create parameters for an honest-AI two-player game: the
// same casual auto-match defaults plus the vs_ai flag. The 7-day inactivity clock is
// applied by the game service for vs_ai games, so TurnTimeout is left zero; the caller
// fills Seats with the human and the picked robot.
func aiMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
return game.CreateParams{
Variant: variant,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
MultipleWordsPerTurn: multipleWords,
VsAI: true,
}
}
+54
View File
@@ -3,6 +3,7 @@ package lobby
import ( import (
"context" "context"
"errors" "errors"
"slices"
"testing" "testing"
"time" "time"
@@ -31,6 +32,11 @@ type stubMatcher struct {
attached bool attached bool
attachErr error attachErr error
attachedGames []uuid.UUID attachedGames []uuid.UUID
createGame game.Game
createErr error
createCalls int
createParams game.CreateParams
} }
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) { func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
@@ -57,6 +63,12 @@ func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.Pl
return notify.PlayerState{}, nil return notify.PlayerState{}, nil
} }
func (s *stubMatcher) Create(_ context.Context, params game.CreateParams) (game.Game, error) {
s.createCalls++
s.createParams = params
return s.createGame, s.createErr
}
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an // fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an
// empty pool. It records the variant of the last substitution request. // empty pool. It records the variant of the last substitution request.
type fakeRobots struct { type fakeRobots struct {
@@ -135,6 +147,48 @@ func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) {
} }
} }
func TestStartVsAISeatsRobotAndFlagsGame(t *testing.T) {
human, robotID := uuid.New(), uuid.New()
m := &stubMatcher{createGame: twoSeatGame(human, robotID)}
robots := &fakeRobots{id: robotID}
mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
res, err := mm.StartVsAI(context.Background(), human, engine.VariantRussianScrabble, false)
if err != nil {
t.Fatalf("start vs AI: %v", err)
}
if !res.Matched {
t.Error("an AI game starts already matched (Matched=true)")
}
// The AI path creates a seated active game and never enters the open pool.
if m.createCalls != 1 || m.openCalls != 0 {
t.Errorf("calls: create=%d open=%d, want create=1 open=0", m.createCalls, m.openCalls)
}
if robots.lastVariant != engine.VariantRussianScrabble {
t.Errorf("robot picked for variant %v, want RussianScrabble", robots.lastVariant)
}
if !m.createParams.VsAI {
t.Error("AI game create params must set VsAI")
}
if len(m.createParams.Seats) != 2 ||
!slices.Contains(m.createParams.Seats, human) || !slices.Contains(m.createParams.Seats, robotID) {
t.Errorf("AI game seats %v, want exactly the human %s and the robot %s", m.createParams.Seats, human, robotID)
}
}
func TestStartVsAINoRobotLeavesNoGame(t *testing.T) {
poolErr := errors.New("pool empty")
m := &stubMatcher{}
mm := NewMatchmaker(m, &fakeRobots{err: poolErr}, time.Minute, time.Minute, zap.NewNop())
if _, err := mm.StartVsAI(context.Background(), uuid.New(), engine.VariantEnglish, true); !errors.Is(err, poolErr) {
t.Fatalf("start vs AI with empty pool = %v, want poolErr", err)
}
if m.createCalls != 0 {
t.Errorf("no robot available must not create a game; create calls = %d", m.createCalls)
}
}
func TestEnqueueDeadlineWithinWindow(t *testing.T) { func TestEnqueueDeadlineWithinWindow(t *testing.T) {
base := time.Now() base := time.Now()
m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)} m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)}
+1
View File
@@ -36,6 +36,7 @@ func toWireGame(g GameSummary) wire.GameView {
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: g.TurnTimeoutSecs, TurnTimeoutSecs: g.TurnTimeoutSecs,
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
+6 -4
View File
@@ -29,10 +29,12 @@ type GameSummary struct {
ToMove int ToMove int
TurnTimeoutSecs int TurnTimeoutSecs int
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
MoveCount int // VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off).
EndReason string VsAI bool
Seats []SeatStanding MoveCount int
LastActivityUnix int64 EndReason string
Seats []SeatStanding
LastActivityUnix int64
} }
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
@@ -32,4 +32,5 @@ type Games struct {
OpenDeadlineAt *time.Time OpenDeadlineAt *time.Time
DropoutTiles string DropoutTiles string
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
VsAi bool
} }
@@ -36,6 +36,7 @@ type gamesTable struct {
OpenDeadlineAt postgres.ColumnTimestampz OpenDeadlineAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool MultipleWordsPerTurn postgres.ColumnBool
VsAi postgres.ColumnBool
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -96,9 +97,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at") OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles") DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} VsAiColumn = postgres.BoolColumn("vs_ai")
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
) )
return gamesTable{ return gamesTable{
@@ -124,6 +126,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
OpenDeadlineAt: OpenDeadlineAtColumn, OpenDeadlineAt: OpenDeadlineAtColumn,
DropoutTiles: DropoutTilesColumn, DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
VsAi: VsAiColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -102,6 +102,13 @@ CREATE TABLE games (
open_deadline_at timestamptz, open_deadline_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove', dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true, multiple_words_per_turn boolean NOT NULL DEFAULT true,
-- vs_ai marks an "honest AI" game: the player knowingly plays a robot that joins
-- and moves at once (no disguise). It drives the 🤖 display, the disabled chat/nudge,
-- the skipped statistics and the robot's immediate, sleepless, nudge-free behaviour.
-- The robot-filled auto-match game (the "random player" path) keeps vs_ai=false, so
-- the disguised-robot opponent is never revealed. turn_timeout_secs holds the 7-day
-- inactivity clock for these games (the per-move timeout is reused as the abandon rule).
vs_ai boolean NOT NULL DEFAULT false,
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')), CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished', 'open')), CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished', 'open')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4), CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
+46
View File
@@ -63,6 +63,18 @@ func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time)
if !ok { if !ok {
return nil return nil
} }
// Honest-AI game: the robot moves the instant it is its turn — no sleep window and
// no proactive nudge (chat and nudge are disabled in these games, and the player
// chose an opponent that "moves at once"). It still plays to the same per-game
// strength (playToWin) and margin band as the human-mimicry path.
if rt.VsAI {
if rt.ToMove == rt.RobotSeat {
return s.act(ctx, rt, now)
}
return nil
}
opp, err := s.accounts.GetByID(ctx, oppID) opp, err := s.accounts.GetByID(ctx, oppID)
if err != nil { if err != nil {
return err return err
@@ -77,6 +89,40 @@ func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time)
return s.maybeNudge(ctx, rt, now) return s.maybeNudge(ctx, rt, now)
} }
// DriveGame handles a single active game seating a pool robot, immediately. It backs
// the honest-AI fast-move trigger fired by the game service after a vs_ai game is
// created or advanced; it mirrors Drive's per-game step but for one game (false from
// the focused read means the game is gone, finished, or holds no pooled robot).
func (s *Service) DriveGame(ctx context.Context, gameID uuid.UUID, now time.Time) error {
rt, ok, err := s.games.RobotTurn(ctx, gameID, s.poolIDs())
if err != nil {
return err
}
if !ok {
return nil
}
return s.handle(ctx, rt, now)
}
// driveTimeout bounds a triggered, off-request honest-AI move so a stuck call cannot
// leak a goroutine. A robot move is a quick in-process computation, so it is generous.
const driveTimeout = 30 * time.Second
// TriggerMove asynchronously drives the robot's move in an honest-AI game, used by the
// game service's after-commit/after-create hook so the robot replies at once instead of
// waiting for the next driver scan. It returns immediately and runs on a background
// context (the originating request's context may already be cancelled); errors are
// logged. The periodic Drive scan remains the fallback if a trigger is missed.
func (s *Service) TriggerMove(gameID uuid.UUID) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), driveTimeout)
defer cancel()
if err := s.DriveGame(ctx, gameID, s.clock()); err != nil {
s.log.Warn("robot trigger failed", zap.String("game", gameID.String()), zap.Error(err))
}
}()
}
// maybeMove acts when the robot's think time has elapsed. A daytime nudge from // maybeMove acts when the robot's think time has elapsed. A daytime nudge from
// the opponent during the current turn pulls the move in to the short reply // the opponent during the current turn pulls the move in to the short reply
// window; otherwise the robot waits out its sampled delay. // window; otherwise the robot waits out its sampled delay.
+1
View File
@@ -39,6 +39,7 @@ var ErrNoRobotAvailable = errors.New("robot: no robot available in the pool")
// player. game.Service satisfies it. // player. game.Service satisfies it.
type GameDriver interface { type GameDriver interface {
RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]game.RobotTurn, error) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]game.RobotTurn, error)
RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (game.RobotTurn, bool, error)
Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error)
Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error)
GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error) GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error)
+6 -2
View File
@@ -94,8 +94,11 @@ type gameDTO struct {
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
MoveCount int `json:"move_count"` // VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
EndReason string `json:"end_reason"` // are suppressed in the client.
VsAI bool `json:"vs_ai"`
MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
// LastActivityUnix is the lobby sort key: the current turn's start for an active // LastActivityUnix is the lobby sort key: the current turn's start for an active
// game, the finish time once finished. // game, the finish time once finished.
LastActivityUnix int64 `json:"last_activity_unix"` LastActivityUnix int64 `json:"last_activity_unix"`
@@ -220,6 +223,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
LastActivityUnix: last.Unix(), LastActivityUnix: last.Unix(),
+2
View File
@@ -168,6 +168,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "nudge_own_turn" return http.StatusConflict, "nudge_own_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive): case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished" return http.StatusConflict, "game_finished"
case errors.Is(err, social.ErrGameVsAI):
return http.StatusConflict, "ai_game"
case errors.Is(err, game.ErrNoOpponentYet): case errors.Is(err, game.ErrNoOpponentYet):
return http.StatusConflict, "no_opponent_yet" return http.StatusConflict, "no_opponent_yet"
case errors.Is(err, game.ErrGameActive): case errors.Is(err, game.ErrGameActive):
+12 -5
View File
@@ -133,15 +133,18 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto) c.JSON(http.StatusOK, dto)
} }
// enqueueRequest enters per-variant auto-match under a per-turn word rule. // enqueueRequest enters per-variant auto-match under a per-turn word rule. VsAI true
// starts an honest-AI game against a robot instead of the open/wait matchmaking path.
type enqueueRequest struct { type enqueueRequest struct {
Variant string `json:"variant"` Variant string `json:"variant"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
VsAI bool `json:"vs_ai"`
} }
// handleEnqueue enters the caller into auto-match for a variant and returns the game // handleEnqueue enters the caller into a quick game and returns the game they land in
// they land in immediately: a freshly opened game awaiting an opponent, or another // immediately. With vs_ai it starts an honest-AI game already seated with a robot;
// player's open game they just joined. The client navigates straight into the game. // otherwise it enters auto-match — a freshly opened game awaiting an opponent, or
// another player's open game they just joined. The client navigates straight in.
func (s *Server) handleEnqueue(c *gin.Context) { func (s *Server) handleEnqueue(c *gin.Context) {
uid, ok := userID(c) uid, ok := userID(c)
if !ok { if !ok {
@@ -158,7 +161,11 @@ func (s *Server) handleEnqueue(c *gin.Context) {
abortBadRequest(c, "unknown variant") abortBadRequest(c, "unknown variant")
return return
} }
res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn) enter := s.matchmaker.Enqueue
if req.VsAI {
enter = s.matchmaker.StartVsAI
}
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
+12
View File
@@ -62,6 +62,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if status != statusActive { if status != statusActive {
return Message{}, ErrGameNotActive return Message{}, ErrGameNotActive
} }
// Chat is disabled in honest-AI games (the opponent is a robot).
if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
return Message{}, err
} else if vsAI {
return Message{}, ErrGameVsAI
}
if idx != toMove { if idx != toMove {
return Message{}, ErrChatNotYourTurn return Message{}, ErrChatNotYourTurn
} }
@@ -118,6 +124,12 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
if idx < 0 { if idx < 0 {
return Message{}, ErrNotParticipant return Message{}, ErrNotParticipant
} }
// Nudge is disabled in honest-AI games (the opponent is a robot).
if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
return Message{}, err
} else if vsAI {
return Message{}, ErrGameVsAI
}
if idx == toMove { if idx == toMove {
return Message{}, ErrNudgeOnOwnTurn return Message{}, ErrNudgeOnOwnTurn
} }
+6
View File
@@ -38,6 +38,9 @@ type GameReader interface {
// GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes // GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes
// to the game's bot rather than the recipient's last-login bot. // to the game's bot rather than the recipient's last-login bot.
GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error)
// VsAI reports whether the game is an honest-AI game, in which chat and nudge are
// both disabled (the game still reports status 'active').
VsAI(ctx context.Context, gameID uuid.UUID) (bool, error)
} }
// Sentinel errors returned by the service. // Sentinel errors returned by the service.
@@ -77,6 +80,9 @@ var (
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour") ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
// ErrGameNotActive is returned when a nudge is attempted on a finished game. // ErrGameNotActive is returned when a nudge is attempted on a finished game.
ErrGameNotActive = errors.New("social: game is not active") ErrGameNotActive = errors.New("social: game is not active")
// ErrGameVsAI is returned when chat or nudge is attempted in an honest-AI game,
// where both are disabled (the UI also disables the controls).
ErrGameVsAI = errors.New("social: chat and nudge are disabled in AI games")
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the // ErrChatNotYourTurn is returned when a chat message is sent while it is not the
// sender's turn — chat is allowed only on your own turn (the opponent's-turn control // sender's turn — chat is allowed only on your own turn (the opponent's-turn control
// is the nudge). // is the nudge).
+24 -4
View File
@@ -361,8 +361,24 @@ Key points:
Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an
open game's empty opponent slot when no human has joined within the wait window (§8). open game's empty opponent slot when no human has joined within the wait window (§8).
It lives in `internal/robot` and plays as an ordinary seated account through the game It lives in `internal/robot` and plays as an ordinary seated account through the game
service, so only `internal/engine` imports the solver. It is designed to be service, so only `internal/engine` imports the solver. In the random/auto-match path it is
indistinguishable from a person. designed to be indistinguishable from a person.
The same robot serves **two quick-game modes**, chosen by the player on New Game and recorded
on the game as **`games.vs_ai`**: the **random** path above (disguised as a person) and an
**honest-AI** path the player knowingly picks (shown as **🤖**). The mode is a per-game flag,
never derived from the opponent account, so the disguised path is never revealed. In an
honest-AI game the robot keeps its per-game strength (`playToWin`) and margin band but **moves
at once** — no sampled delay, no sleep window, no proactive nudge; chat and nudge are disabled,
the opponent is shown as 🤖 everywhere, and the game records **no statistics** for either seat
(practice, like a guest game). The fast reply is **event-driven**: committing a move (or
creating the game) triggers `robot.DriveGame` immediately via the game service's after-commit /
after-create hook (`game.Service.SetAITrigger`, a func value so the game package never imports
the robot package), with the periodic driver as the fallback. There is **no short move
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
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.
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
@@ -406,8 +422,12 @@ English game the Latin pool.
## 8. Lobby & social ## 8. Lobby & social
- **Matchmaking**: auto-match drops the player **straight into a real game and lets - **Matchmaking**: a quick game offers **two opponents** on New Game — an **honest AI** (the
them wait inside it**. `Enqueue` (`POST /lobby/enqueue`) opens a game seating the default) or a **random** human (§7). The AI choice (`vs_ai` on `POST /lobby/enqueue`) takes the
`Matchmaker.StartVsAI` path, which picks a pooled robot and creates a game **already seated and
active** (`vs_ai`, random seat order); it never enters the open pool, so the reaper below never
touches it. The random choice drops the player **straight into a real game and lets
them wait inside it**: `Enqueue` (`POST /lobby/enqueue`) opens a game seating the
caller with an **empty opponent seat** (status `open`, §9), or — when another player caller with an **empty opponent seat** (status `open`, §9), or — when another player
is already waiting for the same `variant` and per-turn rule — seats the caller into is already waiting for the same `variant` and per-turn rule — seats the caller into
that open game and starts it; which seat the caller takes is randomised for that open game and starts it; which seat the caller takes is randomised for
+14 -2
View File
@@ -84,8 +84,14 @@ Russian → Scrabble + Erudite; a bilingual service shows all three, and the web
unrestricted). Variants are shown by their **display name** — both Scrabble variants read unrestricted). Variants are shown by their **display name** — both Scrabble variants read
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and "Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match invitation — so a player still sees and plays existing games of any language.
(always 2 players) drops you **straight into the game and you wait inside it**: if it is your turn you
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
chat and nudge are off, add-friend is never shown, and the word-check tool still works; instead of a
per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely,
and the AI itself never runs out of time). Choosing a **random player** is auto-match
(always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you
can already move, otherwise you watch your tiles. While no opponent has joined, the opponent card (and can already move, otherwise you watch your tiles. While no opponent has joined, the opponent card (and
the game's row in the lobby) reads **"searching for opponent"**, and resign, chat and nudge are the game's row in the lobby) reads **"searching for opponent"**, and resign, chat and nudge are
unavailable. Another player searching the same variant and rule joins your game; failing that, a robot unavailable. Another player searching the same variant and rule joins your game; failing that, a robot
@@ -142,6 +148,12 @@ carries a human-like, language-appropriate name (a Russian game draws mostly Rus
names); it does not chat, and **silently ignores friend requests** — a request to a names); it does not chat, and **silently ignores friend requests** — a request to a
robot stays pending and expires, exactly like a human who never responds. robot stays pending and expires, exactly like a human who never responds.
The same robot also backs the **honest-AI quick game** the player chooses directly (above). There
it makes no pretence: it is shown as **🤖** everywhere, joins and moves at once (no thinking time
or night pause), keeps the same strength (it still plays to win only about 40% of the time), and
chat, nudge and add-friend are off. AI games are **practice** — they never count toward a player's
statistics.
### Social: friends, block, chat, nudge ### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six Become friends in two ways: redeem a **one-time code** the other player issues (six
digits, valid for twelve hours), or send a **request to someone you have played digits, valid for twelve hours), or send a **request to someone you have played
+13 -1
View File
@@ -87,7 +87,14 @@ nudge) приходят от бота **этой партии** — по язы
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо любом языке.
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
сразу **помещает вас в игру, и вы ждёте соперника прямо
в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
@@ -146,6 +153,11 @@ nudge) приходят от бота **этой партии** — по язы
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает, **молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
ровно как у человека, который не отвечает. ровно как у человека, который не отвечает.
Тот же робот стоит и за **честной игрой против ИИ**, которую игрок выбирает напрямую (выше). Там он
не притворяется: везде показан как **🤖**, присоединяется и ходит сразу (без раздумий и ночной
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий), а чат, nudge
и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Социальное: друзья, блок, чат, nudge ### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
+8 -2
View File
@@ -227,7 +227,9 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
Decline for an invitee and a waiting/Cancel state for the inviter; a single-word-rule Decline for an invitee and a waiting/Cancel state for the inviter; a single-word-rule
invitation adds a **"One word per turn"** line to the card. Creating a game lives in invitation adds a **"One word per turn"** line to the card. Creating a game lives in
`NewGame.svelte`: **"Play with friends"** (pick invitees, then variant / move time / `NewGame.svelte`: **"Play with friends"** (pick invitees, then variant / move time /
hints) and **auto-match** (random opponent). The auto-match variant plaques are hints) and **quick game**. Quick game opens with a two-button **🤖 AI / 👤 Random player**
segmented select (the `.seg`/`.opt` style, **AI the default**) in place of the old subtitle — AI
is an honest robot you knowingly play, Random player is auto-match against a human. The variant plaques are
**mutually-exclusive selects** — a tap **highlights** one (an accent inset border) instead **mutually-exclusive selects** — a tap **highlights** one (an accent inset border) instead
of starting a game; a lone offered variant is pre-selected, and a bottom **Start game** of starting a game; a lone offered variant is pre-selected, and a bottom **Start game**
button (disabled until a variant is chosen) confirms. For a **Russian** variant (either button (disabled until a variant is chosen) confirms. For a **Russian** variant (either
@@ -237,7 +239,11 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
opponent's score card (and the game's lobby row) reads the localized **"searching for opponent"** opponent's score card (and the game's lobby row) reads the localized **"searching for opponent"**
placeholder, the add-friend 🤝 is hidden, and resign and the chat's send/nudge are disabled; an placeholder, the add-friend 🤝 is hidden, and resign and the chat's send/nudge are disabled; an
**opponent_joined** push restores them in place when a human or robot takes the seat, and a line **opponent_joined** push restores them in place when a human or robot takes the seat, and a line
under Start game notes the wait can take a while (the app may be closed meanwhile). under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI**
selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"**
and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖**
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's
send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working.
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts. numbers, no charts.
+6 -4
View File
@@ -102,6 +102,7 @@ type GameResp struct {
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
VsAI bool `json:"vs_ai"`
MoveCount int `json:"move_count"` MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"` EndReason string `json:"end_reason"`
LastActivityUnix int64 `json:"last_activity_unix"` LastActivityUnix int64 `json:"last_activity_unix"`
@@ -268,12 +269,13 @@ func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAl
return out, err return out, err
} }
// Enqueue joins the auto-match pool for a variant under a per-turn word rule // Enqueue starts a quick game for a variant under a per-turn word rule (multipleWords
// (multipleWords true is standard Scrabble, false the single-word rule). // true is standard Scrabble, false the single-word rule). vsAI true starts an honest-AI
func (c *Client) Enqueue(ctx context.Context, userID, variant string, multipleWords bool) (MatchResp, error) { // game against a robot instead of human auto-match.
func (c *Client) Enqueue(ctx context.Context, userID, variant string, multipleWords, vsAI bool) (MatchResp, error) {
var out MatchResp var out MatchResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "", err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "",
map[string]any{"variant": variant, "multiple_words_per_turn": multipleWords}, &out) map[string]any{"variant": variant, "multiple_words_per_turn": multipleWords, "vs_ai": vsAI}, &out)
return out, err return out, err
} }
+1
View File
@@ -384,6 +384,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: g.TurnTimeoutSecs, TurnTimeoutSecs: g.TurnTimeoutSecs,
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
+1 -1
View File
@@ -246,7 +246,7 @@ func gameStateHandler(backend *backendclient.Client) Handler {
func enqueueHandler(backend *backendclient.Client) Handler { func enqueueHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEnqueueRequest(req.Payload, 0) in := fb.GetRootAsEnqueueRequest(req.Payload, 0)
m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant()), in.MultipleWordsPerTurn()) m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant()), in.MultipleWordsPerTurn(), in.VsAi())
if err != nil { if err != nil {
return nil, err return nil, err
} }
+6 -1
View File
@@ -70,6 +70,9 @@ table GameView {
// last_activity_unix is the lobby sort key: the current turn's start for an active // last_activity_unix is the lobby sort key: the current turn's start for an active
// game, the finish time for a finished one. // game, the finish time for a finished one.
last_activity_unix:long; last_activity_unix:long;
// vs_ai marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are disabled in the client. A robot-filled random game keeps vs_ai=false.
vs_ai:bool;
} }
// MoveRecord is one decoded move (a committed play, or a hint preview). // MoveRecord is one decoded move (a committed play, or a hint preview).
@@ -289,11 +292,13 @@ table GameList {
// --- lobby (authenticated) --- // --- lobby (authenticated) ---
// EnqueueRequest joins the auto-match pool for a variant under a per-turn word rule. // EnqueueRequest starts a quick game for a variant under a per-turn word rule.
// multiple_words_per_turn true is standard Scrabble; false is the single-word rule. // multiple_words_per_turn true is standard Scrabble; false is the single-word rule.
// vs_ai true starts an honest-AI game against a robot instead of human auto-match.
table EnqueueRequest { table EnqueueRequest {
variant:string; variant:string;
multiple_words_per_turn:bool; multiple_words_per_turn:bool;
vs_ai:bool;
} }
// MatchResult reports whether the caller has been paired into a game yet. // MatchResult reports whether the caller has been paired into a game yet.
+16 -1
View File
@@ -61,8 +61,20 @@ func (rcv *EnqueueRequest) MutateMultipleWordsPerTurn(n bool) bool {
return rcv._tab.MutateBoolSlot(6, n) return rcv._tab.MutateBoolSlot(6, n)
} }
func (rcv *EnqueueRequest) VsAi() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *EnqueueRequest) MutateVsAi(n bool) bool {
return rcv._tab.MutateBoolSlot(8, n)
}
func EnqueueRequestStart(builder *flatbuffers.Builder) { func EnqueueRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2) builder.StartObject(3)
} }
func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
@@ -70,6 +82,9 @@ func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.
func EnqueueRequestAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) { func EnqueueRequestAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) {
builder.PrependBoolSlot(1, multipleWordsPerTurn, false) builder.PrependBoolSlot(1, multipleWordsPerTurn, false)
} }
func EnqueueRequestAddVsAi(builder *flatbuffers.Builder, vsAi bool) {
builder.PrependBoolSlot(2, vsAi, false)
}
func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+16 -1
View File
@@ -173,8 +173,20 @@ func (rcv *GameView) MutateLastActivityUnix(n int64) bool {
return rcv._tab.MutateInt64Slot(26, n) return rcv._tab.MutateInt64Slot(26, n)
} }
func (rcv *GameView) VsAi() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(28))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *GameView) MutateVsAi(n bool) bool {
return rcv._tab.MutateBoolSlot(28, n)
}
func GameViewStart(builder *flatbuffers.Builder) { func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(12) builder.StartObject(13)
} }
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
@@ -215,6 +227,9 @@ func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbu
func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) { func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) {
builder.PrependInt64Slot(11, lastActivityUnix, 0) builder.PrependInt64Slot(11, lastActivityUnix, 0)
} }
func GameViewAddVsAi(builder *flatbuffers.Builder, vsAi bool) {
builder.PrependBoolSlot(12, vsAi, false)
}
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+3
View File
@@ -42,6 +42,8 @@ type GameView struct {
EndReason string EndReason string
Seats []SeatView Seats []SeatView
LastActivityUnix int64 LastActivityUnix int64
// VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off).
VsAI bool
} }
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank // TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
@@ -158,6 +160,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
fb.GameViewAddEndReason(b, endReason) fb.GameViewAddEndReason(b, endReason)
fb.GameViewAddSeats(b, seats) fb.GameViewAddSeats(b, seats)
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
fb.GameViewAddVsAi(b, g.VsAI)
return fb.GameViewEnd(b) return fb.GameViewEnd(b)
} }
+31 -1
View File
@@ -10,7 +10,9 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
// Pick a variant and start; the player lands in the game at once (no "searching" screen). // Choose a random human opponent (the default is the AI); pick a variant and start. The player
// lands in an open game at once (no "searching" screen).
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click(); await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click(); await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
@@ -30,6 +32,33 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled(); await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
}); });
// The honest-AI quick game is the default opponent: the player starts a game already seated with a
// robot shown as 🤖, with no "searching" wait. Chat and nudge are disabled (the opponent is a robot),
// add-friend is never offered, and the dictionary word-check stays available. Mock transport only.
test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click();
// AI is the default opponent: the move-clock line is replaced by the 7-day inactivity rule.
await expect(page.getByText(/Loss after 7 days of inactivity/)).toBeVisible();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
// The player lands in an active game at once (no "searching" wait); the opponent shows as 🤖.
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
// Chat is disabled (the message field), while the dictionary word-check stays usable.
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
await expect(page).toHaveURL(/\/game\/.+\/chat$/);
await expect(page.locator('.chat input')).toBeDisabled();
await page.getByRole('button', { name: 'Dictionary' }).click();
await expect(page.locator('.check input')).toBeEnabled();
});
// The opponent_joined push is best-effort and never replayed, so a join that lands while the live // The opponent_joined push is best-effort and never replayed, so a join that lands while the live
// stream is down is lost. The waiting game screen recovers it without a push: a poll while the // stream is down is lost. The waiting game screen recovers it without a push: a poll while the
// stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops / // stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops /
@@ -39,6 +68,7 @@ async function enterOpenGame(page: import('@playwright/test').Page): Promise<voi
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); await page.getByRole('button', { name: /New/ }).click();
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click(); await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click(); await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.getByText(/Searching for opponent/)).toBeVisible(); await expect(page.getByText(/Searching for opponent/)).toBeVisible();
+8 -3
View File
@@ -12,6 +12,7 @@
canNudge = false, canNudge = false,
waiting = false, waiting = false,
nudgeOnCooldown = false, nudgeOnCooldown = false,
vsAi = false,
onsend, onsend,
onnudge, onnudge,
}: { }: {
@@ -34,6 +35,9 @@
// are disabled (there is no one to message or hurry yet). // are disabled (there is no one to message or hurry yet).
waiting?: boolean; waiting?: boolean;
nudgeOnCooldown?: boolean; nudgeOnCooldown?: boolean;
// vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is
// nobody to message or hurry. The fields still show (turn-driven), but disabled.
vsAi?: boolean;
onsend: (text: string) => void; onsend: (text: string) => void;
onnudge: () => void; onnudge: () => void;
} = $props(); } = $props();
@@ -67,9 +71,10 @@
maxlength="60" maxlength="60"
placeholder={t('chat.placeholder')} placeholder={t('chat.placeholder')}
bind:value={text} bind:value={text}
onkeydown={(e) => e.key === 'Enter' && !busy && send()} disabled={vsAi}
onkeydown={(e) => e.key === 'Enter' && !busy && !vsAi && send()}
/> />
<button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button> <button class="iconbtn" onclick={send} disabled={busy || waiting || vsAi || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else if myTurn} {:else if myTurn}
<!-- Own turn, the one allowed message already sent: a caption holds the row until the <!-- Own turn, the one allowed message already sent: a caption holds the row until the
next turn (no nudge — there is no one to hurry on your own turn). --> next turn (no nudge — there is no one to hurry on your own turn). -->
@@ -77,7 +82,7 @@
{:else if canNudge} {:else if canNudge}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. --> <!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span> <span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button> <button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || vsAi || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if} {/if}
</div> </div>
</div> </div>
+13 -1
View File
@@ -113,4 +113,16 @@
} }
</script> </script>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {canSend} {canNudge} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} /> <Chat
{messages}
{myId}
{busy}
myTurn={isMyTurn}
{canSend}
{canNudge}
{waiting}
{nudgeOnCooldown}
vsAi={!!view && view.game.vsAi}
onsend={sendChat}
onnudge={nudge}
/>
+5
View File
@@ -869,6 +869,8 @@
function seatName(s: { accountId: string; displayName: string } | undefined): string { function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return ''; if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you'); if (s.accountId === app.session?.userId) return t('common.you');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
if (view?.game.vsAi) return '🤖';
if (!s.accountId) return t('game.searchingForOpponent'); if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName; return s.displayName;
} }
@@ -876,6 +878,7 @@
// turnLabel is the under-board status when it is not the viewer's turn: the opponent's name // turnLabel is the under-board status when it is not the viewer's turn: the opponent's name
// once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting). // once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting).
function turnLabel(): string { function turnLabel(): string {
if (view?.game.vsAi) return '🤖';
const s = view?.game.seats[view?.game.toMove ?? -1]; const s = view?.game.seats[view?.game.toMove ?? -1];
if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName; if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName;
return t('game.opponentsTurn'); return t('game.opponentsTurn');
@@ -885,6 +888,8 @@
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
function canAddFriend(accountId: string): boolean { function canAddFriend(accountId: string): boolean {
// Never offer add-friend against an AI opponent.
if (view?.game.vsAi) return false;
return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId); return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
} }
</script> </script>
+12 -2
View File
@@ -32,8 +32,13 @@ multipleWordsPerTurn():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
} }
vsAi():boolean {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startEnqueueRequest(builder:flatbuffers.Builder) { static startEnqueueRequest(builder:flatbuffers.Builder) {
builder.startObject(2); builder.startObject(3);
} }
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
@@ -44,15 +49,20 @@ static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn
builder.addFieldInt8(1, +multipleWordsPerTurn, +false); builder.addFieldInt8(1, +multipleWordsPerTurn, +false);
} }
static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) {
builder.addFieldInt8(2, +vsAi, +false);
}
static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean):flatbuffers.Offset { static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean, vsAi:boolean):flatbuffers.Offset {
EnqueueRequest.startEnqueueRequest(builder); EnqueueRequest.startEnqueueRequest(builder);
EnqueueRequest.addVariant(builder, variantOffset); EnqueueRequest.addVariant(builder, variantOffset);
EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn);
EnqueueRequest.addVsAi(builder, vsAi);
return EnqueueRequest.endEnqueueRequest(builder); return EnqueueRequest.endEnqueueRequest(builder);
} }
} }
+12 -2
View File
@@ -98,8 +98,13 @@ lastActivityUnix():bigint {
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
} }
vsAi():boolean {
const offset = this.bb!.__offset(this.bb_pos, 28);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) { static startGameView(builder:flatbuffers.Builder) {
builder.startObject(12); builder.startObject(13);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -162,12 +167,16 @@ static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint)
builder.addFieldInt64(11, lastActivityUnix, BigInt('0')); builder.addFieldInt64(11, lastActivityUnix, BigInt('0'));
} }
static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) {
builder.addFieldInt8(12, +vsAi, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset { static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean):flatbuffers.Offset {
GameView.startGameView(builder); GameView.startGameView(builder);
GameView.addId(builder, idOffset); GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset); GameView.addVariant(builder, variantOffset);
@@ -181,6 +190,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addEndReason(builder, endReasonOffset); GameView.addEndReason(builder, endReasonOffset);
GameView.addSeats(builder, seatsOffset); GameView.addSeats(builder, seatsOffset);
GameView.addLastActivityUnix(builder, lastActivityUnix); GameView.addLastActivityUnix(builder, lastActivityUnix);
GameView.addVsAi(builder, vsAi);
return GameView.endGameView(builder); return GameView.endGameView(builder);
} }
} }
+2 -1
View File
@@ -68,7 +68,8 @@ export interface GatewayClient {
gamesList(): Promise<GameList>; gamesList(): Promise<GameList>;
// --- lobby --- // --- lobby ---
lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult>; /** Start a quick game: human auto-match, or an honest-AI game against a robot when vsAi. */
lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>; lobbyPoll(): Promise<MatchResult>;
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */ /** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
lobbyCancel(): Promise<void>; lobbyCancel(): Promise<void>;
+14
View File
@@ -20,6 +20,7 @@ import {
encodeCheckWord, encodeCheckWord,
encodeFeedbackSubmit, encodeFeedbackSubmit,
encodeDraftSave, encodeDraftSave,
encodeEnqueue,
encodeExchange, encodeExchange,
encodeStateRequest, encodeStateRequest,
encodeSubmitPlay, encodeSubmitPlay,
@@ -27,6 +28,17 @@ import {
} from './codec'; } from './codec';
describe('codec', () => { describe('codec', () => {
it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
for (const vsAi of [false, true]) {
const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
new ByteBuffer(encodeEnqueue('scrabble_ru', false, vsAi)),
);
expect(req.variant()).toBe('scrabble_ru');
expect(req.multipleWordsPerTurn()).toBe(false);
expect(req.vsAi()).toBe(vsAi);
}
});
it('round-trips a draft save request and view', () => { it('round-trips a draft save request and view', () => {
const json = '{"rack_order":"1,0","board_tiles":[]}'; const json = '{"rack_order":"1,0","board_tiles":[]}';
const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json))); const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json)));
@@ -205,6 +217,7 @@ describe('codec', () => {
fb.GameView.addEndReason(b, er); fb.GameView.addEndReason(b, er);
fb.GameView.addSeats(b, seats); fb.GameView.addSeats(b, seats);
fb.GameView.addLastActivityUnix(b, BigInt(1717000000)); fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
fb.GameView.addVsAi(b, true);
const game = fb.GameView.endGameView(b); const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]); const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b); fb.GameList.startGameList(b);
@@ -217,6 +230,7 @@ describe('codec', () => {
expect(gl.games[0].seats[0].displayName).toBe('Ann'); expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13); expect(gl.games[0].seats[0].score).toBe(13);
expect(gl.games[0].lastActivityUnix).toBe(1717000000); expect(gl.games[0].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true);
}); });
it('decodes an OutgoingRequestList of account refs', () => { it('decodes an OutgoingRequestList of account refs', () => {
+4 -1
View File
@@ -151,12 +151,13 @@ export function encodeComplaint(gameId: string, word: string, note: string): Uin
return finish(b, fb.ComplaintRequest.endComplaintRequest(b)); return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
} }
export function encodeEnqueue(variant: Variant, multipleWords: boolean): Uint8Array { export function encodeEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Uint8Array {
const b = new Builder(64); const b = new Builder(64);
const v = b.createString(variant); const v = b.createString(variant);
fb.EnqueueRequest.startEnqueueRequest(b); fb.EnqueueRequest.startEnqueueRequest(b);
fb.EnqueueRequest.addVariant(b, v); fb.EnqueueRequest.addVariant(b, v);
fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords); fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords);
fb.EnqueueRequest.addVsAi(b, vsAi);
return finish(b, fb.EnqueueRequest.endEnqueueRequest(b)); return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
} }
@@ -244,6 +245,7 @@ function decodeGameView(g: fb.GameView): GameView {
moveCount: g.moveCount(), moveCount: g.moveCount(),
endReason: s(g.endReason()), endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()), lastActivityUnix: Number(g.lastActivityUnix()),
vsAi: g.vsAi(),
seats, seats,
}; };
} }
@@ -781,6 +783,7 @@ function emptyGame(): GameView {
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
vsAi: false,
seats: [], seats: [],
}; };
} }
+1
View File
@@ -7,6 +7,7 @@ function gameView(id: string): GameView {
id, id,
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status: 'active', status: 'active',
players: 2, players: 2,
toMove: 0, toMove: 0,
+1
View File
@@ -8,6 +8,7 @@ function gameView(moveCount: number, over = false): GameView {
id: 'g1', id: 'g1',
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status: over ? 'finished' : 'active', status: over ? 'finished' : 'active',
players: 2, players: 2,
toMove: 1, toMove: 1,
+3 -1
View File
@@ -44,7 +44,6 @@ export const en = {
'lobby.vs': 'vs {opponents}', 'lobby.vs': 'vs {opponents}',
'new.title': 'New game', 'new.title': 'New game',
'new.subtitle': 'Auto-match with another player',
'new.english': 'Scrabble', 'new.english': 'Scrabble',
'new.russian': 'Скрэббл', 'new.russian': 'Скрэббл',
'new.erudit': 'Erudite', 'new.erudit': 'Erudite',
@@ -265,6 +264,9 @@ export const en = {
'new.start': 'Start game', 'new.start': 'Start game',
'new.invited': 'Invitation sent.', 'new.invited': 'Invitation sent.',
'new.noFriends': 'Add friends first to invite them.', 'new.noFriends': 'Add friends first to invite them.',
'new.opponentAI': 'AI',
'new.opponentRandom': 'Random player',
'new.aiInactiveLimit': 'Loss after 7 days of inactivity',
'stats.title': 'Statistics', 'stats.title': 'Statistics',
'stats.wins': 'Wins', 'stats.wins': 'Wins',
+3 -1
View File
@@ -45,7 +45,6 @@ export const ru: Record<MessageKey, string> = {
'lobby.vs': 'против {opponents}', 'lobby.vs': 'против {opponents}',
'new.title': 'Новая игра', 'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника',
'new.english': 'Scrabble', 'new.english': 'Scrabble',
'new.russian': 'Скрэббл', 'new.russian': 'Скрэббл',
'new.erudit': 'Эрудит', 'new.erudit': 'Эрудит',
@@ -266,6 +265,9 @@ export const ru: Record<MessageKey, string> = {
'new.start': 'Начать игру', 'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.', 'new.invited': 'Приглашение отправлено.',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
'new.opponentAI': 'ИИ',
'new.opponentRandom': 'Случайный игрок',
'new.aiInactiveLimit': 'Поражение, если 7 дней нет активности',
'stats.title': 'Статистика', 'stats.title': 'Статистика',
'stats.wins': 'Победы', 'stats.wins': 'Победы',
+1
View File
@@ -24,6 +24,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
id, id,
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status, status,
players: 2, players: 2,
toMove, toMove,
+1
View File
@@ -17,6 +17,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
id, id,
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status, status,
players: 2, players: 2,
toMove, toMove,
+34 -2
View File
@@ -156,11 +156,42 @@ export class MockGateway implements GatewayClient {
} }
// --- lobby --- // --- lobby ---
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> { async lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult> {
const id = crypto.randomUUID();
if (vsAi) {
// An honest-AI game starts active, already seated with a robot (rendered as 🤖 from the
// vs_ai flag); there is no open/wait phase.
const ai: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords,
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: true,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, ai);
return { matched: true, game: structuredClone(ai.view) };
}
// The player enters an open game immediately and waits inside it; a robot opponent takes // The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing // the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state. // opponent_joined so the game UI restores from the "searching for opponent" state.
const id = crypto.randomUUID();
const g: MockGame = { const g: MockGame = {
view: { view: {
id, id,
@@ -174,6 +205,7 @@ export class MockGateway implements GatewayClient {
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: false,
seats: [ seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
+3
View File
@@ -139,6 +139,7 @@ function activeGame(): MockGame {
id: 'g1', id: 'g1',
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status: 'active', status: 'active',
players: 2, players: 2,
toMove: 0, toMove: 0,
@@ -174,6 +175,7 @@ function finishedG2(): MockGame {
id: 'g2', id: 'g2',
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status: 'finished', status: 'finished',
players: 2, players: 2,
toMove: 0, toMove: 0,
@@ -210,6 +212,7 @@ function finishedG3(): MockGame {
id: 'g3', id: 'g3',
variant: 'scrabble_ru', variant: 'scrabble_ru',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status: 'finished', status: 'finished',
players: 2, players: 2,
toMove: 0, toMove: 0,
+2
View File
@@ -45,6 +45,8 @@ export interface GameView {
endReason: string; endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
lastActivityUnix: number; lastActivityUnix: number;
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
vsAi: boolean;
seats: Seat[]; seats: Seat[];
} }
+1
View File
@@ -19,6 +19,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
id, id,
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status, status,
players: 2, players: 2,
toMove: 0, toMove: 0,
+1
View File
@@ -16,6 +16,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
id: 'g', id: 'g',
variant: 'scrabble_en', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
vsAi: false,
status, status,
players: seats.length, players: seats.length,
toMove, toMove,
+2 -2
View File
@@ -84,8 +84,8 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeGameList(await exec('games.list', codec.empty())); return codec.decodeGameList(await exec('games.list', codec.empty()));
}, },
async lobbyEnqueue(variant, multipleWords) { async lobbyEnqueue(variant, multipleWords, vsAi) {
return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords))); return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords, vsAi)));
}, },
async lobbyPoll() { async lobbyPoll() {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty())); return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
+2
View File
@@ -63,6 +63,8 @@
function opponents(g: GameView): string { function opponents(g: GameView): string {
// An auto-match game still waiting for an opponent shows the "searching" placeholder. // An auto-match game still waiting for an opponent shows the "searching" placeholder.
if (g.status === 'open') return t('game.searchingForOpponent'); if (g.status === 'open') return t('game.searchingForOpponent');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
if (g.vsAi) return '🤖';
return g.seats return g.seats
.filter((s) => s.accountId !== myId) .filter((s) => s.accountId !== myId)
.map((s) => s.displayName) .map((s) => s.displayName)
+10 -4
View File
@@ -39,6 +39,9 @@
const guest = $derived(app.profile?.isGuest ?? true); const guest = $derived(app.profile?.isGuest ?? true);
let mode = $state<'auto' | 'friends'>('auto'); let mode = $state<'auto' | 'friends'>('auto');
// The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖)
// or a random human via auto-match. AI is the default.
let opponent = $state<'ai' | 'random'>('ai');
// --- auto-match --- // --- auto-match ---
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an // Enqueue drops the player straight into a real game — a freshly opened one awaiting an
@@ -51,7 +54,7 @@
if (starting) return; if (starting) return;
starting = true; starting = true;
try { try {
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords)); const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords), opponent === 'ai');
if (r.game) navigate(`/game/${r.game.id}`); if (r.game) navigate(`/game/${r.game.id}`);
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -119,7 +122,10 @@
{/if} {/if}
{#if mode === 'auto'} {#if mode === 'auto'}
<p class="subtitle">{t('new.subtitle')}</p> <div class="seg modes">
<button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button>
<button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button>
</div>
<div class="variants"> <div class="variants">
{#each variants as v (v.id)} {#each variants as v (v.id)}
<button <button
@@ -146,8 +152,8 @@
<input type="checkbox" bind:checked={multipleWords} /> <input type="checkbox" bind:checked={multipleWords} />
</label> </label>
{/if} {/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p> {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button <button
class="invite" class="invite"
disabled={!selectedAuto || !connection.online || starting} disabled={!selectedAuto || !connection.online || starting}