bfa8797f8c
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged. New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer). Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
144 lines
8.2 KiB
Markdown
144 lines
8.2 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).
|
||
|
||
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.
|
||
|
||
## Package layout
|
||
|
||
```
|
||
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> 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 + friend-game invitations
|
||
```
|
||
|
||
## 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_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.
|