From effe6675bcd573eeb269bc114b08596c3f7d1ed7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 2 Jun 2026 11:57:58 +0200 Subject: [PATCH] Stage 0: scaffold monorepo, backend skeleton, docs, CI - go.work (Go 1.26.3) with backend module; deps added incrementally (gin+zap only) - backend: /healthz + /readyz, env config, graceful shutdown - docs: ARCHITECTURE, FUNCTIONAL (+ru mirror), TESTING - PLAN.md (stage tracker + per-stage open details) and CLAUDE.md (per-stage workflow) - .gitea go-unit CI (gofmt/vet/build/test) --- .claude/settings.json | 24 +++ .gitattributes | 2 + .gitea/workflows/go-unit.yaml | 56 ++++++ .gitignore | 15 ++ CLAUDE.md | 118 +++++++++++++ PLAN.md | 152 +++++++++++++++++ README.md | 45 +++++ backend/cmd/backend/main.go | 48 ++++++ backend/go.mod | 41 +++++ backend/go.sum | 95 +++++++++++ backend/internal/config/config.go | 57 +++++++ backend/internal/config/config_test.go | 46 +++++ backend/internal/server/server.go | 73 ++++++++ backend/internal/server/server_test.go | 22 +++ docs/ARCHITECTURE.md | 226 +++++++++++++++++++++++++ docs/FUNCTIONAL.md | 56 ++++++ docs/FUNCTIONAL_ru.md | 58 +++++++ docs/TESTING.md | 37 ++++ go.work | 3 + 19 files changed, 1174 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitattributes create mode 100644 .gitea/workflows/go-unit.yaml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 backend/cmd/backend/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/server/server.go create mode 100644 backend/internal/server/server_test.go create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/FUNCTIONAL.md create mode 100644 docs/FUNCTIONAL_ru.md create mode 100644 docs/TESTING.md create mode 100644 go.work diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ffe79a0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "permissions": { + "allow": [], + "defaultMode": "default" + }, + "sandbox": { + "network": { + "allowedDomains": [ + "github.com", + "registry.npmjs.org", + "*.npmjs.org", + "docker.com", + "docker.io", + "gcr.io", + "*.golang.org", + "gitea.iliadenisov.ru" + ], + "allowUnixSockets": [ + "/var/run/docker.sock" + ], + "allowLocalBinding": true + } + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24606c4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.dawg binary diff --git a/.gitea/workflows/go-unit.yaml b/.gitea/workflows/go-unit.yaml new file mode 100644 index 0000000..739a093 --- /dev/null +++ b/.gitea/workflows/go-unit.yaml @@ -0,0 +1,56 @@ +name: Tests · Go + +# Fast unit tests for the Go side of the monorepo. Runs on every push and pull +# request whose path filter matches a Go source directory. The module list +# grows as new go.work modules (gateway, pkg/*, platform/*) are added by later +# stages. + +on: + push: + paths: + - 'backend/**' + - 'go.work' + - 'go.work.sum' + - '.gitea/workflows/go-unit.yaml' + - '!**/*.md' + pull_request: + paths: + - 'backend/**' + - 'go.work' + - 'go.work.sum' + - '.gitea/workflows/go-unit.yaml' + - '!**/*.md' + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + cache: true + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "gofmt needed on:"; echo "$unformatted"; exit 1 + fi + + - name: vet + run: go vet ./backend/... + + - name: build + run: go build ./backend/... + + - name: test + # -count=1 disables the test cache so a green run never depends on a + # previous runner's cached state. + run: go test -count=1 ./backend/... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0578a85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +/bin/ +/dist/ + +# Per-developer Claude Code overrides; shared defaults live in settings.json +.claude/settings.local.json + +# Editor / OS +.vscode/ +.idea/ +.DS_Store + +# Local, unstaged env overrides +**/.env.local +**/.env.*.local diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a4c024f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# scrabble-game — project guide + +Multiplatform Scrabble game. Read this first every session. The owner drives the +project **one stage per session** (tariff constraint), so the repository — not +conversation memory — is the source of continuity. Keep it that way. + +## Sources of truth (read before changing behaviour) + +- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open + details to interview*. +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, + security, the decision record. Always describes current state. +- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) + mirror) — per-domain user stories. English authoritative. +- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate. + +## Mandatory per-stage workflow + +**Start of a stage** +1. Read `PLAN.md` (the stage's scope + *open details*) and the relevant `docs/`. +2. Analyse what the stage actually requires against the current code. +3. **Interview the owner** on every open detail and any fork not already fixed + in the plan — do not silently pick borderline decisions. Offer options with + brief pros/cons. +4. Only then implement, strictly within the stage's scope. + +**End of a stage** +1. Bake every new agreement back into `PLAN.md`, `docs/ARCHITECTURE.md`, + `docs/FUNCTIONAL.md` (+ `_ru`), the affected service `README`, and Go Doc + comments — in the **same** PR. Correct earlier stages' docs/code if a new + decision changes them. +2. Update the stage tracker; add a line under *Refinements logged during + implementation* for any plan deviation. +3. Get CI green, then mark the stage done. + +(The `stage-implementation` skill encodes this same loop and can be invoked.) + +## Conventions + +- All code, comments, identifiers, commits, docs, filenames in **English**. +- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, + the agreed persona and translation rules). +- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` + in the same patch (translate only the touched paragraphs). +- Prefer compact code; do not add deps, seams or knobs until a stage needs them. + Reuse before adding. Document added packages/types/funcs with Go Doc comments. +- Update or add tests for every functional change. + +## Branching & CI + +- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*` + and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on + `master` by necessity (an empty branch has nothing to PR into). +- After any push, watch the run to green before declaring a stage done — use the + ready-made watcher, never an inline poll loop: + `python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL` + / `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in + `.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`. + +## Stack + +Go 1.26.3, `go.work` monorepo, module paths `scrabble/`. Dependencies are +added **when first used** (incremental): backend currently uses only `gin` + +`zap`; pgx/goose/jet/OTel arrive with Stage 1+. Client↔gateway is Connect-RPC + +FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC +server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite, +packaged to native with Capacitor. Likely no Redis. + +## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3) + +Embedded **in-process as a library** — there is no per-game container. Public +API to reuse (do not reimplement): + +- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, + highest score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; + `scrabble.Apply(b, m)`; types `Move/Word/Placement/Direction/Mode` + (`scrabble-solver/scrabble/{solver,move,apply}.go`). +- `rules.English() / RussianScrabble() / Erudit()` + (`scrabble-solver/rules/rules.go`). +- `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`; + `selfplay.NewBag / Draw / Len` (bag pattern). +- Load committed dictionaries with `dawg.Load(path)` from + `github.com/iliadenisov/dafsa`: + `scrabble-solver/dawg/{en_sowpods,ru_scrabble,ru_erudit}.dawg`. + +Constraints: +- Words/tiles are **alphabet-index bytes**, meaningful only with the matching + `rules.Ruleset` (`Alphabet.Decode`); blank flag carried separately. **Decode + to real characters before persisting history** (history must be + dictionary-independent — see `docs/ARCHITECTURE.md` §9.1). +- The solver's `internal/*` is NOT importable from this sibling module. +- **GCG is test-only** in the solver (no public writer) — we ship our own. +- Wiring: add `replace scrabble-solver => ../scrabble-solver` to `go.work` in + **Stage 2** (when `internal/engine` first imports it), and make CI check out + the solver sibling (`https://gitea.iliadenisov.ru/.../scrabble-solver.git`). + It uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace). + +## Repository layout + +``` +go.work # use the existing modules; grows per stage +backend/ # module scrabble/backend + cmd/backend/ # main: boots HTTP listener + internal/config/ # env config + internal/server/ # gin engine, /healthz, /readyz, lifecycle +docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md +gateway/ ui/ pkg/ platform/ # added by their stages +``` + +## Build & test + +```sh +go build ./backend/... # per module ('./...' from the root won't span the workspace) +go vet ./backend/... +gofmt -l . # must print nothing +go test -count=1 ./backend/... +go run ./backend/cmd/backend # /healthz, /readyz on :8080 +``` diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8228e12 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,152 @@ +# Scrabble Game — implementation plan + +Living plan and **stage tracker**. Each stage is implemented in its own session; +the rules for starting and finishing a stage are in [`CLAUDE.md`](CLAUDE.md). +The architecture/decision record is [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); +behaviour is [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md). When a stage produces a +decision, bake it back here **and** into the affected docs/code in the same PR. + +## Context + +Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram +first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three +executables — `gateway`, `backend`, `ui` — plus per-platform side-services. +Deliberately simpler than the sibling `../galaxy-game` (idea donor, not a +template). The `../scrabble-solver` engine is embedded in-process as a library. + +## Locked decisions (recap — full record in docs/ARCHITECTURE.md) + +Stack: `go.work` monorepo, modules `scrabble/`, Go 1.26.x, backend +gin+pgx+Postgres(schema `backend`)+goose+zap+OTel (deps added when first used). +Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + `X-User-ID` +(gateway↔backend), gRPC server-stream for live events. Auth: platform-native, +thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure +HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram + +web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит. +Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout. +Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin +targeting, [2,90]min skewed timing, sleep 00:00–07:00 opp-tz, nudge logic. +Dictionary: pin per game. History: structured + GCG export, dictionary- +independent (see ARCHITECTURE §9.1). + +## Stage tracker + +| # | Stage | Status | +|---|-------|--------| +| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | **in review (CI)** | +| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | todo | +| 2 | Engine package over scrabble-solver | todo | +| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | todo | +| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | todo | +| 5 | Robot opponent | todo | +| 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 | +| 9 | Admin & dictionary ops (complaint review, version reload) | todo | +| 10 | Account linking & merge | todo | +| 11 | Polish (observability, perf with evidence, deploy) | todo | + +Scaffolding is incremental: `go.work` lists only existing modules; each stage +adds the modules it needs. + +## Stages + +Each stage: read this plan + relevant docs, **interview the owner on the open +details below**, implement within scope, then update plan/docs/code and get CI +green before marking done. + +### Stage 0 — Scaffolding *(in review)* +Scope: `go.work` (Go 1.26.3, `use ./backend`); minimal runnable `backend` +(gin, zap, `/healthz`, `/readyz`, env config); docs skeleton; `PLAN.md`; +`CLAUDE.md`; `.gitea/workflows/go-unit.yaml`; README; `.gitignore`. +Acceptance: `go build ./backend/...` + `go vet` + gofmt clean + +`go test ./backend/...` green; CI green on push. + +### Stage 1 — Backend foundation +Scope: config/server route groups (`/api/v1/{public,user,internal,admin}`, +probes), Postgres (pgx) + embedded goose migrations + schema `backend`, +telemetry (OTel) wiring, in-memory cache scaffolding, thin sessions + accounts + +platform identities. +Open details: Postgres version + DSN/`search_path` convention; jet vs +sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque +random length, TTL, revocation); account/identity table shape; whether the +admin bootstrap lands here or in Stage 9. + +### Stage 2 — Engine package +Scope: `backend/internal/engine` over scrabble-solver — versioned DAWG +load/registry, GenerateMoves/ValidatePlay/ScorePlay wrappers, bag/rack, the +**dictionary-independent** game-state model + decode helpers. Add +`replace scrabble-solver => ../scrabble-solver` to `go.work` here and solve the +CI sibling-checkout (clone `gitea.iliadenisov.ru/.../scrabble-solver`). +Open details: how CI obtains the solver (clone sibling vs publish/tag the +solver module); in-memory game-state representation; how blanks and exchanges +are modelled; Эрудит specifics to verify against the solver. + +### Stage 3 — Game domain +Scope: create/join, turn order, submit play/pass/exchange/resign, +validate-at-submit, scoring, end-conditions, 24h timeout/auto-resign, hint, +word-check + complaint capture, structured history + GCG writer, stats on +finish. +Open details: GCG dialect details (blanks, exchanges, notation); exact stats +edge cases; turn-timeout scheduler mechanism (cron vs per-game timer); +complaint payload shape. + +### Stage 4 — Lobby & social +Scope: matchmaking pool, friends, block, per-game chat, profile + email +confirm-code, nudge. +Open details: pool fairness/keying confirmation; deep-link format per platform; +chat length limit + retention; friend-request lifecycle; email-code provider +(SMTP relay choice). + +### Stage 5 — Robot opponent +Scope: human-like player — balance ~0.40, margin targeting, skewed [2,90]min +timing + sleep + nudge logic, friend/DM blocking, name pool. +Open details: exact delay distribution + parameters; margin band; name pool +source; how the scheduler drives robot moves; metrics for tuning balance. + +### Stage 6 — Gateway edge +Scope: Connect/gRPC-Web (h2c), Telegram initData validation → session → +`X-User-ID`, in-memory rate-limit, admin Basic-Auth passthrough, FlatBuffers +transcoding, in-app push stream bridging backend `push` gRPC stream, email + +ephemeral-guest paths. +Open details: FlatBuffers schema layout + message_type catalog; rate-limit +classes/limits; admin surface routing; session cache shape at the gateway. + +### Stage 7 — UI +Scope: plain Svelte + Vite static; Connect-web + FlatBuffers client; lobby (my +games, profile tabs); board (HTML5/CSS grid, drag-n-drop, no assets); chat; +hint/word-check; in-app stream; i18n en/ru; in-memory session (+IndexedDB if +available); Capacitor-ready structure. +Open details: detailed game-board UX (deferred by the owner to this stage); +client routing; offline/refresh behaviour; design system / theming. + +### Stage 8 — Telegram integration +Scope: bot side-service, deep-link invites, platform push (your-turn / nudge), +Mini App launch/auth; backend↔platform internal API. +Open details: bot framework/library; deep-link scheme; push message templates; +internal API contract; Mini App hosting/origin. + +### Stage 9 — Admin & dictionary ops +Scope: admin endpoints (users, games, complaint review queue, dictionary +versions + reload), complaint→dictionary update pipeline. +Open details: whether a server-rendered console is wanted or JSON-only; the +dictionary rebuild/deploy pipeline; complaint resolution workflow. + +### Stage 10 — Account linking & merge +Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends, +dedupe). High blast-radius — focused regression tests. +Open details: conflict resolution (active games on both, duplicate friends, +display-name collisions); irreversibility/audit; confirm-flow per platform. + +### Stage 11 — Polish +Scope: observability dashboards, evidence-based performance work, prod +build/deploy. +Open details: deployment target/host; dashboards; load expectations. + +## Refinements logged during implementation + +- **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet; + adding the path now would break CI, which checks out only this repo). Docker / + compose deferred to a stage that has something to deploy. Trunk is `master` + (owner preference); `feature/*` + PR from Stage 1; the genesis commit lands on + `master` by necessity. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a52e87b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# scrabble-game + +Multiplatform Scrabble game. Players arrive from a platform (Telegram first; +later VK/MAX/iOS/Android) or from standalone web (email / guest). The game +supports English Scrabble, Russian Scrabble and Эрудит. + +## Components + +- **`gateway`** — the only public ingress: anti-abuse, platform authentication + (resolves the player and injects `X-User-ID`), routing to `backend`, and an + admin surface behind Basic Auth. *(added in a later stage)* +- **`backend`** — internal-only service that owns every domain concern and + embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process. +- **`ui`** — pure-HTML5 client (plain Svelte + Vite), embeddable in platform + webviews and packageable to native via Capacitor. *(added in a later stage)* +- **`platform/*`** — per-platform side-services (e.g. the Telegram bot). + *(added in a later stage)* + +## Documentation (sources of truth) + +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — global architecture, transport, + security, cross-service contracts. +- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) — + per-domain user stories. +- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the per-stage CI gate. +- [`PLAN.md`](PLAN.md) — the staged implementation plan and stage tracker. +- [`CLAUDE.md`](CLAUDE.md) — project guide and the mandatory per-stage workflow. + +## Build & test + +```sh +go build ./backend/... # per module (the workspace spans several modules) +go vet ./backend/... +gofmt -l . # must print nothing +go test -count=1 ./backend/... +``` + +## Run the backend locally + +```sh +go run ./backend/cmd/backend # serves /healthz and /readyz on :8080 +``` + +Configuration is read from the environment: `BACKEND_HTTP_ADDR` (default +`:8080`), `BACKEND_LOG_LEVEL` (`debug|info|warn|error`, default `info`). diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go new file mode 100644 index 0000000..45caee6 --- /dev/null +++ b/backend/cmd/backend/main.go @@ -0,0 +1,48 @@ +// Command backend is the Scrabble platform's internal domain service. At this +// stage it boots the HTTP listener with the infrastructure probes only; the +// domain modules described in PLAN.md are added by later stages. +package main + +import ( + "context" + "log" + "os/signal" + "syscall" + + "go.uber.org/zap" + + "scrabble/backend/internal/config" + "scrabble/backend/internal/server" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("backend: load config: %v", err) + } + + logger, err := newLogger(cfg.LogLevel) + if err != nil { + log.Fatalf("backend: build logger: %v", err) + } + defer func() { _ = logger.Sync() }() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + srv := server.New(cfg.HTTPAddr, logger) + if err := srv.Run(ctx); err != nil { + logger.Fatal("backend: server terminated", zap.Error(err)) + } +} + +// newLogger builds a production JSON logger at the given level. +func newLogger(level string) (*zap.Logger, error) { + var lvl zap.AtomicLevel + if err := lvl.UnmarshalText([]byte(level)); err != nil { + return nil, err + } + cfg := zap.NewProductionConfig() + cfg.Level = lvl + return cfg.Build() +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..ae35729 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,41 @@ +module scrabble/backend + +go 1.26.3 + +require ( + github.com/gin-gonic/gin v1.12.0 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..55d4b9a --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,95 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..1c3cb1e --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,57 @@ +// Package config loads and validates the backend's runtime configuration from +// the process environment. +package config + +import ( + "fmt" + "os" +) + +// Config holds the backend's runtime configuration. +type Config struct { + // HTTPAddr is the listen address of the HTTP listener (host:port). + HTTPAddr string + // LogLevel is the zap log level: "debug", "info", "warn" or "error". + LogLevel string +} + +// Defaults applied when the corresponding environment variable is unset. +const ( + defaultHTTPAddr = ":8080" + defaultLogLevel = "info" +) + +// Load reads the configuration from the environment, applies defaults for +// unset variables, and validates the result. +func Load() (Config, error) { + c := Config{ + HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), + LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), + } + if err := c.validate(); err != nil { + return Config{}, err + } + return c, nil +} + +// validate reports whether the configuration values are acceptable. +func (c Config) validate() error { + switch c.LogLevel { + case "debug", "info", "warn", "error": + default: + return fmt.Errorf("config: invalid BACKEND_LOG_LEVEL %q", c.LogLevel) + } + if c.HTTPAddr == "" { + return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty") + } + return nil +} + +// envOr returns the value of the environment variable named key, or fallback +// when the variable is unset or empty. +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..efca0f8 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,46 @@ +package config + +import "testing" + +// TestLoadDefaults verifies that Load applies defaults when the environment is +// empty. +func TestLoadDefaults(t *testing.T) { + t.Setenv("BACKEND_HTTP_ADDR", "") + t.Setenv("BACKEND_LOG_LEVEL", "") + + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.HTTPAddr != defaultHTTPAddr { + t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, defaultHTTPAddr) + } + if c.LogLevel != defaultLogLevel { + t.Errorf("LogLevel = %q, want %q", c.LogLevel, defaultLogLevel) + } +} + +// TestLoadOverrides verifies that environment variables override the defaults. +func TestLoadOverrides(t *testing.T) { + t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:9090") + t.Setenv("BACKEND_LOG_LEVEL", "debug") + + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.HTTPAddr != "127.0.0.1:9090" { + t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, "127.0.0.1:9090") + } + if c.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want %q", c.LogLevel, "debug") + } +} + +// TestLoadRejectsInvalidLevel verifies that an unknown log level is rejected. +func TestLoadRejectsInvalidLevel(t *testing.T) { + t.Setenv("BACKEND_LOG_LEVEL", "verbose") + if _, err := Load(); err == nil { + t.Fatal("Load: expected an error for an invalid log level, got nil") + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..8d10274 --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,73 @@ +// Package server wires the backend's HTTP listener: the gin engine, its route +// groups and the start/stop lifecycle. At this stage it serves only the +// infrastructure probes; the domain route groups described in PLAN.md +// (/api/v1/public, /user, /internal, /admin) are added by later stages. +package server + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// shutdownTimeout bounds how long Run waits for in-flight requests to finish +// during a graceful shutdown. +const shutdownTimeout = 10 * time.Second + +// Server owns the gin engine and the underlying HTTP server. +type Server struct { + log *zap.Logger + http *http.Server +} + +// New returns a Server that will listen on addr. The logger receives lifecycle +// and request diagnostics. +func New(addr string, log *zap.Logger) *Server { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + engine.Use(gin.Recovery()) + registerProbes(engine) + + return &Server{ + log: log, + http: &http.Server{Addr: addr, Handler: engine}, + } +} + +// registerProbes installs the unauthenticated infrastructure probes: /healthz +// reports process liveness and /readyz reports readiness to serve traffic. +// Until later stages add real dependencies (Postgres, warmed caches), +// readiness mirrors liveness. +func registerProbes(engine *gin.Engine) { + ok := func(c *gin.Context) { c.String(http.StatusOK, "ok") } + engine.GET("/healthz", ok) + engine.GET("/readyz", ok) +} + +// Run starts the listener and blocks until ctx is cancelled, then shuts the +// server down gracefully within shutdownTimeout. It returns the first error +// that is not the expected http.ErrServerClosed. +func (s *Server) Run(ctx context.Context) error { + errc := make(chan error, 1) + go func() { + s.log.Info("http listener starting", zap.String("addr", s.http.Addr)) + errc <- s.http.ListenAndServe() + }() + + select { + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + s.log.Info("http listener stopping") + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return s.http.Shutdown(shutdownCtx) + } +} diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go new file mode 100644 index 0000000..df438ec --- /dev/null +++ b/backend/internal/server/server_test.go @@ -0,0 +1,22 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap/zaptest" +) + +// TestProbes verifies that the infrastructure probes answer 200 OK. +func TestProbes(t *testing.T) { + srv := New(":0", zaptest.NewLogger(t)) + for _, path := range []string{"/healthz", "/readyz"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + srv.http.Handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status = %d, want %d", path, rec.Code, http.StatusOK) + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..cb4c6fa --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,226 @@ +# Scrabble Game — Architecture + +Source of truth for the platform architecture, transport, security model and +cross-service contracts. User-visible behaviour per domain lives in +[`FUNCTIONAL.md`](FUNCTIONAL.md); the staged build order lives in +[`../PLAN.md`](../PLAN.md). This document always describes the **current** +design, not the history of how it was reached. Sections describing +not-yet-implemented components are marked *(planned)*. + +## 1. Overview + +Three executables plus per-platform side-services: + +- **`gateway`** *(planned)* — the only public ingress. Performs anti-abuse + (rate limiting), authenticates the player against the originating platform + (or an email/guest session), resolves the internal `user_id`, and forwards + authenticated traffic to `backend` with an `X-User-ID` header. Hosts an admin + surface behind HTTP Basic Auth. Bridges live events from `backend` to the + client. +- **`backend`** — internal-only service that owns every domain concern: + identity/sessions, accounts and linking, lobby and matchmaking, the game + runtime, the robot opponent, chat, notifications, statistics, history, and + administration. Embeds the **`scrabble-solver`** engine **as a library, + in-process** — there is no per-game container. The only network consumer of + `backend` is `gateway` (plus platform side-services over an internal API). +- **`ui`** *(planned)* — pure-HTML5 client (plain Svelte + Vite, static build). + Talks to `backend` only through `gateway`. Embeddable in platform webviews; + packageable to native (iOS/Android) via Capacitor. +- **`platform/`** *(planned)* — per-platform side-services (Telegram bot + first): deep-link invites and platform-native push notifications. They talk + to `backend` over an internal API. + +```mermaid +flowchart LR + Client((Client / webview)) -- Connect-RPC + FlatBuffers (h2c) --> Gateway + Gateway -- REST/JSON, X-User-ID --> Backend + Backend -- gRPC server-stream (live events) --> Gateway + Gateway -- in-app stream --> Client + Backend -- pgx --> Postgres[(Postgres)] + Backend -. embeds .- Solver[[scrabble-solver library]] + Telegram[Telegram bot side-service] -- internal API --> Backend +``` + +The MVP runs `gateway` and `backend` as single-instance processes inside a +trusted network. No Redis is planned (anti-replay crypto was deliberately +dropped). Horizontal scaling is explicit future work. + +## 2. Transport + +- **client ↔ gateway**: **Connect-RPC + FlatBuffers** over HTTP/2 cleartext + (`h2c`). Binary payloads, server-streaming for the in-app live channel, + first-class JS clients (`@connectrpc/connect-web` + the `flatbuffers` npm + package). The contract is kept minimal. +- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects + `X-User-ID` for authenticated requests; `backend` never re-derives identity + from the body. +- **backend → gateway (live)**: a single gRPC server-stream carries live events + (your-turn, opponent-moved, chat, nudge). The gateway bridges them to the + client's in-app stream while the app is open. Out-of-app delivery uses + platform-native push via the platform side-service. + +## 3. Authentication & sessions + +Platform-native, deliberately simple: **no Ed25519 client keys, no per-request +signing, no anti-replay crypto** (these were considered and dropped — players +arrive from a platform rather than completing a mandatory registration). + +- The gateway validates the originating credential **once** — the platform's + signed launch data (e.g. Telegram `initData` HMAC), an email-code login, or a + guest bootstrap — then mints a **thin opaque server session token** + (`session_id`). +- The client holds `session_id` in memory for the app session (browser/OS + storage is optional and may be unavailable; losing it means re-login). +- The gateway caches `session → user_id` and injects `X-User-ID`. Sessions are + revocable. Session records live in `backend`. +- **Guest** = ephemeral web session (no platform, no email): session-only, + nothing persisted; restricted to auto-match, with no friends and no + stats/history. Platform users are auto-provisioned **durable** accounts. + +## 4. Accounts, identities, linking & merge + +- One internal account may carry several **platform identities** + (`telegram`, `vk`, …) plus an optional **email** identity. First contact from + a platform auto-provisions a durable account bound to that platform identity. +- **Linking** is initiated from an authenticated profile: choose a platform → + complete that platform's web-auth confirm → attach the identity to the + current account. +- **Merge**: if the identity being linked already has its own account with + history, the two accounts are **merged into the current one (A is primary)**: + statistics are summed, games and friends are transferred, duplicates are + de-duplicated, the secondary account is retired. High blast-radius; an + isolated, well-tested stage. + +## 5. Game engine integration (`scrabble-solver`) + +`backend` embeds the solver library (see [`CLAUDE.md`](../CLAUDE.md) for the +exact public API and constraints). Key points: + +- Variants at launch: **English Scrabble**, **Russian Scrabble**, **Эрудит** — + `rules.English()`, `rules.RussianScrabble()`, `rules.Erudit()`. +- Dictionaries are committed DAWGs loaded with `dawg.Load`; held in memory and + addressed by `(variant, dict_version)`. +- **Dictionary versioning — pin per game.** A game records the `dict_version` + it started on and finishes on that version; new games use the latest. Multiple + versions may be resident at once. An admin reload endpoint *(planned)* adds a + new version; delivery is the DAWG file in the image / a mounted volume. +- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), + `Solver.ValidatePlay`, `Solver.ScorePlay`; board mutation uses + `scrabble.Apply`. Tile bag follows the `selfplay.Bag` pattern. + +## 6. Game rules + +- **Word legality: validate-at-submit.** An illegal play is rejected by + `Solver.ValidatePlay`; there is no challenge phase. +- **End of game**: the bag is empty **and** a player empties their rack, **or** + **6 consecutive scoreless turns** (passes/exchanges). A move that is not made + within the **24-hour** turn timeout becomes an automatic resignation. +- **Players**: auto-match is always 2 players; friend games are 2–4 players. + `backend` owns turn order and the bag for any player count. +- **Hint**: one per game; reveals the top-1 ranked move (`GenerateMoves[0]`). +- **Word-check tool**: unlimited dictionary lookups; each result offers a + **complaint** that lands in an admin review queue *(admin side planned)*. + +## 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. + +- **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. +- **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. + +## 8. Lobby & social + +- **Matchmaking** *(detail planned)*: a FIFO pool keyed by `(variant, + language)`; 10 s with no human match → substitute the robot. +- **Friends**: add by friend list, internal ID, or platform deep-link. +- **Block** settings independently suppress in-game chat and friend requests. +- **Chat**: per-game, persisted, length-limited, suppressed by the block + setting. +- **Nudge**: a player may nudge the opponent whose turn is awaited once per + hour; the opponent receives a platform-native notification. +- **Profile**: `preferred_language` (en/ru), display name, linked platform + accounts, email (confirm-code binding), **timezone** (drives robot sleep; + default from platform/locale, user-editable), block toggles. + +## 9. Persistence + +- Single Postgres database, schema `backend`; `backend` is the only writer. + pgx pool; queries via go-jet *(introduced when the first real query lands)*; + migrations embedded and applied with `pressly/goose/v3` at startup *(planned)*. +- **Active game state** is stored structurally with the `dict_version` pinned. +- **Statistics** (computed on finish): wins, losses, max points in a game, max + points for a single word. + +### 9.1 History invariant (must hold forever) + +Archived games must replay **independently of any dictionary and of the +solver's internal encoding** — at least visually. Therefore the move log +persists only **decoded concrete values**: letters as text, coordinates, blank +flag, action kind (play / pass / exchange / resign / timeout), acting player, +per-move score and running total, timestamp. The board for visual replay is +reconstructed by applying placements onto an empty grid; no dictionary is +needed because moves were validated at play time and scores are stored. +`variant` and `dict_version` are kept as **metadata only** (audit, complaint +review), never as a replay dependency. **GCG export** is derived from the same +rows and is likewise self-contained (we ship our own writer; the solver exposes +no public GCG writer). + +## 10. Notifications + +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. + +## 11. Observability + +- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry traces and + metrics, with a Prometheus pull endpoint where configured *(introduced with + the first real workload)*. +- Per-request server-side timing via middleware from day one. A client-measured + RTT piggybacked on the next request is a later enhancement, not MVP. +- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness). + +## 12. Security boundaries + +| Concern | Enforced by | +| --- | --- | +| Public rate limiting / anti-abuse | gateway | +| Platform credential validation, session minting | gateway | +| Session → `user_id` resolution, `X-User-ID` injection | gateway | +| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | +| Admin authentication | gateway Basic Auth → backend admin endpoints | +| backend ↔ gateway trust | the network (only gateway may reach backend) | + +This is an explicit, accepted MVP risk: compromise of the gateway↔backend +network segment defeats backend authentication. Mitigated by network isolation; +mutual auth is a future hardening step. + +## 13. Deployment (informational) + +Single public origin, path-routed: the UI, the gateway public surface and the +admin surface share one host that terminates TLS. MVP runs one `gateway`, one +`backend`, one Postgres. Docker/compose environments are introduced when there +is something to deploy. + +## 14. CI & branches + +- Trunk is **`master`**; feature work happens on `feature/*` branches merged via + PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily + lands on `master`). +- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/test on + Go changes; more workflows (ui-test, integration, deploy) are added with the + components they cover. +- After any push, the run is watched to green before a stage is declared done + (`python3 ~/.claude/bin/gitea-ci-watch.py`). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md new file mode 100644 index 0000000..96703e4 --- /dev/null +++ b/docs/FUNCTIONAL.md @@ -0,0 +1,56 @@ +# Scrabble Game — Functional spec + +Per-domain user stories: what each user-visible operation does. This is the +starting point for any change request that touches behaviour. The English +version is authoritative; [`FUNCTIONAL_ru.md`](FUNCTIONAL_ru.md) is a mirror for +the project owner — mirror every point edit in the same patch (translate only +the changed paragraphs). Sections deepen as stages land; *(Stage N)* marks where +the detail is authored. + +## Domains + +### Identity & sessions *(Stage 1 / 6)* +A player arrives from a platform (Telegram first), via email login, or as an +ephemeral guest. The gateway validates the credential once and mints a thin +session token; the backend resolves it to an internal `user_id`. Guests are +session-only with restricted features (auto-match only; no friends, stats or +history). + +### Accounts, linking & merge *(Stage 1 / 10)* +First platform contact auto-provisions a durable account. From the profile a +player links additional platform identities or an email via a confirm flow; +linking an identity that already has history merges it into the current +account (stats summed, games/friends transferred). + +### Lobby & matchmaking *(Stage 4)* +Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins +a `(variant, language)` pool; after 10 s with no human, the robot substitutes. +Friend games (2–4) are formed by friend list, internal ID, or deep-link. + +### Playing a game *(Stage 3)* +Place tiles, pass, exchange, or resign. A play is validated against the +dictionary at submit time and scored. One hint per game reveals the best move. +The dictionary check tool is unlimited and offers a complaint. The game ends +when the bag empties and a player clears their rack, after 6 consecutive +scoreless turns, or by the 24-hour move timeout (auto-resign). + +### 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. + +### Social: friends, block, chat, nudge *(Stage 4)* +Add friends; block chat and/or friend requests independently; per-game chat; +nudge the awaited opponent at most once per hour (platform-native push). + +### Profile & settings *(Stage 4)* +Language (en/ru), display name, linked accounts, email binding, timezone, block +toggles. + +### History & statistics *(Stage 3)* +Finished games are archived in a dictionary-independent form and exportable to +GCG. Statistics: wins, losses, max points in a game, max points for one word. + +### Administration *(Stage 9)* +Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary +versions, and inspects users/games. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md new file mode 100644 index 0000000..2fa81a6 --- /dev/null +++ b/docs/FUNCTIONAL_ru.md @@ -0,0 +1,58 @@ +# Scrabble Game — Функциональная спецификация + +Пользовательские сценарии по доменам: что делает каждая видимая пользователю +операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта; +**авторитетна английская версия**. Любую точечную правку переносим в том же +патче (переводим только изменённые абзацы). Разделы наполняются по мере этапов; +*(Stage N)* помечает, где пишется детализация. + +## Домены + +### Личность и сессии *(Stage 1 / 6)* +Игрок приходит с платформы (сначала Telegram), через email-вход или как +эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий +session-токен; backend сопоставляет его с внутренним `user_id`. Гость — +только сессия, с урезанными функциями (только авто-подбор; без друзей, +статистики и истории). + +### Аккаунты, привязка и слияние *(Stage 1 / 10)* +Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок +привязывает другие платформенные личности или email через confirm-поток; +привязка личности, у которой уже есть история, сливает её в текущий аккаунт +(статистика суммируется, игры/друзья переносятся). + +### Лобби и подбор *(Stage 4)* +Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока) +встаёт в пул по `(вариант, язык)`; через 10 с без человека подставляется +робот. Игры с друзьями (2–4) формируются по списку друзей, внутреннему ID +или deep-link. + +### Игровой процесс *(Stage 3)* +Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю при +сдаче и считается. Одна подсказка на партию показывает лучший ход. Инструмент +проверки слова безлимитный и предлагает пожаловаться. Партия завершается, когда +мешок пуст и игрок выложил стойку, после 6 подряд бесплодных ходов, либо по +таймауту хода в 24 часа (авто-сдача). + +### Робот-соперник *(Stage 5)* +Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на +победу (~40%), целится в небольшой отрыв по очкам, ходит с человеческим +таймингом и ночным сном, делает и принимает nudge как человек. + +### Социальное: друзья, блок, чат, nudge *(Stage 4)* +Добавление в друзья; независимая блокировка чата и/или заявок в друзья; +чат в рамках партии; nudge ожидаемого соперника не чаще раза в час +(платформенное уведомление). + +### Профиль и настройки *(Stage 4)* +Язык (en/ru), отображаемое имя, привязанные аккаунты, привязка email, таймзона, +переключатели блокировок. + +### История и статистика *(Stage 3)* +Завершённые партии архивируются в независимом от словаря виде и экспортируются +в GCG. Статистика: победы, поражения, макс. очков за партию, макс. очков за +слово. + +### Администрирование *(Stage 9)* +Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями +словаря, смотрит пользователей/игры. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..138794a --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,37 @@ +# Scrabble Game — Testing + +How the project is tested and the gate every stage must pass. Read before adding +tests or touching CI. + +## Layers + +- **Go unit tests** — table-driven where it helps; `testing` + standard library. + Every functional change ships with regression coverage. Run: + `go test -count=1 ./backend/...` (the module list grows with the workspace). +- **Integration** *(introduced with Postgres in Stage 1)* — `testcontainers-go` + spins real dependencies (Postgres). Slow; a separate CI workflow. +- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright + (e2e), mirroring the chosen plain-Svelte + Vite toolchain. +- **Engine** — correctness of scoring and move generation is owned by + `scrabble-solver`'s own GCG-backed tests. The backend adds regression tests + for end-conditions, the 24-hour timeout / auto-resign, robot balance and + margin targeting, and **dictionary-independent history replay**. + +## Principles + +- A green run must not depend on cached state: use `-count=1` in CI. +- Tests that need infrastructure fail loudly (`t.Fatal`) when it is unavailable + rather than silently skipping coverage. +- No network or real platform calls in unit tests; validate platform + credentials behind an interface seam and test with fixtures. + +## Per-stage CI gate + +Every completed stage is exercised on `gitea.iliadenisov.ru` before it is marked +done in [`../PLAN.md`](../PLAN.md): + +1. Commit the stage on its `feature/*` branch. +2. Push to `origin`. +3. Watch the run to completion — never hand-roll a poll loop: + `python3 ~/.claude/bin/gitea-ci-watch.py` (launch in the background). +4. Only after every workflow that fired is green may the stage be marked done. diff --git a/go.work b/go.work new file mode 100644 index 0000000..e87beab --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.26.3 + +use ./backend