# backend Internal-only domain service for the Scrabble platform (module `scrabble/backend`). It owns identity/sessions, accounts, and — in later stages — the lobby, game runtime, robot, chat, history and administration. Its only network consumers are the `gateway` and the platform side-services; it is never exposed publicly. As of Stage 1 the backend provides the foundation: configuration, the HTTP listener with the `/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache, and the durable accounts / identities / sessions data model. The session and account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the store/service layer they will call. Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver` library: a versioned dictionary registry, a deterministic tile bag, and a pure rules `Game` (legal plays, passes, exchanges, resignations and end-condition detection) that emits dictionary-independent move records. It is a library only; the game domain wires it into the process in Stage 3. Stage 3 adds `internal/game`, the game domain over the engine. Active games are event-sourced: a `games` row plus an append-only decoded move journal, with the live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It provides create, the play/pass/exchange/resign transitions, an unlimited score/legality preview, the hint (per-game allowance plus a profile wallet), the word-check tool with complaint capture, per-player game state, history and GCG export, per-account statistics on finish, and a background turn-timeout sweeper that auto-resigns overdue turns (honouring each player's daily away window). Like Stages 1–2 it is a service/store layer; the HTTP surface lands with the `gateway` (Stage 6). Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and friend-game invitations (invite → accept, starting a 2–4 player game once every invitee accepts). `internal/social` owns the friend graph (request/accept), per-user blocks, and per-game chat with nudges folded in as a message kind; chat messages are length-capped, content-filtered (no links/emails/phone numbers, including obfuscated forms) and stored with the sender's IP. `internal/account` gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a development log mailer). The engine now also handles **multi-player drop-out**: in a 3–4 player game a resignation or timeout drops that seat and the rest play on (the tile disposition is a per-game setting), the game ending when one active seat remains. As before this is a service/store layer — chat and nudges are persisted 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 -> 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 internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations migrations/ # embedded *.sql (goose), schema `backend` jet/ # generated go-jet models + table builders (committed) internal/account/ # durable accounts + platform/email identities (store) internal/session/ # opaque tokens, sessions store, write-through cache, service 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 (+ robot substitution) + friend-game invitations internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver ``` ## Configuration (environment) | Variable | Default | Notes | | --- | --- | --- | | `BACKEND_HTTP_ADDR` | `:8080` | HTTP listen address. | | `BACKEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. | | `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. | | `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. | | `BACKEND_POSTGRES_MAX_IDLE_CONNS` | `5` | Pool max idle connections. | | `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. | | `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. | | `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. | | `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none` or `stdout` (OTLP arrives later). | | `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none` or `stdout`. | | `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. | | `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. | | `BACKEND_SMTP_PASSWORD` | — | SMTP password. | | `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | ## Run ```sh docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ BACKEND_DICT_DIR=../../scrabble-solver/dawg \ go run ./cmd/backend ``` On boot the backend opens the pool, creates the `backend` schema if needed, applies the embedded migrations, loads the dictionaries into the engine registry (a hard dependency — a missing dictionary aborts the boot), warms the session cache and starts the game turn-timeout sweeper. `GET /healthz` reports liveness; `GET /readyz` reports 200 only when the database answers and the session cache is warmed. ## Migrations & generated code Migrations are plain goose SQL under `internal/postgres/migrations` (sequential `NNNNN_name.sql`), embedded and applied at startup. After changing the schema, regenerate the committed go-jet code (needs Docker): ```sh go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp container ``` ## Engine & dictionaries `internal/engine` consumes the sibling `scrabble-solver` module in-process. Its bare module path (`scrabble-solver`, not a URL) cannot be fetched via VCS, so the workspace `go.work` carries `replace scrabble-solver => ../scrabble-solver` and the build must run from the repository root (the workspace), not from this module in isolation. `github.com/iliadenisov/dafsa` (the DAWG loader) is a direct dependency. CI clones the public solver repository into `../scrabble-solver` before building (see `.gitea/workflows/`); locally, check it out next to this repository. Committed dictionaries (`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) live in the solver's `dawg/` directory; the engine loads them by `(variant, dict_version)` from a directory path. Since Stage 3 the backend loads them at startup from the **required** `BACKEND_DICT_DIR` (a missing dictionary aborts the boot); the future versioned-artifact direction is recorded in [`../PLAN.md`](../PLAN.md) TODO-2. ## Tests ```sh go test -count=1 ./... # unit tests (no Docker) go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker) ``` Integration tests are guarded by the `integration` build tag and run against a throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent rather than skipping. The `internal/engine` tests load the committed DAWGs from `BACKEND_DICT_DIR` (defaulting to the sibling `../scrabble-solver/dawg`) and fail loudly when that directory is absent.