- 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,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