Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution) #5
@@ -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 1–4: **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 2–10 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:00–07: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+move−opp)
|
||||
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
@@ -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. |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 (≈ 1–20 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 (**1–30 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:00–07:00** in
|
||||
the opponent's profile timezone (fallback UTC); on a daytime nudge after 60
|
||||
minutes idle it replies within **2–10 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:00–07: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
|
||||
**2–10 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
@@ -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,
|
||||
|
||||
@@ -48,9 +48,14 @@ session-токен; backend сопоставляет его с внутренн
|
||||
показывается остальным.
|
||||
|
||||
### Робот-соперник *(Stage 5)*
|
||||
Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на
|
||||
победу (~40%), целится в небольшой отрыв по очкам, ходит с человеческим
|
||||
таймингом и ночным сном, делает и принимает nudge как человек.
|
||||
Если авто-подбор не находит человека за десять секунд, свободное место занимает
|
||||
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
|
||||
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
|
||||
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
|
||||
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
|
||||
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
|
||||
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
|
||||
в чате и не принимает заявки в друзья.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge *(Stage 4)*
|
||||
Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление —
|
||||
|
||||
+17
-8
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user