Merge pull request 'Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution)' (#5) from feature/stage-5-robot into master
Tests · Go / test (push) Successful in 5s
Tests · Integration / integration (push) Successful in 10s

This commit was merged in pull request #5.
This commit is contained in:
2026-06-02 19:05:18 +00:00
26 changed files with 1700 additions and 85 deletions
+45 -1
View File
@@ -38,7 +38,7 @@ independent (see ARCHITECTURE §9.1).
| 2 | Engine package over scrabble-solver | **done** |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | todo |
| 5 | Robot opponent | **done** |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | todo |
| 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo |
| 8 | Telegram integration (bot side-service, deep-link, push) | todo |
@@ -311,6 +311,50 @@ Open details: deployment target/host; dashboards; load expectations.
(both Go workflows already clone the solver sibling and export
`BACKEND_DICT_DIR`).
- **Stage 5** (interview + implementation):
- Scope, as in Stages 14: **domain layer, no HTTP** — the robot consumes the
public game API as an ordinary seated player (`internal/robot`), so only
`internal/engine` still imports the solver. New: `engine.Candidates()` (decoded
ranked plays) and a thin `game.Service.Candidates` + `RobotTurns` read.
- **Account model** (interview): a pool of **durable accounts**, each a single
`identities` row `kind='robot'` (migration `00004` widens the kind CHECK — a
CHECK-only change, no jetgen). A curated ~16-name pool in code; `EnsurePool`
provisions them idempotently at boot (a hard dependency, like the registry) with
`block_chat`/`block_friend_requests` set, which is **all** the friend/DM blocking
needs (no special-casing).
- **Driver + state** (interview): a background sweeper goroutine
(`robot.Service.Run`/`Drive`, mirroring the timeout sweeper); **every per-game
and per-turn choice is derived deterministically from the game `seed`** (FNV-1a
mix, restart-stable — not `hash/maphash`), so the robot keeps **no extra state**.
`playToWin = mix(seed,"win")%100 < 40`; per-turn `delay`; sleep `drift`.
- **Timing** (interview): per-move delay `2 + 88·u^k` minutes, `u~U(0,1)`,
**k≈3.5 → median ~10 min**, clamped to [2,90]. A daytime nudge on the robot's
turn pulls the move into a 210 min reply window; the robot proactively nudges
after **12 h** idle on the human's turn (reusing `social.Nudge`'s once-per-hour
guard; `social.LastNudgeAt` added to detect the human's nudge).
- **Sleep** (interview — resolves the §7-vs-`account.go` mismatch): the robot
sleeps 00:0007:00 in the **opponent's timezone shifted by a per-game drift ∈
[3,+3]h** (so its night overlaps the human's rather than running anti-phase),
computed on the fly per game — **no profile mutation, no concurrency cap**. The
`account.go` away-window comment was corrected accordingly.
- **Margin** (interview): pick the candidate whose resulting margin (own+moveopp)
is closest to **[1,30]** when playing to win / **[30,1]** when playing to lose,
tie-broken toward the conservative edge; no legal play → exchange the full rack
when the bag can refill it, else pass.
- **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`)
substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`),
`NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human
pairing **or** substitution — through a new `Poll` + results map; production
delivery is a **match-found notification** (session/in-app push + side-service),
Stage 6/8 — noted in §10.
- **Metrics** (interview, 1+2): robots are durable accounts, so `account_stats`
is the authoritative, complete balance ground-truth (target ~40% robot wins);
an OTel counter (`robot_games_finished_total`, exporter `none` today) and a
structured log cover robot-finished games for live observation.
- **Config**: `BACKEND_ROBOT_DRIVE_INTERVAL` (30 s), `BACKEND_LOBBY_ROBOT_WAIT`
(10 s), `BACKEND_LOBBY_REAPER_INTERVAL` (1 s). No CI change (both Go workflows
already clone the solver sibling and export `BACKEND_DICT_DIR`).
## Deferred TODOs (cross-stage)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
+18 -2
View File
@@ -44,10 +44,22 @@ remains. As before this is a service/store layer — chat and nudges are persist
but their live delivery, and all REST endpoints, arrive with the `gateway`
(Stage 6); the services are exposed via `Server` accessors for those handlers.
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
each a `kind='robot'` identity, provisioned at startup with chat and friend
requests blocked — backs a human-like name pool. A background driver plays the
robot's moves through the public game API as an ordinary seated player (so only
`internal/engine` imports the solver): it decides once per game whether to play to
win (≈ 40%), targets a small score margin, and times its moves with a right-skewed
delay, a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. The matchmaker now substitutes a pooled robot after a 10-second wait and
exposes `Poll` so a waiting player can collect the started game (the live
match-found notification arrives with the `gateway`).
## Package layout
```
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> lobby+social -> server
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> robot pool+driver -> lobby+social -> server
cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container
internal/config/ # env configuration (composes postgres + telemetry + game config)
internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
@@ -60,7 +72,8 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/ # in-memory matchmaking pool + friend-game invitations
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
```
## Configuration (environment)
@@ -81,6 +94,9 @@ internal/lobby/ # in-memory matchmaking pool + friend-game invitations
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
+20 -6
View File
@@ -23,6 +23,7 @@ import (
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
@@ -54,7 +55,8 @@ func main() {
// run wires the process dependencies in order — telemetry, database (with
// migrations), engine dictionaries, session cache, game domain (with its
// turn-timeout sweeper), HTTP server — and blocks until ctx is cancelled.
// turn-timeout sweeper), the robot opponent (pool + move driver) and the
// matchmaking reaper, HTTP server — and blocks until ctx is cancelled.
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
tel, err := telemetry.New(ctx, cfg.Telemetry)
if err != nil {
@@ -103,15 +105,27 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
// Stage 4 lobby & social domains. They have no active driver yet — their REST
// and stream surface is added with the gateway in Stage 6 so they are handed
// to the server (like the route groups) for the handlers to come.
// Stage 4 lobby & social domains. Their REST and stream surface is added with
// the gateway in Stage 6, so they are handed to the server (like the route
// groups) for the handlers to come.
mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer)
socialSvc := social.NewService(social.NewStore(db), accounts, games)
matchmaker := lobby.NewMatchmaker(games)
// Stage 5 robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window.
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
if err := robots.EnsurePool(ctx); err != nil {
return fmt.Errorf("provision robot pool: %w", err)
}
go robots.Run(ctx, cfg.Robot.DriveInterval)
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
logger.Info("lobby and social domains ready")
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
srv := server.New(cfg.HTTPAddr, server.Deps{
Logger: logger,
+9 -5
View File
@@ -21,10 +21,12 @@ import (
// Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email
// confirm-code flow in a later stage.
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity (Stage 5).
const (
KindTelegram = "telegram"
KindEmail = "email"
KindRobot = "robot"
)
// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
@@ -34,10 +36,12 @@ const uniqueViolation = "23505"
var ErrNotFound = errors.New("account: not found")
// Account is a durable internal account. AwayStart and AwayEnd bound the daily
// local-time window (in TimeZone) during which the player is asleep: the
// turn-timeout sweeper does not auto-resign them inside it, and the robot reuses
// it for its own sleep in a later stage. HintBalance is the player's wallet of
// purchasable hints, spent after a game's per-seat allowance.
// local-time window (in TimeZone) during which the player is asleep, so the
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
// own sleep is anchored to its human opponent's timezone with a per-game drift,
// computed in internal/robot, not from a robot account's away window.) HintBalance
// is the player's wallet of purchasable hints, spent after a game's per-seat
// allowance.
type Account struct {
ID uuid.UUID
DisplayName string
+27
View File
@@ -10,7 +10,9 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
)
@@ -26,6 +28,10 @@ type Config struct {
Telemetry telemetry.Config
// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
Game game.Config
// Lobby configures matchmaking robot substitution (wait window, reaper cadence).
Lobby lobby.Config
// Robot configures the robot opponent driver (scan cadence).
Robot robot.Config
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig
@@ -71,6 +77,19 @@ func Load() (Config, error) {
return Config{}, err
}
lb := lobby.DefaultConfig()
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
return Config{}, err
}
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
return Config{}, err
}
rb := robot.DefaultConfig()
if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil {
return Config{}, err
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
@@ -85,6 +104,8 @@ func Load() (Config, error) {
Postgres: pg,
Telemetry: tel,
Game: gm,
Lobby: lb,
Robot: rb,
SMTP: smtp,
}
if err := c.validate(); err != nil {
@@ -112,6 +133,12 @@ func (c Config) validate() error {
if err := c.Game.Validate(); err != nil {
return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err)
}
if err := c.Lobby.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if err := c.Robot.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
return nil
}
+15
View File
@@ -107,6 +107,21 @@ func (g *Game) HintView() (MoveRecord, bool) {
return g.decodeMove(move), true
}
// Candidates returns every legal play for the current player as decoded
// MoveRecords, ranked by descending score (so the first entry equals HintView's
// move). It is empty when the player has no legal play. The robot opponent picks
// from these by margin without importing the solver; each record carries the
// move's score, so a caller can choose by resulting score difference rather than
// always taking the maximum.
func (g *Game) Candidates() []MoveRecord {
moves := g.GenerateMoves()
out := make([]MoveRecord, len(moves))
for i, m := range moves {
out[i] = g.decodeMove(m)
}
return out
}
// Hand returns the player's current rack decoded to concrete letters, with "?"
// for each undesignated blank. The order mirrors the internal hand. It supplies
// the GCG rack field and the per-player game-state view.
+31
View File
@@ -43,6 +43,37 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
}
}
// TestCandidatesRankedAndMatchesHint checks that Candidates decodes every
// generated move, ranks them by descending score, and leads with the same move
// HintView reveals.
func TestCandidatesRankedAndMatchesHint(t *testing.T) {
g := openingGame(t)
cands := g.Candidates()
if len(cands) == 0 {
t.Fatal("opening game has no candidates")
}
if got, want := len(cands), len(g.GenerateMoves()); got != want {
t.Errorf("candidate count = %d, want %d (one per generated move)", got, want)
}
for i := 1; i < len(cands); i++ {
if cands[i-1].Score < cands[i].Score {
t.Errorf("candidates not ranked: [%d].Score=%d < [%d].Score=%d", i-1, cands[i-1].Score, i, cands[i].Score)
}
}
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if cands[0].Score != hint.Score {
t.Errorf("top candidate score = %d, want hint score %d", cands[0].Score, hint.Score)
}
for _, c := range cands {
if c.Action != ActionPlay {
t.Errorf("candidate action = %v, want play", c.Action)
}
}
}
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
// leaves the board, scores, turn and bag untouched.
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
+37
View File
@@ -426,6 +426,43 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
}
// Candidates returns the to-move player's legal plays for a seated player on
// their turn, ranked by descending score. It is the read the robot opponent uses
// to choose a move by margin; it spends nothing and mutates no state. It returns
// ErrNotAPlayer, ErrFinished or ErrNotYourTurn like the other turn-scoped reads.
func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return nil, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return nil, ErrNotAPlayer
}
if pre.Status != StatusActive {
return nil, ErrFinished
}
if pre.ToMove != seat {
return nil, ErrNotYourTurn
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return nil, err
}
return g.Candidates(), nil
}
// RobotTurns returns the robot driver's view of every active game seating one of
// robotIDs. It is the robot scheduler's periodic scan, mirroring the timeout
// sweeper's ActiveGames read; the driver derives each robot's deadline from the
// returned seed and turn cursor.
func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]RobotTurn, error) {
return svc.store.RobotTurns(ctx, robotIDs)
}
// 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) {
+45
View File
@@ -346,6 +346,51 @@ func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
return out, nil
}
// RobotTurns returns one row per active game seating any of the given accounts,
// for the robot scheduler. It joins games to game_players on the robot's seat and
// carries the game's turn cursor and bag seed; the driver filters these against
// each robot's per-game deadline. An empty id list returns no rows.
func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, error) {
if len(ids) == 0 {
return nil, 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.GamePlayers.Seat, table.GamePlayers.AccountID,
).FROM(
table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
).WHERE(
table.Games.Status.EQ(postgres.String(StatusActive)).
AND(table.GamePlayers.AccountID.IN(exprs...)),
).ORDER_BY(table.Games.TurnStartedAt.ASC())
var rows []struct {
model.Games
model.GamePlayers
}
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list robot turns: %w", err)
}
out := make([]RobotTurn, 0, len(rows))
for _, r := range rows {
out = append(out, RobotTurn{
GameID: r.Games.GameID,
RobotID: r.GamePlayers.AccountID,
RobotSeat: int(r.GamePlayers.Seat),
ToMove: int(r.Games.ToMove),
TurnStartedAt: r.Games.TurnStartedAt,
MoveCount: int(r.Games.MoveCount),
Seed: r.Games.Seed,
})
}
return out, nil
}
// 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) {
+15
View File
@@ -161,6 +161,21 @@ type HistoryView struct {
Moves []HistoryMove
}
// RobotTurn is the robot driver's view of one active game seating a robot: the
// seat the robot holds, whose turn it currently is, when that turn started, the
// move index and the bag seed. Seed is backend-internal state (never exposed in
// the public Game view); the robot derives its deterministic per-game behaviour
// from it, so the scheduler stays stateless and restart-safe.
type RobotTurn struct {
GameID uuid.UUID
RobotID uuid.UUID
RobotSeat int
ToMove int
TurnStartedAt time.Time
MoveCount int
Seed int64
}
// Complaint is a word-check complaint awaiting admin review (Stage 9).
type Complaint struct {
ID uuid.UUID
+1 -1
View File
@@ -32,7 +32,7 @@ func englishInvite() lobby.InvitationSettings {
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
ctx := context.Background()
mm := lobby.NewMatchmaker(newGameService())
mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
a, b := provisionAccount(t), provisionAccount(t)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
+267
View File
@@ -0,0 +1,267 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel/metric/noop"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/robot"
)
// newRobotService builds a robot service over games (shared so its moves and the
// test's human moves use the same live-game cache and per-game locks), a fresh
// social service for nudges, and a no-op meter.
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
t.Helper()
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
}
// newMatchmaker builds a matchmaker starting real games and substituting from
// robots after wait.
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
t.Helper()
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
}
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
// idle) at a chosen instant, independent of wall time.
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.games SET turn_started_at = $2 WHERE game_id = $1`, id, at); err != nil {
t.Fatalf("set turn_started_at: %v", err)
}
}
// isRobotAccount reports whether the account carries a robot identity.
func isRobotAccount(t *testing.T, id uuid.UUID) bool {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.identities WHERE account_id = $1 AND kind = 'robot'`, id).Scan(&n); err != nil {
t.Fatalf("count robot identity: %v", err)
}
return n > 0
}
// countNudges counts the nudges senderID has sent in a game.
func countNudges(t *testing.T, gameID, senderID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.chat_messages WHERE game_id = $1 AND sender_id = $2 AND kind = 'nudge'`,
gameID, senderID).Scan(&n); err != nil {
t.Fatalf("count nudges: %v", err)
}
return n
}
// daytime is a fixed instant whose hour is awake for every sleep drift (the
// always-awake band is [10,21) local), used to drive robot moves deterministically.
var daytime = time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC)
// TestRobotPoolProvisionsRobotAccounts checks EnsurePool creates durable,
// chat/friend-blocked robot accounts (exercising the kind='robot' migration) and
// is idempotent.
func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
ctx := context.Background()
r := newRobotService(t, newGameService())
if err := r.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
if err := r.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool (idempotent): %v", err)
}
id, err := r.Pick()
if err != nil {
t.Fatalf("pick: %v", err)
}
if !isRobotAccount(t, id) {
t.Errorf("picked account %s is not a robot identity", id)
}
acc, err := account.NewStore(testDB).GetByID(ctx, id)
if err != nil {
t.Fatalf("get robot account: %v", err)
}
if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests {
t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
}
}
// TestRobotPlaysAutoMatchToEnd drives a robot through a full two-player game (the
// human plays greedily) and checks it finishes with a robot statistics row. The
// robot is forced due each turn by resetting the turn clock and driving at a fixed
// daytime instant, so the game does not depend on wall time.
func TestRobotPlaysAutoMatchToEnd(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)
}
robotID, err := robots.Pick()
if err != nil {
t.Fatalf("pick: %v", err)
}
human := provisionAccount(t)
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
robotSeat := 1 // seats = [human, robot]
finished := false
for i := 0; i < 400 && !finished; i++ {
_, toMove, status, err := svc.Participants(ctx, g.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != game.StatusActive {
finished = true
break
}
if toMove == robotSeat {
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) // well past any sampled delay
robots.Drive(ctx, daytime)
continue
}
playHuman(t, ctx, svc, g.ID, human)
}
if !finished {
t.Fatal("robot game did not finish within the move budget")
}
if _, _, _, mg, _, ok := readStats(t, robotID); !ok || mg < 0 {
t.Errorf("robot must have a statistics row after a finished game (found=%v, maxGame=%d)", ok, mg)
}
}
// TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a
// real robot account after the wait window, discoverable through Poll.
func TestMatchmakerSubstitutesRobotEndToEnd(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, 10*time.Second)
human := provisionAccount(t)
before := time.Now()
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
if r.Matched {
t.Fatal("first enqueue must wait")
}
mm.Reap(ctx, before.Add(11*time.Second))
got, err := mm.Poll(ctx, human)
if err != nil {
t.Fatalf("poll: %v", err)
}
if !got.Matched {
t.Fatal("expected a substituted game after the wait window")
}
seats, _, status, err := newGameService().Participants(ctx, got.Game.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != game.StatusActive || len(seats) != 2 {
t.Fatalf("substituted game: status %q seats %v", status, seats)
}
var human0, robot0 bool
for _, s := range seats {
switch {
case s == human:
human0 = true
case isRobotAccount(t, s):
robot0 = true
}
}
if !human0 || !robot0 {
t.Errorf("substituted seats must be the human and a robot, got %v", seats)
}
}
// TestRobotProactiveNudge checks the robot nudges the human after the idle
// threshold on the human's turn.
func TestRobotProactiveNudge(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)
}
robotID, err := robots.Pick()
if err != nil {
t.Fatalf("pick: %v", err)
}
human := provisionAccount(t)
seed := openingSeed(t)
// Seat the human first so it is the human's turn and the robot is the awaiter.
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for
// every drift.
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
setTurnStarted(t, g.ID, start)
robots.Drive(ctx, start.Add(13*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 1 {
t.Errorf("robot nudges = %d, want 1 after 13h idle on the human's turn", n)
}
}
// playHuman makes a greedy human move: the top candidate, else an exchange, else a
// pass.
func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, human uuid.UUID) {
t.Helper()
cands, err := svc.Candidates(ctx, gameID, human)
if err != nil {
t.Fatalf("human candidates: %v", err)
}
if len(cands) > 0 {
if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Dir, cands[0].Tiles); err != nil {
t.Fatalf("human play: %v", err)
}
return
}
st, err := svc.GameState(ctx, gameID, human)
if err != nil {
t.Fatalf("human state: %v", err)
}
if len(st.Rack) > 0 && st.BagLen >= len(st.Rack) {
if _, err := svc.Exchange(ctx, gameID, human, st.Rack); err != nil {
t.Fatalf("human exchange: %v", err)
}
return
}
if _, err := svc.Pass(ctx, gameID, human); err != nil {
t.Fatalf("human pass: %v", err)
}
}
+36
View File
@@ -0,0 +1,36 @@
package lobby
import (
"fmt"
"time"
)
// Config configures the matchmaking pool's robot substitution.
type Config struct {
// RobotWait is how long an auto-match player waits for a human before a robot
// is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT.
RobotWait time.Duration
// ReaperInterval is how often the substitution reaper scans for over-waited
// players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
ReaperInterval time.Duration
}
// DefaultConfig returns the matchmaking defaults: a 10-second wait
// (docs/ARCHITECTURE.md §7) scanned every second.
func DefaultConfig() Config {
return Config{
RobotWait: 10 * time.Second,
ReaperInterval: time.Second,
}
}
// Validate reports whether the configuration is usable.
func (c Config) Validate() error {
if c.RobotWait <= 0 {
return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait)
}
if c.ReaperInterval <= 0 {
return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval)
}
return nil
}
+7
View File
@@ -21,6 +21,13 @@ type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
}
// RobotProvider supplies a robot account to substitute for a missing human in
// auto-match. robot.Service satisfies it; it returns an error when no robot is
// available so the matchmaker can defer substitution.
type RobotProvider interface {
Pick() (uuid.UUID, error)
}
// Blocker reports whether two accounts have a block between them (either
// direction). social.Service satisfies it; the lobby uses it to refuse
// invitations between blocked accounts.
+148 -33
View File
@@ -7,34 +7,56 @@ import (
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
// the next two humans into a two-player game. It holds no database state and is
// lost on restart (players simply re-queue). It is safe for concurrent use.
// the next two humans into a two-player game, or — when no human arrives within
// the wait window — substitutes a robot. It holds no database state and is lost on
// restart (players simply re-queue). It is safe for concurrent use.
//
// Auto-match is anonymous, so the pool does not consult per-user blocks (those
// govern friends, chat and invitations between known players). Robot substitution
// for a missing human is added in a later stage.
// govern friends, chat and invitations between known players).
//
// A player who is queued learns of a match — by a waiting human being paired, or
// by robot substitution — through Poll, the interim delivery seam: production
// delivery is a notification (session/in-app push and the platform side-service,
// docs/ARCHITECTURE.md §10), wired with the gateway in a later stage.
type Matchmaker struct {
games GameCreator
games GameCreator
robots RobotProvider
waitDelay time.Duration
clock func() time.Time
log *zap.Logger
mu sync.Mutex
queues map[engine.Variant][]uuid.UUID
queued map[uuid.UUID]engine.Variant
rng *rand.Rand
mu sync.Mutex
queues map[engine.Variant][]uuid.UUID
queued map[uuid.UUID]engine.Variant
waitingSince map[uuid.UUID]time.Time
results map[uuid.UUID]game.Game
rng *rand.Rand
}
// NewMatchmaker constructs a Matchmaker that starts matched games through games.
func NewMatchmaker(games GameCreator) *Matchmaker {
// NewMatchmaker constructs a Matchmaker that starts matched games through games
// and substitutes a robot from robots when a player waits longer than waitDelay.
func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Duration, log *zap.Logger) *Matchmaker {
if log == nil {
log = zap.NewNop()
}
return &Matchmaker{
games: games,
queues: make(map[engine.Variant][]uuid.UUID),
queued: make(map[uuid.UUID]engine.Variant),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
games: games,
robots: robots,
waitDelay: waitDelay,
clock: func() time.Time { return time.Now().UTC() },
log: log,
queues: make(map[engine.Variant][]uuid.UUID),
queued: make(map[uuid.UUID]engine.Variant),
waitingSince: make(map[uuid.UUID]time.Time),
results: make(map[uuid.UUID]game.Game),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
@@ -47,7 +69,8 @@ type EnqueueResult struct {
// Enqueue joins accountID to the variant pool. If an opponent already waits, the
// two are paired (seat order randomised for first-move fairness) and a game starts
// immediately; otherwise the account waits. An account already waiting in any pool
// immediately; otherwise the account waits, and a later pairing or robot
// substitution is delivered through Poll. An account already waiting in any pool
// gets ErrAlreadyQueued.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) {
m.mu.Lock()
@@ -59,31 +82,42 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
if len(q) == 0 {
m.queues[variant] = append(q, accountID)
m.queued[accountID] = variant
m.waitingSince[accountID] = m.clock()
m.mu.Unlock()
return EnqueueResult{}, nil
}
opponent := q[0]
m.queues[variant] = q[1:]
delete(m.queued, opponent)
m.removeLocked(opponent, variant)
seats := []uuid.UUID{opponent, accountID}
if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0]
}
m.mu.Unlock()
g, err := m.games.Create(ctx, game.CreateParams{
Variant: variant,
Seats: seats,
TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
})
g, err := m.games.Create(ctx, autoMatchParams(variant, seats))
if err != nil {
return EnqueueResult{}, err
}
// The opponent was waiting; record the game so they can collect it via Poll.
m.mu.Lock()
m.results[opponent] = g
m.mu.Unlock()
return EnqueueResult{Matched: true, Game: g}, nil
}
// Poll reports whether accountID has been matched since it queued, returning the
// started game once (the result is drained on read). It reports Matched=false
// while the account is still waiting or has no pending result.
func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
if g, ok := m.results[accountID]; ok {
delete(m.results, accountID)
return EnqueueResult{Matched: true, Game: g}, nil
}
return EnqueueResult{}, nil
}
// Cancel removes accountID from whatever pool it waits in, reporting whether it
// was queued.
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
@@ -93,14 +127,7 @@ func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
if !ok {
return false
}
delete(m.queued, accountID)
q := m.queues[variant]
for i, id := range q {
if id == accountID {
m.queues[variant] = append(q[:i], q[i+1:]...)
break
}
}
m.removeLocked(accountID, variant)
return true
}
@@ -110,3 +137,91 @@ func (m *Matchmaker) QueueLen(variant engine.Variant) int {
defer m.mu.Unlock()
return len(m.queues[variant])
}
// RunReaper substitutes a robot for any player that has waited past waitDelay,
// scanning every interval until ctx is cancelled. It is started once from main.
func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.Reap(ctx, m.clock())
}
}
}
// Reap pairs every player that has waited past waitDelay with a freshly picked
// robot and starts the game, recording it for the player's Poll. RunReaper calls
// it on a timer; it takes now explicitly so tests and ops can drive a single pass
// at a chosen instant. A waiter is only dequeued once a robot is secured, so a
// momentarily empty pool just defers substitution to a later tick.
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
type sub struct {
human uuid.UUID
variant engine.Variant
seats []uuid.UUID
}
m.mu.Lock()
var due []uuid.UUID
for acc, since := range m.waitingSince {
if now.Sub(since) >= m.waitDelay {
due = append(due, acc)
}
}
var subs []sub
for _, acc := range due {
robotID, err := m.robots.Pick()
if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err))
continue
}
variant := m.queued[acc]
m.removeLocked(acc, variant)
seats := []uuid.UUID{acc, robotID}
if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0]
}
subs = append(subs, sub{human: acc, variant: variant, seats: seats})
}
m.mu.Unlock()
for _, s := range subs {
g, err := m.games.Create(ctx, autoMatchParams(s.variant, s.seats))
if err != nil {
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
continue
}
m.mu.Lock()
m.results[s.human] = g
m.mu.Unlock()
}
}
// removeLocked drops accountID from the queue, the queued index and the waiting
// clock. The caller holds m.mu.
func (m *Matchmaker) removeLocked(accountID uuid.UUID, variant engine.Variant) {
delete(m.queued, accountID)
delete(m.waitingSince, accountID)
q := m.queues[variant]
for i, id := range q {
if id == accountID {
m.queues[variant] = append(q[:i], q[i+1:]...)
break
}
}
}
// autoMatchParams builds the create parameters for a two-player auto-match with
// the casual defaults.
func autoMatchParams(variant engine.Variant, seats []uuid.UUID) game.CreateParams {
return game.CreateParams{
Variant: variant,
Seats: seats,
TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
}
}
+113 -5
View File
@@ -4,8 +4,10 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
@@ -25,6 +27,28 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
}
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
// an empty pool.
type fakeRobots struct {
id uuid.UUID
err error
}
func (f *fakeRobots) Pick() (uuid.UUID, error) {
if f.err != nil {
return uuid.Nil, f.err
}
return f.id, nil
}
// testWaitDelay is long enough that the reaper never fires in the pairing tests
// (which do not run it); the substitution tests drive reap directly.
const testWaitDelay = 10 * time.Second
func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker {
return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop())
}
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
for _, w := range want {
found := false
@@ -43,7 +67,7 @@ func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
func TestMatchmakerPairsTwoHumans(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator)
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
a, b := uuid.New(), uuid.New()
@@ -78,10 +102,22 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) {
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
t.Errorf("auto-match defaults not applied: %+v", p)
}
// The waiting opponent learns of the match through Poll, exactly once.
got, err := mm.Poll(ctx, a)
if err != nil {
t.Fatalf("poll a: %v", err)
}
if !got.Matched || got.Game.ID != r2.Game.ID {
t.Errorf("poll a = %+v, want the matched game %s", got, r2.Game.ID)
}
if again, _ := mm.Poll(ctx, a); again.Matched {
t.Error("poll result must drain after the first read")
}
}
func TestMatchmakerAlreadyQueued(t *testing.T) {
mm := NewMatchmaker(&fakeCreator{})
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
@@ -93,7 +129,7 @@ func TestMatchmakerAlreadyQueued(t *testing.T) {
}
func TestMatchmakerCancel(t *testing.T) {
mm := NewMatchmaker(&fakeCreator{})
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
@@ -112,7 +148,7 @@ func TestMatchmakerCancel(t *testing.T) {
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator)
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil {
t.Fatalf("enqueue en: %v", err)
@@ -130,7 +166,7 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) {
func TestMatchmakerFIFO(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator)
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
a, b, c := uuid.New(), uuid.New(), uuid.New()
for _, id := range []uuid.UUID{a, b, c} {
@@ -149,3 +185,75 @@ func TestMatchmakerFIFO(t *testing.T) {
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
}
}
func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
creator := &fakeCreator{}
robotID := uuid.New()
mm := newTestMatchmaker(creator, robotID)
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window
if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 {
t.Fatalf("must not substitute before the wait: created=%d queued=%d", len(creator.created), mm.QueueLen(engine.VariantEnglish))
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // past the wait window
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1 after substitution", len(creator.created))
}
if !seatsContain(creator.created[0].Seats, a, robotID) {
t.Errorf("substituted game seats = %v, want human %s and robot %s", creator.created[0].Seats, a, robotID)
}
if mm.QueueLen(engine.VariantEnglish) != 0 {
t.Errorf("waiter should be dequeued after substitution")
}
got, err := mm.Poll(ctx, a)
if err != nil || !got.Matched {
t.Errorf("poll after substitution = %+v err=%v, want matched", got, err)
}
}
func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Cancel(ctx, a)
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
if len(creator.created) != 0 {
t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created))
}
}
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
if len(creator.created) != 0 {
t.Errorf("no robot available: must not create a game; created %d", len(creator.created))
}
if mm.QueueLen(engine.VariantEnglish) != 1 {
t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish))
}
}
@@ -0,0 +1,15 @@
-- +goose Up
-- Stage 5 robot opponent: admit a 'robot' identity kind so the robot pool can be
-- provisioned as durable accounts (one identity row per named robot). This widens
-- the identities kind CHECK only; no table or column changes, so the generated
-- jet code is unaffected.
SET search_path = backend, pg_catalog;
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot'));
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email'));
+201
View File
@@ -0,0 +1,201 @@
package robot
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.uber.org/zap"
"scrabble/backend/internal/game"
)
// Run drives the robot until ctx is cancelled, scanning for due turns every
// interval. It mirrors the game turn-timeout sweeper and is started once from
// main; it simply calls Drive on each tick.
func (s *Service) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.Drive(ctx, s.clock())
}
}
}
// Drive performs one scan: it handles every active game seating a pool robot as
// of now. Run calls it on a timer; it takes now explicitly so tests and ops can
// drive a single pass at a chosen instant (mirroring game.Service.SweepTimeouts).
func (s *Service) Drive(ctx context.Context, now time.Time) {
turns, err := s.games.RobotTurns(ctx, s.poolIDs())
if err != nil {
s.log.Warn("robot scan failed", zap.Error(err))
return
}
for _, rt := range turns {
if err := s.handle(ctx, rt, now); err != nil {
s.log.Warn("robot turn failed", zap.String("game", rt.GameID.String()), zap.Error(err))
}
}
}
// handle resolves the opponent (a two-player auto-match), honours the robot's
// sleep window, then either makes a move on the robot's turn or considers a
// proactive nudge on the human's turn. The seat→account mapping is fixed for the
// game's life, so reading it at a different instant than the scan is consistent;
// the turn cursor comes from the scan snapshot (rt), and the submit/nudge calls
// re-validate against the live state and skip benignly if it has moved on.
func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time) error {
seats, _, status, err := s.games.Participants(ctx, rt.GameID)
if err != nil {
return err
}
if status != game.StatusActive {
return nil
}
oppID, ok := opponentOf(seats, rt.RobotSeat)
if !ok {
return nil
}
opp, err := s.accounts.GetByID(ctx, oppID)
if err != nil {
return err
}
if asleep(opp.TimeZone, sleepDrift(rt.Seed), now) {
return nil
}
if rt.ToMove == rt.RobotSeat {
return s.maybeMove(ctx, rt, oppID, now)
}
return s.maybeNudge(ctx, rt, now)
}
// 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.
func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.UUID, now time.Time) error {
if now.Before(rt.TurnStartedAt.Add(moveDelay(rt.Seed, rt.MoveCount))) {
last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, oppID)
if err != nil {
return err
}
if !ok || !last.After(rt.TurnStartedAt) {
return nil // not yet due and no nudge this turn
}
if now.Before(last.Add(nudgeReplyDelay(rt.Seed, rt.MoveCount))) {
return nil // within the reply window
}
}
return s.act(ctx, rt, now)
}
// maybeNudge sends a proactive nudge once the human has been idle past the
// threshold. The social service enforces the once-per-hour-per-game limit and
// rejects a nudge on the robot's own turn, so any such rejection is benign.
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
if now.Sub(rt.TurnStartedAt) < proactiveNudgeIdle {
return nil
}
if _, err := s.social.Nudge(ctx, rt.GameID, rt.RobotID); err != nil {
s.log.Debug("robot nudge skipped", zap.String("game", rt.GameID.String()), zap.Error(err))
}
return nil
}
// act reads the live turn, chooses a move by margin and submits it. State that
// has moved on since the scan (a finished game, a turn that is no longer the
// robot's) surfaces as a benign error and is skipped.
func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) error {
st, err := s.games.GameState(ctx, rt.GameID, rt.RobotID)
if err != nil {
return skipBenign(err)
}
cands, err := s.games.Candidates(ctx, rt.GameID, rt.RobotID)
if err != nil {
return skipBenign(err)
}
myScore := st.Game.Seats[st.Seat].Score
oppScore := bestOpponentScore(st.Game.Seats, st.Seat)
d := selectMove(cands, myScore, oppScore, playToWin(rt.Seed), defaultBand, st.Rack, st.BagLen)
var res game.MoveResult
switch d.kind {
case decidePlay:
res, err = s.games.SubmitPlay(ctx, rt.GameID, rt.RobotID, d.move.Dir, d.move.Tiles)
case decideExchange:
res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange)
default:
res, err = s.games.Pass(ctx, rt.GameID, rt.RobotID)
}
if err != nil {
return skipBenign(err)
}
s.recordFinish(ctx, rt.GameID, rt.RobotID, res.Game)
return nil
}
// recordFinish counts and logs a robot game that the robot's move has just
// finished. account_stats remains the authoritative, complete balance metric
// (it also captures games the human finishes); this live counter only sees
// robot-finished games.
func (s *Service) recordFinish(ctx context.Context, gameID, robotID uuid.UUID, g game.Game) {
if g.Status != game.StatusFinished {
return
}
result := "draw"
for _, seat := range g.Seats {
if seat.IsWinner {
if seat.AccountID == robotID {
result = "win"
} else {
result = "loss"
}
break
}
}
s.finished.Add(ctx, 1, metric.WithAttributes(attribute.String("result", result)))
s.log.Info("robot game finished",
zap.String("game", gameID.String()),
zap.String("result", result),
zap.String("reason", g.EndReason))
}
// opponentOf returns the account at the single non-robot seat of a two-player
// auto-match, and false when none differs from the robot seat.
func opponentOf(seats []uuid.UUID, robotSeat int) (uuid.UUID, bool) {
for seat, id := range seats {
if seat != robotSeat {
return id, true
}
}
return uuid.Nil, false
}
// bestOpponentScore is the highest score among the seats other than the robot's.
func bestOpponentScore(seats []game.Seat, robotSeat int) int {
best := 0
for _, s := range seats {
if s.Seat != robotSeat && s.Score > best {
best = s.Score
}
}
return best
}
// skipBenign swallows the errors that mean the game moved on since the scan (it
// finished, or it is no longer the robot's turn), so the driver simply tries
// again next tick.
func skipBenign(err error) error {
if errors.Is(err, game.ErrFinished) || errors.Is(err, game.ErrNotYourTurn) || errors.Is(err, game.ErrNotAPlayer) {
return nil
}
return err
}
+177
View File
@@ -0,0 +1,177 @@
// Package robot is the human-like computer opponent. It substitutes for a missing
// human in two-player auto-match: a pool of durable accounts (one robot identity
// each) is provisioned at startup, and a background driver makes their moves with
// human-like timing, a night sleep window and nudge behaviour
// (docs/ARCHITECTURE.md §7).
//
// The robot consumes the public game API as an ordinary seated player and works
// on decoded values only, so it never imports the solver (only internal/engine
// does). All of a robot's per-game and per-turn choices are derived
// deterministically from the game's bag seed (see strategy.go), so the driver
// holds no per-game state and is restart-safe.
package robot
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"sync"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
// ErrNoRobotAvailable is returned by Pick when the pool is empty (EnsurePool has
// not run or failed).
var ErrNoRobotAvailable = errors.New("robot: no robot available in the pool")
// GameDriver is the slice of the game domain the robot needs: scanning its active
// games, reading a turn's candidates and state, and making moves as a seated
// player. game.Service satisfies it.
type GameDriver interface {
RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]game.RobotTurn, 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)
SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (game.MoveResult, error)
Pass(ctx context.Context, gameID, accountID uuid.UUID) (game.MoveResult, error)
Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error)
}
// Nudger is the slice of the social domain the robot needs: sending a proactive
// nudge and reading the opponent's last nudge to answer it. social.Service
// satisfies it.
type Nudger interface {
Nudge(ctx context.Context, gameID, senderID uuid.UUID) (social.Message, error)
LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error)
}
// robotNames is the curated, human-like name pool. Each name backs one durable
// robot account, addressed by a stable robot identity (its lower-cased name).
var robotNames = []string{
"Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan",
"Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage",
}
// Config configures the robot subsystem.
type Config struct {
// DriveInterval is how often the driver scans for robot turns. Sourced from
// BACKEND_ROBOT_DRIVE_INTERVAL.
DriveInterval time.Duration
}
// DefaultConfig returns the robot configuration defaults.
func DefaultConfig() Config {
return Config{DriveInterval: 30 * time.Second}
}
// Validate reports whether the configuration is usable.
func (c Config) Validate() error {
if c.DriveInterval <= 0 {
return fmt.Errorf("robot: drive interval must be positive, got %s", c.DriveInterval)
}
return nil
}
// Service owns the robot pool and the move driver. It is safe for concurrent use.
type Service struct {
games GameDriver
accounts *account.Store
social Nudger
finished metric.Int64Counter
clock func() time.Time
log *zap.Logger
mu sync.RWMutex
pool []uuid.UUID
}
// NewService constructs a robot Service. games and social are the domain seams it
// drives; accounts provisions the pool and resolves opponent timezones; meter
// records the balance counter; log carries driver diagnostics.
func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter metric.Meter, log *zap.Logger) *Service {
if log == nil {
log = zap.NewNop()
}
counter, err := meter.Int64Counter(
"robot_games_finished_total",
metric.WithDescription("Robot games finished, labelled by result from the robot's view (win/loss/draw)."),
)
if err != nil {
log.Warn("robot: create finished counter", zap.Error(err))
counter, _ = noop.NewMeterProvider().Meter("robot").Int64Counter("robot_games_finished_total")
}
return &Service{
games: games,
accounts: accounts,
social: soc,
finished: counter,
clock: func() time.Time { return time.Now().UTC() },
log: log,
}
}
// EnsurePool idempotently provisions the named robot accounts and records their
// ids as the pool. Each robot is a durable account bound to a robot identity,
// with chat and friend requests blocked so it never engages socially
// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary
// registry: a failure fails the boot.
func (s *Service) EnsurePool(ctx context.Context) error {
ids := make([]uuid.UUID, 0, len(robotNames))
for _, name := range robotNames {
acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name))
if err != nil {
return fmt.Errorf("robot: provision %q: %w", name, err)
}
if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests {
if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
DisplayName: name,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart,
AwayEnd: acc.AwayEnd,
BlockChat: true,
BlockFriendRequests: true,
}); err != nil {
return fmt.Errorf("robot: profile %q: %w", name, err)
}
}
ids = append(ids, acc.ID)
}
s.mu.Lock()
s.pool = ids
s.mu.Unlock()
return nil
}
// Pick returns a random robot account from the pool, for the matchmaker to
// substitute into an auto-match. It satisfies lobby.RobotProvider.
func (s *Service) Pick() (uuid.UUID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.pool) == 0 {
return uuid.Nil, ErrNoRobotAvailable
}
return s.pool[rand.IntN(len(s.pool))], nil
}
// poolIDs returns a snapshot of the pool for the driver scan.
func (s *Service) poolIDs() []uuid.UUID {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]uuid.UUID(nil), s.pool...)
}
// externalID is the stable robot identity for a pool name.
func externalID(name string) string {
return "robot-" + name
}
+201
View File
@@ -0,0 +1,201 @@
package robot
import (
"encoding/binary"
"hash/fnv"
"math"
"time"
"scrabble/backend/internal/engine"
)
// The robot's per-game and per-turn choices are derived deterministically from
// the game's bag seed, so the scheduler keeps no extra state and recomputes the
// same behaviour on every tick and after a restart (mirroring how the engine
// replays a game from the same seed). The mixing must be stable across process
// restarts, so it uses FNV-1a rather than hash/maphash (whose seed is process
// random).
const (
// playToWinPercent is the probability, in percent, that the robot decides at
// game start to play to win; the rest of the time it plays to lose, so the
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
playToWinPercent = 40
// delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the
// right-skewed distribution (short delays frequent). With skew 3.5 the median
// is about 10 minutes and the mean about 20, with a tail out to the maximum.
delayMinMinutes = 2.0
delayMaxMinutes = 90.0
delaySkew = 3.5
// nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot
// answers a daytime nudge on its turn.
nudgeReplyMinMinutes = 2.0
nudgeReplyMaxMinutes = 10.0
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
// while the local hour is in [sleepStartHour, sleepEndHour).
sleepStartHour = 0
sleepEndHour = 7
// sleepDriftHours is the half-width of the random drift applied to the robot's
// sleep window relative to the opponent's timezone, in hours.
sleepDriftHours = 3
// proactiveNudgeIdle is how long the robot waits on the human's turn before it
// proactively nudges (subject to the social once-per-hour-per-game limit).
proactiveNudgeIdle = 12 * time.Hour
)
// defaultBand is the target resulting score margin after the robot's move: when
// playing to win it aims to lead by 1..30 points, when playing to lose it aims to
// trail by 1..30 (the band is negated). It picks the candidate closest to the
// band rather than the maximum (docs/ARCHITECTURE.md §7).
var defaultBand = marginBand{lo: 1, hi: 30}
// marginBand is an inclusive target range for the resulting score margin
// (own score after the move minus the opponent's).
type marginBand struct{ lo, hi int }
// decisionKind enumerates the move the robot makes on its turn.
type decisionKind int
const (
decidePlay decisionKind = iota
decideExchange
decidePass
)
// decision is the robot's chosen action for a turn: a play (Move), an exchange of
// the listed tiles, or a pass.
type decision struct {
kind decisionKind
move engine.MoveRecord
exchange []string
}
// mix folds the game seed and a salt (a label plus optional integers such as the
// move index) into a stable 64-bit value. It is deterministic across process
// restarts.
func mix(seed int64, salt string, nums ...int) uint64 {
h := fnv.New64a()
var b [8]byte
binary.LittleEndian.PutUint64(b[:], uint64(seed))
_, _ = h.Write(b[:])
_, _ = h.Write([]byte(salt))
for _, n := range nums {
binary.LittleEndian.PutUint64(b[:], uint64(int64(n)))
_, _ = h.Write(b[:])
}
return h.Sum64()
}
// unitFloat maps a mixed value to a float in [0, 1).
func unitFloat(v uint64) float64 {
return float64(v) / (float64(math.MaxUint64) + 1)
}
// playToWin reports the robot's once-per-game decision to play to win, derived
// from the seed so it is fixed for the whole game.
func playToWin(seed int64) bool {
return mix(seed, "win")%100 < playToWinPercent
}
// moveDelay is the robot's think time for the move at moveCount, sampled from the
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
func moveDelay(seed int64, moveCount int) time.Duration {
u := unitFloat(mix(seed, "delay", moveCount))
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
return time.Duration(mins * float64(time.Minute))
}
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes).
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
u := unitFloat(mix(seed, "nudge", moveCount))
mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u
return time.Duration(mins * float64(time.Minute))
}
// sleepDrift is the per-game shift of the robot's sleep window relative to the
// opponent's timezone, in [-sleepDriftHours, +sleepDriftHours] hours.
func sleepDrift(seed int64) time.Duration {
span := 2*sleepDriftHours + 1
h := int(mix(seed, "tz")%uint64(span)) - sleepDriftHours
return time.Duration(h) * time.Hour
}
// asleep reports whether the robot is in its nightly sleep window at now. The
// window is [sleepStartHour, sleepEndHour) in the opponent's timezone shifted by
// drift; an unknown or empty timezone falls back to UTC.
func asleep(opponentTZ string, drift time.Duration, now time.Time) bool {
local := now.In(loadLocation(opponentTZ)).Add(drift)
h := local.Hour()
return h >= sleepStartHour && h < sleepEndHour
}
// loadLocation resolves an IANA timezone name, falling back to UTC when it is
// empty or unknown (so a bad opponent profile never breaks the driver).
func loadLocation(name string) *time.Location {
if name == "" {
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
}
// selectMove chooses the robot's action given the ranked candidate plays, the
// current scores, the play-to-win decision and the target band. With at least one
// legal play it picks the candidate whose resulting margin (myScore + score -
// oppScore) is closest to the band, breaking ties toward the conservative edge
// (the smallest lead when winning, the smallest deficit when losing). With no
// legal play it exchanges the whole rack when the bag can refill it, else passes.
func selectMove(cands []engine.MoveRecord, myScore, oppScore int, win bool, band marginBand, rack []string, bagLen int) decision {
if len(cands) == 0 {
if len(rack) > 0 && bagLen >= len(rack) {
return decision{kind: decideExchange, exchange: append([]string(nil), rack...)}
}
return decision{kind: decidePass}
}
lo, hi := band.lo, band.hi
if !win {
lo, hi = -band.hi, -band.lo
}
margin := func(c engine.MoveRecord) int { return myScore + c.Score - oppScore }
best := 0
bestDist := math.MaxInt
for i, c := range cands {
m := margin(c)
dist := distanceToBand(m, lo, hi)
switch {
case dist < bestDist:
best, bestDist = i, dist
case dist == bestDist:
// Conservative tie-break inside the band: keep the lead (win) or the
// deficit (lose) small.
if win && m < margin(cands[best]) || !win && m > margin(cands[best]) {
best = i
}
}
}
return decision{kind: decidePlay, move: cands[best]}
}
// distanceToBand is how far m lies outside [lo, hi], or 0 when inside.
func distanceToBand(m, lo, hi int) int {
switch {
case m < lo:
return lo - m
case m > hi:
return m - hi
default:
return 0
}
}
+190
View File
@@ -0,0 +1,190 @@
package robot
import (
"sort"
"testing"
"time"
"scrabble/backend/internal/engine"
)
// TestPlayToWinDistribution checks the once-per-game decision is fixed per seed
// and lands near the 40% target over many games.
func TestPlayToWinDistribution(t *testing.T) {
const n = 20000
wins := 0
for seed := int64(1); seed <= n; seed++ {
if playToWin(seed) {
wins++
}
if playToWin(seed) != playToWin(seed) {
t.Fatalf("playToWin not deterministic for seed %d", seed)
}
}
pct := float64(wins) / float64(n) * 100
if pct < 37 || pct > 43 {
t.Errorf("play-to-win rate = %.1f%%, want ~40%% (37-43)", pct)
}
}
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in
// [2min, 90min) and is reproducible for a (seed, moveCount).
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
for seed := int64(1); seed <= 200; seed++ {
for mc := 0; mc < 50; mc++ {
d := moveDelay(seed, mc)
if d < 2*time.Minute || d >= 90*time.Minute {
t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc)
}
if moveDelay(seed, mc) != d {
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
}
}
}
}
// TestMoveDelaySkew checks the distribution is right-skewed with the intended
// ~10-minute median: most delays are short, the mean sits above the median.
func TestMoveDelaySkew(t *testing.T) {
const n = 20000
mins := make([]float64, 0, n)
var sum float64
for mc := 0; mc < n; mc++ {
m := moveDelay(42, mc).Minutes()
mins = append(mins, m)
sum += m
}
sort.Float64s(mins)
median := mins[n/2]
mean := sum / float64(n)
if median < 7 || median > 13 {
t.Errorf("median delay = %.1f min, want ~10 (7-13)", median)
}
if mean <= median {
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
}
}
// TestSelectMovePlayToWinKeepsLeadSmall checks the winning robot prefers an
// in-band move with the smallest resulting lead.
func TestSelectMovePlayToWinKeepsLeadSmall(t *testing.T) {
cands := plays(50, 20, 5, 2) // margins 50,20,5,2 with scores even
d := selectMove(cands, 100, 100, true, marginBand{1, 30}, nil, 0)
if d.kind != decidePlay || d.move.Score != 2 {
t.Errorf("got kind=%d score=%d, want play score=2 (smallest in-band lead)", d.kind, d.move.Score)
}
}
// TestSelectMovePlayToLoseKeepsDeficitSmall checks the losing robot prefers the
// in-band move with the smallest deficit.
func TestSelectMovePlayToLoseKeepsDeficitSmall(t *testing.T) {
cands := plays(50, 20, 15, 5) // myScore 80, opp 100 → margins 30,0,-5,-15
d := selectMove(cands, 80, 100, false, marginBand{1, 30}, nil, 0)
if d.kind != decidePlay || d.move.Score != 15 {
t.Errorf("got kind=%d score=%d, want play score=15 (smallest deficit in band)", d.kind, d.move.Score)
}
}
// TestSelectMoveFallbackBehind checks that when even the best play cannot reach
// the band the winning robot takes the highest-scoring move (best catch-up).
func TestSelectMoveFallbackBehind(t *testing.T) {
cands := plays(10, 5) // myScore 50, opp 100 → margins -40,-45, both below band
d := selectMove(cands, 50, 100, true, marginBand{1, 30}, nil, 0)
if d.move.Score != 10 {
t.Errorf("got score=%d, want 10 (closest to band from below)", d.move.Score)
}
}
// TestSelectMoveFallbackOvershoot checks that when every play overshoots the band
// the winning robot takes the lowest-scoring move (keeps the lead near the cap).
func TestSelectMoveFallbackOvershoot(t *testing.T) {
cands := plays(40, 10) // myScore 100, opp 50 → margins 90,60, both above band
d := selectMove(cands, 100, 50, true, marginBand{1, 30}, nil, 0)
if d.move.Score != 10 {
t.Errorf("got score=%d, want 10 (closest to band from above)", d.move.Score)
}
}
// TestSelectMoveNoPlay checks the exchange-or-pass fallback.
func TestSelectMoveNoPlay(t *testing.T) {
rack := []string{"A", "B", "C"}
if d := selectMove(nil, 0, 0, true, defaultBand, rack, 5); d.kind != decideExchange || len(d.exchange) != 3 {
t.Errorf("with a refillable bag want exchange of 3, got kind=%d n=%d", d.kind, len(d.exchange))
}
if d := selectMove(nil, 0, 0, true, defaultBand, rack, 2); d.kind != decidePass {
t.Errorf("with a short bag want pass, got kind=%d", d.kind)
}
if d := selectMove(nil, 0, 0, true, defaultBand, nil, 9); d.kind != decidePass {
t.Errorf("with an empty rack want pass, got kind=%d", d.kind)
}
}
// TestSleepDriftBounds checks the drift stays within ±3h and is deterministic.
func TestSleepDriftBounds(t *testing.T) {
for seed := int64(1); seed <= 5000; seed++ {
d := sleepDrift(seed)
if d < -3*time.Hour || d > 3*time.Hour {
t.Fatalf("drift %s out of ±3h for seed %d", d, seed)
}
if sleepDrift(seed) != d {
t.Fatalf("drift not deterministic for seed %d", seed)
}
}
}
// TestAsleep covers the window, the drift shift, a real timezone and the
// midnight wrap.
func TestAsleep(t *testing.T) {
at := func(tz string, y int, mo time.Month, d, h int) time.Time {
loc, err := time.LoadLocation(tz)
if err != nil {
t.Fatalf("load %s: %v", tz, err)
}
return time.Date(y, mo, d, h, 0, 0, 0, loc)
}
cases := []struct {
name string
tz string
drift time.Duration
now time.Time
want bool
}{
{"utc night", "UTC", 0, at("UTC", 2024, 1, 1, 3), true},
{"utc day", "UTC", 0, at("UTC", 2024, 1, 1, 12), false},
{"utc edge end", "UTC", 0, at("UTC", 2024, 1, 1, 7), false},
{"drift+3 shifts earlier", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 22), true},
{"drift+3 awake midday", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 5), false},
{"drift-3 shifts later", "UTC", -3 * time.Hour, at("UTC", 2024, 1, 1, 9), true},
{"tokyo asleep", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 18), true}, // 03:00 JST
{"tokyo awake", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 0), false}, // 09:00 JST
{"bad tz falls back to utc", "Nowhere/Bad", 0, at("UTC", 2024, 1, 1, 3), true},
}
for _, c := range cases {
if got := asleep(c.tz, c.drift, c.now); got != c.want {
t.Errorf("%s: asleep = %v, want %v", c.name, got, c.want)
}
}
}
// TestMixDeterministic checks the mixer is stable (across calls, and so across
// restarts) and salt-sensitive.
func TestMixDeterministic(t *testing.T) {
if mix(7, "win") != mix(7, "win") {
t.Error("mix not stable for the same inputs")
}
if mix(7, "win") == mix(7, "delay") {
t.Error("mix should differ by salt")
}
if mix(7, "delay", 1) == mix(7, "delay", 2) {
t.Error("mix should differ by move index")
}
}
// plays builds candidate plays carrying only the given scores (ranked as passed).
func plays(scores ...int) []engine.MoveRecord {
out := make([]engine.MoveRecord, len(scores))
for i, s := range scores {
out[i] = engine.MoveRecord{Action: engine.ActionPlay, Score: s}
}
return out
}
+7
View File
@@ -103,6 +103,13 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
}
// LastNudgeAt returns the time of the most recent nudge senderID sent in the game
// and true, or the zero time and false when there is none. The robot opponent
// uses it to notice a human nudge on its turn and answer promptly.
func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
return svc.store.lastNudgeAt(ctx, gameID, senderID)
}
// Messages returns the per-game chat visible to viewerID: the viewer must be a
// seated player. Messages from a sender the viewer has a block with (either
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
+42 -18
View File
@@ -87,7 +87,8 @@ arrive from a platform rather than completing a mandatory registration).
a platform auto-provisions a durable account bound to that platform identity.
Concretely, platform and email identities share one `identities` table keyed by
a unique `(kind, external_id)`; email is an identity with `kind=email` and a
`confirmed` flag. The **email confirm-code flow** (Stage 4) binds an email to the
`confirmed` flag. A synthetic `kind='robot'` identity (Stage 5) backs each pooled
robot opponent (§7). The **email confirm-code flow** (Stage 4) binds an email to the
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
development log mailer when none is configured) and, once verified, attaches a
@@ -191,20 +192,37 @@ Key points:
## 7. Robot opponent
Substitutes for a human in 2-player auto-match when the pool yields no human
within 10 seconds. Designed to be indistinguishable from a person.
within 10 seconds (§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.
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
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
behaviour on every scan and after a restart — the same philosophy as journal
replay. A pool of durable accounts — each a `kind='robot'` identity (§4),
provisioned at startup with chat and friend requests blocked — backs the
human-like name pool; those two profile toggles are all the friend/DM blocking
requires (there is no DM surface; chat is per-game).
- **Balance**: at game start it decides once whether to play to win, with
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%). Adaptive difficulty is
post-MVP.
- **Margin targeting**: each turn it picks from `GenerateMoves` a move that
keeps the resulting lead (when playing to win) or deficit (when playing to
lose) small (≈ 120 points), rather than always the maximum.
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
Adaptive difficulty is post-MVP.
- **Margin targeting**: each turn it picks from the ranked candidates
(`engine.Candidates`) the move whose resulting lead (playing to win) or deficit
(playing to lose) is closest to a small band (**130 points**), rather than
always the maximum; with no legal play it exchanges a full rack when the bag can
refill it, else passes.
- **Timing**: per-move delay sampled from a right-skewed distribution (short
delays frequent), clamped to **[2, 90] minutes**; **sleeps 00:0007:00** in
the opponent's profile timezone (fallback UTC); on a daytime nudge after 60
minutes idle it replies within **210 minutes**; it proactively nudges the
human after 12 hours idle.
- Blocks friend requests and direct messages; uses a human-like name pool.
delays frequent, median ≈ 10 min), clamped to **[2, 90] minutes**; it
**sleeps 00:0007:00** anchored to the **opponent's** profile timezone with a
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
rather than running anti-phase; on a daytime nudge it replies within
**210 minutes**; it proactively nudges the human after **12 hours** idle
(subject to the once-per-hour chat limit).
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
## 8. Lobby & social
@@ -212,8 +230,10 @@ within 10 seconds. Designed to be indistinguishable from a person.
fixes the board language), pairing the next two humans into a two-player
auto-match with the seat order randomised for first-move fairness. The pool is
lost on restart (players re-queue) and is anonymous, so it does not consult
blocks. The 10 s wait and the **robot substitution** for a missing human are
added in Stage 5.
blocks. After **10 s** with no human a background reaper substitutes a pooled
robot (§7) and starts the game. A queued player learns of a pairing or a
substitution through the matchmaker's `Poll`, the interim delivery seam until the
live match-found notification (§10).
- **Friends**: a **request → accept** graph (one `friendships` table) — add by
friend list or internal ID now, by platform deep-link with Stage 8. Declining or
cancelling removes the pending request; blocking someone severs an existing
@@ -252,7 +272,8 @@ within 10 seconds. Designed to be indistinguishable from a person.
keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`),
`identities` (platform/email identities, unique `(kind, external_id)`),
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
Stage 5's migration `00004` admits the `robot` kind),
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
`games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`,
`game_moves` (the move journal), `complaints` and `account_stats`, and the
@@ -301,9 +322,12 @@ does not cover.
Two channels: **platform-native push** (out-of-app, via the platform
side-service — your-turn, nudge) and the **in-app live stream** (chat,
opponent-moved, while the app is open). Backend emits notification intents;
delivery fans out to the appropriate channel. Stage 4 **persists** the
notification-worthy events (chat messages and nudges) but does not yet deliver
them: the gRPC stream to the gateway and the platform push arrive in Stage 6 / 8.
delivery fans out to the appropriate channel. A **match-found** event (a human
pairing or a robot substitution in auto-match, §8) belongs to the same fabric.
Stage 4 **persists** the notification-worthy events (chat messages and nudges) but
does not yet deliver them, and Stage 5's match-found has no live channel yet: the
gRPC stream to the gateway and the platform push arrive in Stage 6 / 8. Until then
a waiting client retrieves its started game by polling the matchmaker (`Poll`).
## 11. Observability
+8 -3
View File
@@ -49,9 +49,14 @@ the bag or removed from play) is chosen when the game is created, and the leaver
rack is never shown to the others.
### Robot opponent *(Stage 5)*
Indistinguishable-from-human substitute in auto-match. Decides once whether to
play to win (~40%), targets a small score margin, plays with human-like timing
and a night sleep window, and nudges/answers nudges like a person.
When auto-match finds no human within ten seconds, a robot opponent takes the empty
seat so the game starts without waiting. It is meant to feel like a person: it
decides once per game whether to play to win (about 40% of the time, so the human
wins most games), aims for a close score rather than crushing or throwing the game,
and plays at a human pace — short thinking times for most moves, the occasional long
one, and a night-time pause that tracks the player's own day. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It
carries a human-like name and neither chats nor accepts friend requests.
### Social: friends, block, chat, nudge *(Stage 4)*
Send a friend request and have it accepted (decline or cancel withdraws it,
+8 -3
View File
@@ -48,9 +48,14 @@ session-токен; backend сопоставляет его с внутренн
показывается остальным.
### Робот-соперник *(Stage 5)*
Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на
победу (~40%), целится в небольшой отрыв по очкам, ходит с человеческим
таймингом и ночным сном, делает и принимает nudge как человек.
Если авто-подбор не находит человека за десять секунд, свободное место занимает
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
в чате и не принимает заявки в друзья.
### Социальное: друзья, блок, чат, nudge *(Stage 4)*
Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление —
+17 -8
View File
@@ -32,21 +32,30 @@ tests or touching CI.
Postgres-backed integration tests in `inttest` (full lifecycle to a natural
end, **journal-replay equivalence**, the turn-timeout sweep with away-window
grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
word-check and complaint capture, and per-game-lock serialisation). The robot
balance/margin regression tests arrive with Stage 5. Stage 4 adds the engine's
**multi-player drop-out** cases (continue after one resign, last-survivor win,
the tile-disposition bag effect) and a domain integration test for a 3-player
**timeout that continues**.
word-check and complaint capture, and per-game-lock serialisation). Stage 4 adds
the engine's **multi-player drop-out** cases (continue after one resign,
last-survivor win, the tile-disposition bag effect) and a domain integration test
for a 3-player **timeout that continues**. The engine also gains a `Candidates`
ranked/decoded test (Stage 5).
- **Social & lobby** *(Stage 4+)*`backend/internal/social` unit-tests the chat
**content filter** (links/emails/phones plus obfuscated forms) and
`backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing,
cancel, per-variant pools) with a fake game creator. Postgres-backed `inttest`
covers the friend request/accept lifecycle with the block/toggle guards, the
per-user block (and its severing of friendships), chat post/list with the IP,
cancel, per-variant pools, plus the Stage 5 **robot substitution** reaper and
`Poll` delivery) with fake game-creator and robot-provider seams. Postgres-backed
`inttest` covers the friend request/accept lifecycle with the block/toggle guards,
the per-user block (and its severing of friendships), chat post/list with the IP,
content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
email, expiry and attempt-cap) with a fixture mailer.
- **Robot** *(Stage 5+)*`backend/internal/robot` unit-tests the pure strategy:
the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay
(bounds, ~10-min median, determinism), the margin selection (win/lose, in-band
and out-of-band fallbacks, no-play exchange/pass), the sleep window with drift
and the midnight wrap, and mix restart-stability. Postgres-backed `inttest`
drives a robot through a full auto-match to a natural end (asserting a robot
statistics row), the matchmaker substitution end-to-end (enqueue → reap →
`[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge.
## Principles