feat: honest AI opponent in quick game #68

Merged
developer merged 2 commits from feature/ai-opponent into development 2026-06-15 19:05:23 +00:00
56 changed files with 901 additions and 86 deletions
Showing only changes of commit aa765a0c06 - Show all commits
+37
View File
@@ -31,6 +31,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| 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.
+10
View File
@@ -59,6 +59,16 @@ elapsed, and the waiting starter is told an opponent took the seat by an in-app
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
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
+3
View File
@@ -178,6 +178,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
if err := robots.EnsurePool(ctx); err != nil {
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))
+1
View File
@@ -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,
+78 -17
View File
@@ -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) {
+81 -17
View File
@@ -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
}
+23
View File
@@ -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
+194
View File
@@ -0,0 +1,194 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
)
// The AI-game suite covers the honest-AI quick game: a game created already seated with a
// pooled robot (vs_ai), in which the robot moves immediately, the move clock is the 7-day
// inactivity rule, chat and nudge are disabled and no statistics are recorded.
// startAIGame creates an active vs_ai game seating a fresh human and a pooled robot, the human
// at seat 0 when humanFirst (otherwise the robot moves first), and returns it with the two ids.
func startAIGame(t *testing.T, svc *game.Service, robots *robot.Service, humanFirst bool, seed int64) (game.Game, uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
human := provisionAccount(t)
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick robot: %v", err)
}
seats := []uuid.UUID{human, robotID}
if !humanFirst {
seats = []uuid.UUID{robotID, human}
}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, VsAI: true, Seed: seed,
HintsAllowed: true, HintsPerPlayer: 1, MultipleWordsPerTurn: true,
})
if err != nil {
t.Fatalf("create AI game: %v", err)
}
return g, human, robotID
}
// TestAIGameStartSeatsRobotActive checks the matchmaker's AI path creates an active game already
// seated with a robot (no open/wait phase), flagged vs_ai and on the 7-day inactivity clock.
func TestAIGameStartSeatsRobotActive(t *testing.T) {
ctx := context.Background()
robots := newRobotService(t, newGameService())
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
mm := newMatchmaker(t, robots, time.Minute, 0)
human := provisionAccount(t)
res, err := mm.StartVsAI(ctx, human, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("start vs AI: %v", err)
}
g := res.Game
if !res.Matched || g.Status != game.StatusActive {
t.Fatalf("AI game = (matched %v, status %q), want (true, active)", res.Matched, g.Status)
}
if !g.VsAI {
t.Error("an AI game must be flagged vs_ai")
}
if g.TurnTimeout != game.AIInactivityTimeout {
t.Errorf("AI game move clock = %s, want %s", g.TurnTimeout, game.AIInactivityTimeout)
}
var hasHuman, hasRobot bool
for _, s := range g.Seats {
switch {
case s.AccountID == human:
hasHuman = true
case s.AccountID != uuid.Nil:
hasRobot = true
}
}
if len(g.Seats) != 2 || !hasHuman || !hasRobot {
t.Errorf("AI game seats = %+v, want exactly the human and a robot (no empty seat)", g.Seats)
}
}
// TestAIRobotMovesImmediately checks the robot in a vs_ai game moves the instant it is its turn,
// with no sampled delay and no sleep window (the honest-AI fast path the trigger drives).
func TestAIRobotMovesImmediately(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, _, _ := startAIGame(t, svc, robots, false, openingSeed(t)) // robot at seat 0, to move
if err := robots.DriveGame(ctx, g.ID, time.Now()); err != nil {
t.Fatalf("drive AI game: %v", err)
}
after, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if after.MoveCount != 1 {
t.Errorf("after one drive the robot's first move must be in (move count %d, want 1)", after.MoveCount)
}
if after.ToMove == 0 {
t.Error("after the robot's move it must be the human's turn")
}
}
// TestAIGameStatsSkipped checks a vs_ai game records no statistics: a human resign (a loss in a
// normal game) leaves the human with no stats row.
func TestAIGameStatsSkipped(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, _ := startAIGame(t, svc, robots, true, openingSeed(t))
if _, err := svc.Resign(ctx, g.ID, human); err != nil {
t.Fatalf("resign: %v", err)
}
if _, _, _, _, _, found := readStats(t, human); found {
t.Error("an AI game must not record statistics for the human")
}
}
// TestAIGameSevenDayTimeout checks the reused turn-timeout sweeper enforces the 7-day inactivity
// rule: nothing fires within seven days, and past it the human on the clock is resigned and loses.
func TestAIGameSevenDayTimeout(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, robotID := startAIGame(t, svc, robots, true, openingSeed(t)) // human at seat 0, to move
// Six days idle: well under the 7-day clock, so the sweeper leaves it active.
setTurnStarted(t, g.ID, time.Now().Add(-6*24*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
if mid, _ := svc.GameByID(ctx, g.ID); mid.Status != game.StatusActive {
t.Fatalf("at six days idle the AI game must still be active, got %q", mid.Status)
}
// Eight days idle: past the clock, so the human (on the clock) is timed out and loses.
setTurnStarted(t, g.ID, time.Now().Add(-8*24*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
done, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if done.Status != game.StatusFinished || done.EndReason != "timeout" {
t.Fatalf("after seven days idle: (status %q, reason %q), want (finished, timeout)", done.Status, done.EndReason)
}
for _, s := range done.Seats {
if s.AccountID == robotID && !s.IsWinner {
t.Error("the robot must win when the human abandons the game")
}
if s.AccountID == human && s.IsWinner {
t.Error("the abandoning human must not win")
}
}
if _, _, _, _, _, found := readStats(t, human); found {
t.Error("a timed-out AI game must not record statistics")
}
}
// TestAIGameChatAndNudgeRejected checks chat and nudge are both refused in a vs_ai game (the
// opponent is a robot), even though the game reports status 'active'.
func TestAIGameChatAndNudgeRejected(t *testing.T) {
ctx := context.Background()
svc := newGameService()
soc := newSocialService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, _ := startAIGame(t, svc, robots, true, openingSeed(t))
if _, err := soc.PostMessage(ctx, g.ID, human, "hi robot", ""); !errors.Is(err, social.ErrGameVsAI) {
t.Errorf("chat in an AI game = %v, want ErrGameVsAI", err)
}
if _, err := soc.Nudge(ctx, g.ID, human); !errors.Is(err, social.ErrGameVsAI) {
t.Errorf("nudge in an AI game = %v, want ErrGameVsAI", err)
}
}
+40
View File
@@ -22,6 +22,9 @@ type GameMatcher interface {
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error)
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,
}
}
+54
View File
@@ -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)}
+1
View File
@@ -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,
+6 -4
View File
@@ -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
@@ -32,4 +32,5 @@ type Games struct {
OpenDeadlineAt *time.Time
DropoutTiles string
MultipleWordsPerTurn bool
VsAi bool
}
@@ -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,
@@ -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),
+46
View File
@@ -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.
+1
View File
@@ -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)
+6 -2
View File
@@ -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(),
+2
View File
@@ -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):
+12 -5
View File
@@ -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
+12
View File
@@ -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
}
+6
View File
@@ -38,6 +38,9 @@ type GameReader interface {
// GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes
// 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).
+24 -4
View File
@@ -361,8 +361,24 @@ Key points:
Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an
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
+14 -2
View File
@@ -84,8 +84,14 @@ Russian → Scrabble + Erudite; a bilingual service shows all three, and the web
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
"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
+13 -1
View File
@@ -87,7 +87,14 @@ nudge) приходят от бота **этой партии** — по язы
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо
любом языке.
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
сразу **помещает вас в игру, и вы ждёте соперника прямо
в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
@@ -146,6 +153,11 @@ nudge) приходят от бота **этой партии** — по язы
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
ровно как у человека, который не отвечает.
Тот же робот стоит и за **честной игрой против ИИ**, которую игрок выбирает напрямую (выше). Там он
не притворяется: везде показан как **🤖**, присоединяется и ходит сразу (без раздумий и ночной
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий), а чат, nudge
и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
+8 -2
View File
@@ -227,7 +227,9 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
Decline for an invitee and a waiting/Cancel state for the inviter; a single-word-rule
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.
+6 -4
View File
@@ -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
}
+1
View File
@@ -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,
+1 -1
View File
@@ -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
}
+6 -1
View File
@@ -70,6 +70,9 @@ table GameView {
// last_activity_unix is the lobby sort key: the current turn's start for an active
// 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.
+16 -1
View File
@@ -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()
}
+16 -1
View File
@@ -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()
}
+3
View File
@@ -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)
}
+31 -1
View File
@@ -10,7 +10,9 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /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<voi
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click();
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.getByText(/Searching for opponent/)).toBeVisible();
+8 -3
View File
@@ -12,6 +12,7 @@
canNudge = false,
waiting = false,
nudgeOnCooldown = false,
vsAi = false,
onsend,
onnudge,
}: {
@@ -34,6 +35,9 @@
// are disabled (there is no one to message or hurry yet).
waiting?: boolean;
nudgeOnCooldown?: boolean;
// vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is
// nobody to message or hurry. The fields still show (turn-driven), but disabled.
vsAi?: boolean;
onsend: (text: string) => void;
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()}
/>
<button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={send} disabled={busy || waiting || vsAi || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else if myTurn}
<!-- Own turn, the one allowed message already sent: a caption holds the row until the
next turn (no nudge — there is no one to hurry on your own turn). -->
@@ -77,7 +82,7 @@
{:else if canNudge}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || vsAi || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div>
</div>
+13 -1
View File
@@ -113,4 +113,16 @@
}
</script>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {canSend} {canNudge} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
<Chat
{messages}
{myId}
{busy}
myTurn={isMyTurn}
{canSend}
{canNudge}
{waiting}
{nudgeOnCooldown}
vsAi={!!view && view.game.vsAi}
onsend={sendChat}
onnudge={nudge}
/>
+5
View File
@@ -869,6 +869,8 @@
function seatName(s: { accountId: string; displayName: string } | undefined): string {
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);
}
</script>
+12 -2
View File
@@ -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);
}
}
+12 -2
View File
@@ -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);
}
}
+2 -1
View File
@@ -68,7 +68,8 @@ export interface GatewayClient {
gamesList(): Promise<GameList>;
// --- lobby ---
lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult>;
/** Start a quick game: human auto-match, or an honest-AI game against a robot when vsAi. */
lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>;
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
lobbyCancel(): Promise<void>;
+14
View File
@@ -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', () => {
+4 -1
View File
@@ -151,12 +151,13 @@ export function encodeComplaint(gameId: string, word: string, note: string): Uin
return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
}
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: [],
};
}
+1
View File
@@ -7,6 +7,7 @@ function gameView(id: string): GameView {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status: 'active',
players: 2,
toMove: 0,
+1
View File
@@ -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,
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -45,7 +45,6 @@ export const ru: Record<MessageKey, string> = {
'lobby.vs': 'против {opponents}',
'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника',
'new.english': 'Scrabble',
'new.russian': 'Скрэббл',
'new.erudit': 'Эрудит',
@@ -266,6 +265,9 @@ export const ru: Record<MessageKey, string> = {
'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
'new.opponentAI': 'ИИ',
'new.opponentRandom': 'Случайный игрок',
'new.aiInactiveLimit': 'Поражение, если 7 дней нет активности',
'stats.title': 'Статистика',
'stats.wins': 'Победы',
+1
View File
@@ -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,
+1
View File
@@ -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,
+34 -2
View File
@@ -156,11 +156,42 @@ export class MockGateway implements GatewayClient {
}
// --- lobby ---
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
async lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult> {
const id = crypto.randomUUID();
if (vsAi) {
// An honest-AI game starts active, already seated with a robot (rendered as 🤖 from the
// vs_ai flag); there is no open/wait phase.
const ai: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords,
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: true,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, ai);
return { matched: true, game: structuredClone(ai.view) };
}
// The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state.
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 },
+3
View File
@@ -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,
+2
View File
@@ -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[];
}
+1
View File
@@ -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,
+1
View File
@@ -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,
+2 -2
View File
@@ -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()));
+2
View File
@@ -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)
+10 -4
View File
@@ -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'}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="seg modes">
<button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button>
<button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button>
</div>
<div class="variants">
{#each variants as v (v.id)}
<button
@@ -146,8 +152,8 @@
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button
class="invite"
disabled={!selectedAuto || !connection.online || starting}