From aa765a0c0679ebcfdf9ae1e754c83b858137968f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 20:14:24 +0200 Subject: [PATCH] feat: honest AI opponent in quick game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Game's quick game gains an explicit opponent selector — 🤖 AI (default) or 👤 Random player. AI starts a game seated with a pooled robot that joins and moves at once: no per-move timeout (a 7-day inactivity loss reusing the turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown as 🤖 everywhere. The random path (disguised robot) is unchanged. Driven by one game flag (games.vs_ai), set only on AI-started games so the disguised path is never revealed; Matchmaker.StartVsAI seats the robot directly (no open pool); the robot replies event-driven via the game service's after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView. --- PRERELEASE.md | 37 ++++ backend/README.md | 10 + backend/cmd/backend/main.go | 3 + backend/internal/game/eventwire.go | 1 + backend/internal/game/service.go | 95 +++++++-- backend/internal/game/store.go | 98 +++++++-- backend/internal/game/types.go | 23 +++ backend/internal/inttest/ai_game_test.go | 194 ++++++++++++++++++ backend/internal/lobby/matchmaker.go | 40 ++++ backend/internal/lobby/matchmaker_test.go | 54 +++++ backend/internal/notify/encode.go | 1 + backend/internal/notify/payload.go | 10 +- .../postgres/jet/backend/model/games.go | 1 + .../postgres/jet/backend/table/games.go | 9 +- .../postgres/migrations/00001_baseline.sql | 7 + backend/internal/robot/driver.go | 46 +++++ backend/internal/robot/robot.go | 1 + backend/internal/server/dto.go | 8 +- backend/internal/server/handlers.go | 2 + backend/internal/server/handlers_user.go | 17 +- backend/internal/social/chat.go | 12 ++ backend/internal/social/social.go | 6 + docs/ARCHITECTURE.md | 28 ++- docs/FUNCTIONAL.md | 16 +- docs/FUNCTIONAL_ru.md | 14 +- docs/UI_DESIGN.md | 10 +- gateway/internal/backendclient/api.go | 10 +- gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/transcode.go | 2 +- pkg/fbs/scrabble.fbs | 7 +- pkg/fbs/scrabblefb/EnqueueRequest.go | 17 +- pkg/fbs/scrabblefb/GameView.go | 17 +- pkg/wire/build.go | 3 + ui/e2e/quickmatch.spec.ts | 32 ++- ui/src/game/Chat.svelte | 11 +- ui/src/game/ChatScreen.svelte | 14 +- ui/src/game/Game.svelte | 5 + ui/src/gen/fbs/scrabblefb/enqueue-request.ts | 14 +- ui/src/gen/fbs/scrabblefb/game-view.ts | 14 +- ui/src/lib/client.ts | 3 +- ui/src/lib/codec.test.ts | 14 ++ ui/src/lib/codec.ts | 5 +- ui/src/lib/gamecache.test.ts | 1 + ui/src/lib/gamedelta.test.ts | 1 + ui/src/lib/i18n/en.ts | 4 +- ui/src/lib/i18n/ru.ts | 4 +- ui/src/lib/lobbycache.test.ts | 1 + ui/src/lib/lobbysort.test.ts | 1 + ui/src/lib/mock/client.ts | 36 +++- ui/src/lib/mock/data.ts | 3 + ui/src/lib/model.ts | 2 + ui/src/lib/preload.test.ts | 1 + ui/src/lib/result.test.ts | 1 + ui/src/lib/transport.ts | 4 +- ui/src/screens/Lobby.svelte | 2 + ui/src/screens/NewGame.svelte | 14 +- 56 files changed, 901 insertions(+), 86 deletions(-) create mode 100644 backend/internal/inttest/ai_game_test.go diff --git a/PRERELEASE.md b/PRERELEASE.md index 78b1345..6c6365e 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -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 90–180 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** | | 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) | ## 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. - **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). + +- **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. diff --git a/backend/README.md b/backend/README.md index 5c7bc03..849da24 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 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 handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under `/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index a0185d8..00003a8 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -178,6 +178,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { if err := robots.EnsurePool(ctx); err != nil { 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) logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval)) diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 9a8106e..c9d2b31 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -51,6 +51,7 @@ func gameSummary(g Game, names []string) notify.GameSummary { ToMove: g.ToMove, TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), MultipleWordsPerTurn: g.MultipleWordsPerTurn, + VsAI: g.VsAI, MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index e69757e..c6e7ef5 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -39,8 +39,14 @@ type Service struct { clock func() time.Time rng func() int64 pub notify.Publisher - metrics *gameMetrics - log *zap.Logger + // aiTrigger, when set, is called after an honest-AI game is created or advanced and + // is still on a robot's potential turn, so the robot replies at once instead of + // waiting for the next driver scan. It is fire-and-forget (the robot package wires + // its asynchronous TriggerMove); nil disables the fast path (the scan still covers + // these games). Kept as a func so the game package never imports the robot package. + aiTrigger func(gameID uuid.UUID) + metrics *gameMetrics + log *zap.Logger } // NewService constructs a Service. store and accounts wrap the same pool; @@ -75,6 +81,23 @@ func (svc *Service) SetNotifier(p notify.Publisher) { } } +// SetAITrigger installs the honest-AI fast-move hook called when a vs_ai game is +// created or advanced and a robot may now be on the clock. It must be called during +// startup wiring; the default (nil) leaves only the periodic driver scan. The robot +// package wires its asynchronous TriggerMove here. +func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) { + svc.aiTrigger = trigger +} + +// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort, +// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is +// installed, so callers can invoke it unconditionally after a create or commit. +func (svc *Service) triggerAI(g Game) { + if svc.aiTrigger != nil && g.VsAI && g.Status == StatusActive { + svc.aiTrigger(g.ID) + } +} + // activeVersion returns the dictionary version new games currently pin. func (svc *Service) activeVersion() string { svc.verMu.RLock() @@ -143,11 +166,17 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig) } timeout := params.TurnTimeout - if timeout == 0 { - timeout = DefaultTurnTimeout - } - if !allowedTimeout(timeout) { - return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout) + if params.VsAI { + // Honest-AI games use the fixed 7-day inactivity clock, not a user-chosen value + // from AllowedTurnTimeouts (it is never offered in the creation UI). + timeout = AIInactivityTimeout + } else { + if timeout == 0 { + timeout = DefaultTurnTimeout + } + if !allowedTimeout(timeout) { + return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout) + } } seen := make(map[uuid.UUID]bool, len(params.Seats)) for _, id := range params.Seats { @@ -200,13 +229,21 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro hintsPerPlayer: params.HintsPerPlayer, dropoutTiles: params.DropoutTiles.String(), multipleWordsPerTurn: params.MultipleWordsPerTurn, + vsAI: params.VsAI, } if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil { return Game{}, err } svc.cache.put(id, g, params.Variant.String()) svc.metrics.recordStarted(ctx, params.Variant) - return svc.store.GetGame(ctx, id) + created, err := svc.store.GetGame(ctx, id) + if err != nil { + return Game{}, err + } + // Honest-AI game seated with a robot: if the robot moves first, reply at once + // (the periodic driver is the fallback). No-op for every human-only game. + svc.triggerAI(created) + return created, nil } // OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in @@ -368,7 +405,7 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo if err != nil { return MoveResult{}, err } - post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats) + post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats, pre.VsAI) if err != nil { return MoveResult{}, err } @@ -516,7 +553,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, if err != nil { return MoveResult{}, err } - post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats) + post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats, pre.VsAI) if err != nil { return MoveResult{}, err } @@ -546,7 +583,7 @@ func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uui // cursor and scores, and on a game-ending move the finish stamp and statistics. // On a persistence failure it evicts the now-divergent live game so the next // access rebuilds from the journal. -func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat) (Game, error) { +func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat, vsAI bool) (Game, error) { now := svc.clock() logLen := len(g.Log()) scores := make([]int, g.Players()) @@ -577,12 +614,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game c.endReason = "timeout" } c.winner = g.Result().Winner - statSeats, err := svc.nonGuestSeats(ctx, seats) - if err != nil { - svc.cache.remove(gameID) - return Game{}, err + // Honest-AI games are practice and never touch player statistics (like guest + // games); a human game records them for its non-guest seats. + if !vsAI { + statSeats, err := svc.nonGuestSeats(ctx, seats) + if err != nil { + svc.cache.remove(gameID) + return Game{}, err + } + c.stats = buildStats(g, statSeats) } - c.stats = buildStats(g, statSeats) } if err := svc.store.CommitMove(ctx, c); err != nil { svc.cache.remove(gameID) @@ -596,6 +637,9 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game return Game{}, err } svc.emitMove(ctx, post, rec, g.BagLen()) + // Honest-AI game still going: nudge the robot to take its turn at once (the + // periodic driver is the fallback). No-op for human games and finished ones. + svc.triggerAI(post) return post, nil } @@ -630,6 +674,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco word = rec.Words[0] } opponent := svc.displayName(ctx, post.Seats, rec.Player) + if post.VsAI { + opponent = aiOpponentName // the player chose an AI game; show the robot as 🤖, not its pool name + } yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount) yourTurn.Language = lang intents = append(intents, yourTurn) @@ -759,7 +806,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time. if err != nil { return false, err } - if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil { + if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats, cur.VsAI); err != nil { return false, err } svc.metrics.recordAbandoned(ctx, cur.Variant) @@ -996,6 +1043,20 @@ func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]Rob return svc.store.RobotTurns(ctx, robotIDs) } +// RobotTurn returns the robot driver's view of a single active game seating one of +// robotIDs, and true, or false when the game holds no pooled robot or is no longer +// active. It backs the honest-AI fast-move trigger, which drives just the one game. +func (svc *Service) RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (RobotTurn, bool, error) { + return svc.store.RobotTurnByGame(ctx, gameID, robotIDs) +} + +// VsAI reports whether a game is an honest-AI game (games.vs_ai). The social +// service uses it to reject chat and nudge in AI games (which otherwise report +// status 'active'). +func (svc *Service) VsAI(ctx context.Context, gameID uuid.UUID) (bool, error) { + return svc.store.GameVsAI(ctx, gameID) +} + // GameState returns a seated player's view of the game: the shared summary plus // their private rack, the bag size and their remaining hint budget. func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index d5ab8e2..68e6557 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -41,6 +41,8 @@ type gameInsert struct { dropoutTiles string // multipleWordsPerTurn false selects the single-word rule for the game. multipleWordsPerTurn bool + // vsAI marks an honest-AI game (games.vs_ai). + vsAI bool // status is the lifecycle state to create the game in: StatusActive for a normal // seated game, StatusOpen for an auto-match game still awaiting an opponent. An // empty string defaults to StatusActive. @@ -130,9 +132,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid. table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, - table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, + table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players, - ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn) + ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI) if _, err := gi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert game: %w", err) } @@ -916,7 +918,7 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e } stmt := postgres.SELECT( table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, - table.Games.MoveCount, table.Games.Seed, + table.Games.MoveCount, table.Games.Seed, table.Games.VsAi, table.GamePlayers.Seat, table.GamePlayers.AccountID, ).FROM( table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)), @@ -934,24 +936,85 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e } out := make([]RobotTurn, 0, len(rows)) for _, r := range rows { - // The filter matches only the robot's (non-null) seat, so AccountID is set. - robotID := uuid.Nil - if r.GamePlayers.AccountID != nil { - robotID = *r.GamePlayers.AccountID - } - out = append(out, RobotTurn{ - GameID: r.Games.GameID, - RobotID: robotID, - RobotSeat: int(r.GamePlayers.Seat), - ToMove: int(r.Games.ToMove), - TurnStartedAt: r.Games.TurnStartedAt, - MoveCount: int(r.Games.MoveCount), - Seed: r.Games.Seed, - }) + out = append(out, robotTurnFrom(r.Games, r.GamePlayers)) } return out, nil } +// RobotTurnByGame returns the robot turn for a single active game — the seat held by +// one of ids (the robot pool) — and true, or false when the game is not active, holds +// no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which +// drives one game at once rather than scanning the whole pool (RobotTurns). +func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) { + if len(ids) == 0 { + return RobotTurn{}, false, nil + } + exprs := make([]postgres.Expression, len(ids)) + for i, id := range ids { + exprs[i] = postgres.UUID(id) + } + stmt := postgres.SELECT( + table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, + table.Games.MoveCount, table.Games.Seed, table.Games.VsAi, + table.GamePlayers.Seat, table.GamePlayers.AccountID, + ).FROM( + table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)), + ).WHERE( + table.Games.GameID.EQ(postgres.UUID(gameID)). + AND(table.Games.Status.EQ(postgres.String(StatusActive))). + AND(table.GamePlayers.AccountID.IN(exprs...)), + ).LIMIT(1) + + var rows []struct { + model.Games + model.GamePlayers + } + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return RobotTurn{}, false, fmt.Errorf("game: robot turn by game: %w", err) + } + if len(rows) == 0 { + return RobotTurn{}, false, nil + } + return robotTurnFrom(rows[0].Games, rows[0].GamePlayers), true, nil +} + +// robotTurnFrom projects a games row joined with the robot's seat into a RobotTurn. +// The query matches only the robot's (non-null) seat, so AccountID is set. +func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn { + robotID := uuid.Nil + if p.AccountID != nil { + robotID = *p.AccountID + } + return RobotTurn{ + GameID: g.GameID, + RobotID: robotID, + RobotSeat: int(p.Seat), + ToMove: int(g.ToMove), + TurnStartedAt: g.TurnStartedAt, + MoveCount: int(g.MoveCount), + Seed: g.Seed, + VsAI: g.VsAi, + } +} + +// GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap +// single-column read for the social chat/nudge gate, which must reject both in an +// AI game even though it reports status 'active'. ErrNotFound when the game is gone. +func (s *Store) GameVsAI(ctx context.Context, id uuid.UUID) (bool, error) { + stmt := postgres.SELECT(table.Games.VsAi). + FROM(table.Games). + WHERE(table.Games.GameID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.Games + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return false, ErrNotFound + } + return false, fmt.Errorf("game: get vs_ai %s: %w", id, err) + } + return row.VsAi, nil +} + // GameSeed returns the bag seed a game was dealt from, used to replay it. The // seed is server-only state and never travels in the public Game view. func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { @@ -1046,6 +1109,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { UpdatedAt: g.UpdatedAt, } out.MultipleWordsPerTurn = g.MultipleWordsPerTurn + out.VsAI = g.VsAi if g.EndReason != nil { out.EndReason = *g.EndReason } diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index dccd8b7..c61b2e2 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -79,6 +79,19 @@ var AllowedTurnTimeouts = []time.Duration{ // zero (the owner's default: a full day). 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 // order (seat 0 moves first); lobby/matchmaking assembles it in a later stage. type CreateParams struct { @@ -92,6 +105,10 @@ type CreateParams struct { // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule — // only the main word is validated and scored. Russian games default to false. 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. @@ -115,6 +132,9 @@ type Game struct { FinishedAt *time.Time // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. 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. @@ -220,6 +240,9 @@ type RobotTurn struct { TurnStartedAt time.Time MoveCount int 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 diff --git a/backend/internal/inttest/ai_game_test.go b/backend/internal/inttest/ai_game_test.go new file mode 100644 index 0000000..ca4e3ad --- /dev/null +++ b/backend/internal/inttest/ai_game_test.go @@ -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) + } +} diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 6456680..18ed0bf 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -22,6 +22,9 @@ type GameMatcher interface { AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error) ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, 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: @@ -96,6 +99,29 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e 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 // interval until ctx is cancelled. It is started once from main. 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, } } + +// 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, + } +} diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index ad5bb9b..ce3de67 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -3,6 +3,7 @@ package lobby import ( "context" "errors" + "slices" "testing" "time" @@ -31,6 +32,11 @@ type stubMatcher struct { attached bool attachErr error 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) { @@ -57,6 +63,12 @@ func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.Pl 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 // empty pool. It records the variant of the last substitution request. 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) { base := time.Now() m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)} diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index ceb3982..d2b1914 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -36,6 +36,7 @@ func toWireGame(g GameSummary) wire.GameView { ToMove: g.ToMove, TurnTimeoutSecs: g.TurnTimeoutSecs, MultipleWordsPerTurn: g.MultipleWordsPerTurn, + VsAI: g.VsAI, MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index ce3c68a..abb4c95 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -29,10 +29,12 @@ type GameSummary struct { ToMove int TurnTimeoutSecs int MultipleWordsPerTurn bool - MoveCount int - EndReason string - Seats []SeatStanding - LastActivityUnix int64 + // VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off). + VsAI bool + MoveCount int + EndReason string + Seats []SeatStanding + LastActivityUnix int64 } // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go index b4a7d2f..1057252 100644 --- a/backend/internal/postgres/jet/backend/model/games.go +++ b/backend/internal/postgres/jet/backend/model/games.go @@ -32,4 +32,5 @@ type Games struct { OpenDeadlineAt *time.Time DropoutTiles string MultipleWordsPerTurn bool + VsAi bool } diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go index bfe8e7c..1ef8123 100644 --- a/backend/internal/postgres/jet/backend/table/games.go +++ b/backend/internal/postgres/jet/backend/table/games.go @@ -36,6 +36,7 @@ type gamesTable struct { OpenDeadlineAt postgres.ColumnTimestampz DropoutTiles postgres.ColumnString MultipleWordsPerTurn postgres.ColumnBool + VsAi postgres.ColumnBool AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -96,9 +97,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at") DropoutTilesColumn = postgres.StringColumn("dropout_tiles") 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} - mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} - defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} + VsAiColumn = postgres.BoolColumn("vs_ai") + allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn} + 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{ @@ -124,6 +126,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { OpenDeadlineAt: OpenDeadlineAtColumn, DropoutTiles: DropoutTilesColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn, + VsAi: VsAiColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index eaadf3d..290937a 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -102,6 +102,13 @@ CREATE TABLE games ( open_deadline_at timestamptz, dropout_tiles text NOT NULL DEFAULT 'remove', 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_status_chk CHECK (status IN ('active', 'finished', 'open')), CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4), diff --git a/backend/internal/robot/driver.go b/backend/internal/robot/driver.go index 3c3466b..711d73c 100644 --- a/backend/internal/robot/driver.go +++ b/backend/internal/robot/driver.go @@ -63,6 +63,18 @@ func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time) if !ok { 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) if err != nil { 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) } +// 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 // the opponent during the current turn pulls the move in to the short reply // window; otherwise the robot waits out its sampled delay. diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go index ffa0908..37f59f4 100644 --- a/backend/internal/robot/robot.go +++ b/backend/internal/robot/robot.go @@ -39,6 +39,7 @@ var ErrNoRobotAvailable = errors.New("robot: no robot available in the pool") // player. game.Service satisfies it. type GameDriver interface { 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) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error) diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 3f1b475..98def50 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -94,8 +94,11 @@ type gameDTO struct { ToMove int `json:"to_move"` TurnTimeoutSecs int `json:"turn_timeout_secs"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` + // VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend + // 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 // game, the finish time once finished. LastActivityUnix int64 `json:"last_activity_unix"` @@ -220,6 +223,7 @@ func gameDTOFromGame(g game.Game) gameDTO { ToMove: g.ToMove, TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), MultipleWordsPerTurn: g.MultipleWordsPerTurn, + VsAI: g.VsAI, MoveCount: g.MoveCount, EndReason: g.EndReason, LastActivityUnix: last.Unix(), diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 671f5c1..bc335f2 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -168,6 +168,8 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "nudge_own_turn" case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive): return http.StatusConflict, "game_finished" + case errors.Is(err, social.ErrGameVsAI): + return http.StatusConflict, "ai_game" case errors.Is(err, game.ErrNoOpponentYet): return http.StatusConflict, "no_opponent_yet" case errors.Is(err, game.ErrGameActive): diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 798fd14..642f759 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -133,15 +133,18 @@ func (s *Server) handleGameState(c *gin.Context) { 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 { Variant string `json:"variant"` 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 -// they land in immediately: a freshly opened game awaiting an opponent, or another -// player's open game they just joined. The client navigates straight into the game. +// handleEnqueue enters the caller into a quick game and returns the game they land in +// immediately. With vs_ai it starts an honest-AI game already seated with a robot; +// 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) { uid, ok := userID(c) if !ok { @@ -158,7 +161,11 @@ func (s *Server) handleEnqueue(c *gin.Context) { abortBadRequest(c, "unknown variant") 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 { s.abortErr(c, err) return diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 839d192..14e685c 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -62,6 +62,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, if status != statusActive { 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 { return Message{}, ErrChatNotYourTurn } @@ -118,6 +124,12 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess if idx < 0 { 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 { return Message{}, ErrNudgeOnOwnTurn } diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 0b5c0a2..91fb11f 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -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 // to the game's bot rather than the recipient's last-login bot. 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. @@ -77,6 +80,9 @@ var ( 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 = 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 // sender's turn — chat is allowed only on your own turn (the opponent's-turn control // is the nudge). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2cb450f..2a88f00 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -361,8 +361,24 @@ Key points: 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). 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 -indistinguishable from a person. +service, so only `internal/engine` imports the solver. In the random/auto-match path it is +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 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 -- **Matchmaking**: auto-match drops the player **straight into a real game and lets - them wait inside it**. `Enqueue` (`POST /lobby/enqueue`) opens a game seating the +- **Matchmaking**: a quick game offers **two opponents** on New Game — an **honest AI** (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 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f5193e6..05c78b3 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 "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 -invitation — so a player still sees and plays existing games of any language. Auto-match -(always 2 players) drops you **straight into the game and you wait inside it**: if it is your turn you +invitation — so a player still sees and plays existing games of any language. + +**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 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 @@ -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 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 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index a08598a..d77f430 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -87,7 +87,14 @@ nudge) приходят от бота **этой партии** — по язы читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на -любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо +любом языке. + +**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ** +вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge +выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы +проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ +никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который +сразу **помещает вас в игру, и вы ждёте соперника прямо в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей @@ -146,6 +153,11 @@ nudge) приходят от бота **этой партии** — по язы **молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает, ровно как у человека, который не отвечает. +Тот же робот стоит и за **честной игрой против ИИ**, которую игрок выбирает напрямую (выше). Там он +не притворяется: везде показан как **🤖**, присоединяется и ходит сразу (без раздумий и ночной +паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий), а чат, nudge +и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. + ### Социальное: друзья, блок, чат, nudge Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 2a6bfd3..83b1a85 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 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 / - 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 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 @@ -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"** 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 - 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 cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 4c09e45..4255fcd 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -102,6 +102,7 @@ type GameResp struct { ToMove int `json:"to_move"` TurnTimeoutSecs int `json:"turn_timeout_secs"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + VsAI bool `json:"vs_ai"` MoveCount int `json:"move_count"` EndReason string `json:"end_reason"` LastActivityUnix int64 `json:"last_activity_unix"` @@ -268,12 +269,13 @@ func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAl return out, err } -// Enqueue joins the auto-match pool for a variant under a per-turn word rule -// (multipleWords true is standard Scrabble, false the single-word rule). -func (c *Client) Enqueue(ctx context.Context, userID, variant string, multipleWords bool) (MatchResp, error) { +// Enqueue starts a quick game for a variant under a per-turn word rule (multipleWords +// true is standard Scrabble, false the single-word rule). vsAI true starts an honest-AI +// 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 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 } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 062fff4..d3c8ee0 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -384,6 +384,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView { ToMove: g.ToMove, TurnTimeoutSecs: g.TurnTimeoutSecs, MultipleWordsPerTurn: g.MultipleWordsPerTurn, + VsAI: g.VsAI, MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index b176709..978bd7f 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -246,7 +246,7 @@ func gameStateHandler(backend *backendclient.Client) Handler { func enqueueHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { 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 { return nil, err } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b098ff0..1c88274 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -70,6 +70,9 @@ table GameView { // last_activity_unix is the lobby sort key: the current turn's start for an active // game, the finish time for a finished one. 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). @@ -289,11 +292,13 @@ table GameList { // --- 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. +// vs_ai true starts an honest-AI game against a robot instead of human auto-match. table EnqueueRequest { variant:string; multiple_words_per_turn:bool; + vs_ai:bool; } // MatchResult reports whether the caller has been paired into a game yet. diff --git a/pkg/fbs/scrabblefb/EnqueueRequest.go b/pkg/fbs/scrabblefb/EnqueueRequest.go index 080e7f2..dc3ca37 100644 --- a/pkg/fbs/scrabblefb/EnqueueRequest.go +++ b/pkg/fbs/scrabblefb/EnqueueRequest.go @@ -61,8 +61,20 @@ func (rcv *EnqueueRequest) MutateMultipleWordsPerTurn(n bool) bool { 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) { - builder.StartObject(2) + builder.StartObject(3) } func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { 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) { builder.PrependBoolSlot(1, multipleWordsPerTurn, false) } +func EnqueueRequestAddVsAi(builder *flatbuffers.Builder, vsAi bool) { + builder.PrependBoolSlot(2, vsAi, false) +} func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 89b47c7..8139daf 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -173,8 +173,20 @@ func (rcv *GameView) MutateLastActivityUnix(n int64) bool { 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) { - builder.StartObject(12) + builder.StartObject(13) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { 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) { builder.PrependInt64Slot(11, lastActivityUnix, 0) } +func GameViewAddVsAi(builder *flatbuffers.Builder, vsAi bool) { + builder.PrependBoolSlot(12, vsAi, false) +} func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 4ef6834..146cab7 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -42,6 +42,8 @@ type GameView struct { EndReason string Seats []SeatView 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 @@ -158,6 +160,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.GameViewAddEndReason(b, endReason) fb.GameViewAddSeats(b, seats) fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) + fb.GameViewAddVsAi(b, g.VsAI) return fb.GameViewEnd(b) } diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 9271dc2..dc66901 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -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: /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.getByRole('button', { name: /Start game/i }).click(); 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(); }); +// 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 // 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 / @@ -39,6 +68,7 @@ async function enterOpenGame(page: import('@playwright/test').Page): Promise void; onnudge: () => void; } = $props(); @@ -67,9 +71,10 @@ maxlength="60" placeholder={t('chat.placeholder')} bind:value={text} - onkeydown={(e) => e.key === 'Enter' && !busy && send()} + disabled={vsAi} + onkeydown={(e) => e.key === 'Enter' && !busy && !vsAi && send()} /> - + {:else if myTurn} @@ -77,7 +82,7 @@ {:else if canNudge} {nudgeOnCooldown ? t('chat.awaitingReply') : ''} - + {/if} diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 551d045..7855554 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -113,4 +113,16 @@ } - + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index d87ce28..6f4df4d 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -869,6 +869,8 @@ function seatName(s: { accountId: string; displayName: string } | undefined): string { if (!s) return ''; 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'); 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 // once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting). function turnLabel(): string { + if (view?.game.vsAi) return '🤖'; const s = view?.game.seats[view?.game.toMove ?? -1]; if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName; 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 // opponent still shows it, but disabled). 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); } diff --git a/ui/src/gen/fbs/scrabblefb/enqueue-request.ts b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts index 7dd5232..85862cf 100644 --- a/ui/src/gen/fbs/scrabblefb/enqueue-request.ts +++ b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts @@ -32,8 +32,13 @@ multipleWordsPerTurn():boolean { 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) { - builder.startObject(2); + builder.startObject(3); } static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { @@ -44,15 +49,20 @@ static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn builder.addFieldInt8(1, +multipleWordsPerTurn, +false); } +static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) { + builder.addFieldInt8(2, +vsAi, +false); +} + static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); 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.addVariant(builder, variantOffset); EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); + EnqueueRequest.addVsAi(builder, vsAi); return EnqueueRequest.endEnqueueRequest(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index fc03e8e..31ba398 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -98,8 +98,13 @@ lastActivityUnix():bigint { 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) { - builder.startObject(12); + builder.startObject(13); } 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')); } +static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) { + builder.addFieldInt8(12, +vsAi, +false); +} + static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); 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.addId(builder, idOffset); GameView.addVariant(builder, variantOffset); @@ -181,6 +190,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, GameView.addEndReason(builder, endReasonOffset); GameView.addSeats(builder, seatsOffset); GameView.addLastActivityUnix(builder, lastActivityUnix); + GameView.addVsAi(builder, vsAi); return GameView.endGameView(builder); } } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 8b6849d..cca4252 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -68,7 +68,8 @@ export interface GatewayClient { gamesList(): Promise; // --- lobby --- - lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise; + /** 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; lobbyPoll(): Promise; /** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */ lobbyCancel(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index f737ed9..b96eda8 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -20,6 +20,7 @@ import { encodeCheckWord, encodeFeedbackSubmit, encodeDraftSave, + encodeEnqueue, encodeExchange, encodeStateRequest, encodeSubmitPlay, @@ -27,6 +28,17 @@ import { } from './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', () => { const json = '{"rack_order":"1,0","board_tiles":[]}'; const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json))); @@ -205,6 +217,7 @@ describe('codec', () => { fb.GameView.addEndReason(b, er); fb.GameView.addSeats(b, seats); fb.GameView.addLastActivityUnix(b, BigInt(1717000000)); + fb.GameView.addVsAi(b, true); const game = fb.GameView.endGameView(b); const games = fb.GameList.createGamesVector(b, [game]); 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].score).toBe(13); expect(gl.games[0].lastActivityUnix).toBe(1717000000); + expect(gl.games[0].vsAi).toBe(true); }); it('decodes an OutgoingRequestList of account refs', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index d6537e0..af97592 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -151,12 +151,13 @@ export function encodeComplaint(gameId: string, word: string, note: string): Uin 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 v = b.createString(variant); fb.EnqueueRequest.startEnqueueRequest(b); fb.EnqueueRequest.addVariant(b, v); fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords); + fb.EnqueueRequest.addVsAi(b, vsAi); return finish(b, fb.EnqueueRequest.endEnqueueRequest(b)); } @@ -244,6 +245,7 @@ function decodeGameView(g: fb.GameView): GameView { moveCount: g.moveCount(), endReason: s(g.endReason()), lastActivityUnix: Number(g.lastActivityUnix()), + vsAi: g.vsAi(), seats, }; } @@ -781,6 +783,7 @@ function emptyGame(): GameView { moveCount: 0, endReason: '', lastActivityUnix: 0, + vsAi: false, seats: [], }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index 52eb07f..375dfff 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -7,6 +7,7 @@ function gameView(id: string): GameView { id, variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status: 'active', players: 2, toMove: 0, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 57318e7..b66c371 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -8,6 +8,7 @@ function gameView(moveCount: number, over = false): GameView { id: 'g1', variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status: over ? 'finished' : 'active', players: 2, toMove: 1, diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 7092e19..057c2c2 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -44,7 +44,6 @@ export const en = { 'lobby.vs': 'vs {opponents}', 'new.title': 'New game', - 'new.subtitle': 'Auto-match with another player', 'new.english': 'Scrabble', 'new.russian': 'Скрэббл', 'new.erudit': 'Erudite', @@ -265,6 +264,9 @@ export const en = { 'new.start': 'Start game', 'new.invited': 'Invitation sent.', '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.wins': 'Wins', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 5107cc1..a2f726d 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -45,7 +45,6 @@ export const ru: Record = { 'lobby.vs': 'против {opponents}', 'new.title': 'Новая игра', - 'new.subtitle': 'Автоподбор соперника', 'new.english': 'Scrabble', 'new.russian': 'Скрэббл', 'new.erudit': 'Эрудит', @@ -266,6 +265,9 @@ export const ru: Record = { 'new.start': 'Начать игру', 'new.invited': 'Приглашение отправлено.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', + 'new.opponentAI': 'ИИ', + 'new.opponentRandom': 'Случайный игрок', + 'new.aiInactiveLimit': 'Поражение, если 7 дней нет активности', 'stats.title': 'Статистика', 'stats.wins': 'Победы', diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts index ffbd934..792b481 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -24,6 +24,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0) id, variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status, players: 2, toMove, diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index dd3fea4..1e63e68 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -17,6 +17,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi id, variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status, players: 2, toMove, diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 22cefa3..f94e930 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -156,11 +156,42 @@ export class MockGateway implements GatewayClient { } // --- lobby --- - async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise { + async lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise { + 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 empty seat shortly (a sped-up version of the backend's 90–180 s wait), pushing // opponent_joined so the game UI restores from the "searching for opponent" state. - const id = crypto.randomUUID(); const g: MockGame = { view: { id, @@ -174,6 +205,7 @@ export class MockGateway implements GatewayClient { moveCount: 0, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000), + vsAi: false, seats: [ { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false }, diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index f5af53c..dadd4a0 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -139,6 +139,7 @@ function activeGame(): MockGame { id: 'g1', variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status: 'active', players: 2, toMove: 0, @@ -174,6 +175,7 @@ function finishedG2(): MockGame { id: 'g2', variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status: 'finished', players: 2, toMove: 0, @@ -210,6 +212,7 @@ function finishedG3(): MockGame { id: 'g3', variant: 'scrabble_ru', dictVersion: 'v1', + vsAi: false, status: 'finished', players: 2, toMove: 0, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index d3dbfd4..faefcbe 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -45,6 +45,8 @@ export interface GameView { endReason: string; /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ lastActivityUnix: number; + /** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */ + vsAi: boolean; seats: Seat[]; } diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index 9e4823e..cb1cf11 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -19,6 +19,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView { id, variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status, players: 2, toMove: 0, diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index be4ebc4..09d9b08 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -16,6 +16,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView { id: 'g', variant: 'scrabble_en', dictVersion: 'v1', + vsAi: false, status, players: seats.length, toMove, diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 3b2874b..0cdc25f 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -84,8 +84,8 @@ export function createTransport(baseUrl: string): GatewayClient { return codec.decodeGameList(await exec('games.list', codec.empty())); }, - async lobbyEnqueue(variant, multipleWords) { - return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords))); + async lobbyEnqueue(variant, multipleWords, vsAi) { + return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords, vsAi))); }, async lobbyPoll() { return codec.decodeMatchResult(await exec('lobby.poll', codec.empty())); diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 166c805..c9e3c8d 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -63,6 +63,8 @@ function opponents(g: GameView): string { // An auto-match game still waiting for an opponent shows the "searching" placeholder. 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 .filter((s) => s.accountId !== myId) .map((s) => s.displayName) diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 7a048a2..8ea84d6 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -39,6 +39,9 @@ const guest = $derived(app.profile?.isGuest ?? true); 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 --- // Enqueue drops the player straight into a real game — a freshly opened one awaiting an @@ -51,7 +54,7 @@ if (starting) return; starting = true; 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}`); } catch (e) { handleError(e); @@ -119,7 +122,10 @@ {/if} {#if mode === 'auto'} -

{t('new.subtitle')}

+
+ + +
{#each variants as v (v.id)}