- 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)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.dawg binary
|
||||||
@@ -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/...
|
||||||
+15
@@ -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
|
||||||
@@ -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/<name>`. 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
|
||||||
|
```
|
||||||
@@ -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/<name>`, 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.
|
||||||
@@ -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`).
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<name>`** *(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`).
|
||||||
@@ -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.
|
||||||
@@ -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) разбирает жалобы на слова, управляет версиями
|
||||||
|
словаря, смотрит пользователей/игры.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user