Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -87,7 +87,14 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
|
||||
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
|
||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
||||
любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо
|
||||
любом языке.
|
||||
|
||||
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
|
||||
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
|
||||
выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы
|
||||
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
|
||||
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
|
||||
сразу **помещает вас в игру, и вы ждёте соперника прямо
|
||||
в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
|
||||
присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
|
||||
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
|
||||
@@ -146,6 +153,11 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
|
||||
ровно как у человека, который не отвечает.
|
||||
|
||||
Тот же робот стоит и за **честной игрой против ИИ**, которую игрок выбирает напрямую (выше). Там он
|
||||
не притворяется: везде показан как **🤖**, присоединяется и ходит сразу (без раздумий и ночной
|
||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий), а чат, nudge
|
||||
и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||
|
||||
+8
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ function gameView(id: string): GameView {
|
||||
id,
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
status: 'active',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Победы',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user