751e74b14f
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), 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 with no
HTTP; the gateway surface lands in Stage 6.
Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.
Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.
Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
122 lines
6.5 KiB
Markdown
122 lines
6.5 KiB
Markdown
# 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).
|
||
|
||
## Package layout
|
||
|
||
```
|
||
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> 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
|
||
```
|
||
|
||
## 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. |
|
||
|
||
## 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.
|