Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.
Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.
Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.
Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
This commit is contained in:
+76
-30
@@ -111,10 +111,12 @@ Key points:
|
||||
`Erudit()`). Эрудит's specifics (non-doubling centre, `ё` with no tiles, 3
|
||||
blanks, a 15-point bonus) live entirely in the solver ruleset, so the engine
|
||||
treats every variant uniformly.
|
||||
- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from a directory
|
||||
(a parameter today; a configurable `BACKEND_DICT_DIR` is wired when the first
|
||||
consumer needs it). The `engine.Registry` holds them in memory addressed by
|
||||
`(variant, dict_version)`, tracking the latest version per variant.
|
||||
- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from the
|
||||
directory `BACKEND_DICT_DIR`; `backend` loads the `engine.Registry` at startup
|
||||
as a hard dependency (like migrations), so a missing dictionary fails the boot.
|
||||
The registry holds dictionaries in memory addressed by `(variant,
|
||||
dict_version)`, tracking the latest version per variant, and answers the
|
||||
word-check tool through `Registry.Lookup`.
|
||||
- **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 *(planned, Stage 9)*
|
||||
@@ -130,10 +132,18 @@ Key points:
|
||||
- **`engine.Game`** is the in-memory match state and the pure rules engine: it
|
||||
deals racks, applies legal plays / passes / exchanges / resignations, refills
|
||||
from the bag, keeps the scores and whose turn it is, and **detects the end of
|
||||
the game** — empty bag with an empty rack, six consecutive scoreless turns, or
|
||||
a resignation — applying the end-game rack-value adjustment. The 24-hour
|
||||
timeout / auto-resign, turn scheduling and persistence belong to the game
|
||||
domain *(Stage 3)*.
|
||||
the game** — empty bag with an empty rack, or six consecutive scoreless turns,
|
||||
applying the end-game rack-value adjustment, or a resignation. On a
|
||||
**resignation the resigner keeps their accumulated score (no rack adjustment)
|
||||
and never wins**: the win goes to the highest score among the remaining seats,
|
||||
unconditionally the other player in a two-player game. The engine exposes a
|
||||
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
||||
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
||||
- The **game domain** (`internal/game`) owns everything the engine does not —
|
||||
persistence, turn scheduling, the configurable turn timeout / auto-resign, the
|
||||
hint budget, word-check complaints, history and GCG — and is the engine's only
|
||||
consumer. Timeout auto-resign reuses `engine.Resign`, recording the move as a
|
||||
timeout, so it inherits the resignation win/loss.
|
||||
- History is dictionary-independent (§9.1): the engine emits decoded
|
||||
`MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard`
|
||||
(alphabet only, no dictionary).
|
||||
@@ -143,13 +153,29 @@ Key points:
|
||||
- **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.
|
||||
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
|
||||
a missed turn. The **per-game turn timeout** is chosen at creation
|
||||
(5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h); a turn not made within it
|
||||
becomes an automatic resignation, applied by a background sweeper. The sweeper
|
||||
honours each player's **away window** — a daily local-time sleep interval on the
|
||||
account (default 00:00–07:00, midnight-cross aware) — so a player is never
|
||||
timed out while asleep.
|
||||
- **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)*.
|
||||
`backend` owns turn order and the bag for any player count. A resignation or
|
||||
timeout in a two-player game ends it with the other player winning; **richer
|
||||
multi-player drop-out (a leaver's seat skipped while the rest play on, with a
|
||||
per-game disposition of their tiles) is deferred to Stage 4**, when friend games
|
||||
are formed.
|
||||
- **Hint**: governed by two per-game settings — whether hints are allowed and the
|
||||
starting per-player allowance — plus a per-account hint **wallet**
|
||||
(`hint_balance`, spent after the allowance; top-ups are a later feature). A hint
|
||||
reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller
|
||||
picks the per-game defaults (e.g. one in casual random games, none in
|
||||
tournaments).
|
||||
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
|
||||
dictionary; each result offers a **complaint** (complainant, game, variant,
|
||||
dict_version, word, the disputed result, an optional note) that lands in an
|
||||
admin review queue *(admin side planned, Stage 9)*.
|
||||
|
||||
## 7. Robot opponent
|
||||
|
||||
@@ -191,26 +217,46 @@ within 10 seconds. Designed to be indistinguishable from a person.
|
||||
into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
|
||||
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
|
||||
keys are application-generated **UUIDv7**.
|
||||
- Stage 1 tables: `accounts` (durable internal accounts), `identities`
|
||||
(platform/email identities, unique `(kind, external_id)`) and `sessions`
|
||||
(revoke-only opaque-token hashes).
|
||||
- **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.
|
||||
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
|
||||
columns `away_start`/`away_end` and the hint wallet `hint_balance`),
|
||||
`identities` (platform/email identities, unique `(kind, external_id)`),
|
||||
`sessions` (revoke-only opaque-token hashes), and the Stage 3 game tables
|
||||
`games`, `game_players`, `game_moves` (the move journal), `complaints` and
|
||||
`account_stats`.
|
||||
- **Active games are event-sourced.** A game is a `games` row (pinned
|
||||
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
|
||||
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
|
||||
position is an `engine.Game` held in an in-memory cache (≈24 h idle TTL) and
|
||||
rebuilt by replaying the journal on a miss, which the seeded bag makes exact.
|
||||
Each game is serialised by a per-game lock; a persistence failure evicts the
|
||||
live game so the next access rebuilds from the journal. `game_players` records
|
||||
each seat's account, running score, hints used and winner flag.
|
||||
- **Statistics** (`account_stats`, recomputed on each finish, durable accounts
|
||||
only — guests never appear): wins, losses, **draws**, max points in a game, and
|
||||
max points for a single **move** (which already folds in every word the move
|
||||
formed plus the all-tiles bonus). A tie increments draws only; a resignation or
|
||||
timeout is a loss for the acting player.
|
||||
|
||||
### 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).
|
||||
solver's internal encoding** — at least visually. Therefore the move journal
|
||||
persists only **decoded concrete values**: action kind (play / pass / exchange /
|
||||
resign / timeout), acting player, per-move score and running total, timestamp,
|
||||
and — in a per-move JSON payload — the acting player's rack before the move (with
|
||||
`?` for a blank), and for a play its direction, main-word anchor, placed tiles
|
||||
(letter as text, coordinate, blank flag) and the words formed; for an exchange,
|
||||
the swapped tiles. This is exactly what is needed both to **replay the game
|
||||
through the engine** (a cache miss) and to render history or emit GCG **without a
|
||||
dictionary**: the board for visual replay is reconstructed by applying placements
|
||||
onto an empty grid, since 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 none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
||||
does not cover.
|
||||
|
||||
## 10. Notifications
|
||||
|
||||
|
||||
+15
-6
@@ -28,11 +28,18 @@ 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).
|
||||
Place tiles, pass, exchange, or resign. A play is validated against the game's
|
||||
dictionary at submit time and scored; an unlimited preview reports what a
|
||||
tentative move would score and whether it is legal. The dictionary check tool is
|
||||
unlimited and offers a complaint on any result. Hints are governed per game —
|
||||
whether they are allowed and how many each player starts with — and draw on a
|
||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||
daily away window. A resignation or timeout gives the win to the other player and
|
||||
the leaver keeps their score (two-player games; multi-player drop-out-and-continue
|
||||
arrives with the lobby in Stage 4).
|
||||
|
||||
### Robot opponent *(Stage 5)*
|
||||
Indistinguishable-from-human substitute in auto-match. Decides once whether to
|
||||
@@ -49,7 +56,9 @@ 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.
|
||||
GCG. Statistics (durable accounts only): wins, losses, draws, max points in a
|
||||
game, and max points for a single move (the best play, which already includes
|
||||
every word it formed plus the all-tiles bonus).
|
||||
|
||||
### Administration *(Stage 9)*
|
||||
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
|
||||
|
||||
+15
-7
@@ -28,11 +28,18 @@ session-токен; backend сопоставляет его с внутренн
|
||||
или deep-link.
|
||||
|
||||
### Игровой процесс *(Stage 3)*
|
||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю при
|
||||
сдаче и считается. Одна подсказка на партию показывает лучший ход. Инструмент
|
||||
проверки слова безлимитный и предлагает пожаловаться. Партия завершается, когда
|
||||
мешок пуст и игрок выложил стойку, после 6 подряд бесплодных ходов, либо по
|
||||
таймауту хода в 24 часа (авто-сдача).
|
||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
|
||||
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||
суточного окна отсутствия (away). Сдача или таймаут отдают победу другому игроку,
|
||||
а вышедший сохраняет свои очки (партии на двоих; выход одного с продолжением для
|
||||
остальных появится вместе с лобби в Stage 4).
|
||||
|
||||
### Робот-соперник *(Stage 5)*
|
||||
Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на
|
||||
@@ -50,8 +57,9 @@ session-токен; backend сопоставляет его с внутренн
|
||||
|
||||
### История и статистика *(Stage 3)*
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG. Статистика: победы, поражения, макс. очков за партию, макс. очков за
|
||||
слово.
|
||||
в GCG. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи,
|
||||
макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все
|
||||
образованные им слова и бонус за все фишки).
|
||||
|
||||
### Администрирование *(Stage 9)*
|
||||
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
|
||||
|
||||
+12
-4
@@ -22,10 +22,18 @@ tests or touching CI.
|
||||
and exchange accounting, the `Game` end-conditions (empty bag with an empty
|
||||
rack, and six scoreless turns) with end-game rack scoring, and
|
||||
**dictionary-independent history replay** (`ReplayBoard` reproduces a full
|
||||
greedy game's final board from decoded records alone). The engine tests read
|
||||
the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver` checkout)
|
||||
and fail loudly when it is absent. The 24-hour timeout / auto-resign and robot
|
||||
balance/margin regression tests arrive with those stages.
|
||||
greedy game's final board from decoded records alone), and the **resignation
|
||||
win/loss rule** (the resigner keeps their score yet loses). The engine tests
|
||||
read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver`
|
||||
checkout) and fail loudly when it is absent.
|
||||
- **Game domain** *(Stage 3+)* — `backend/internal/game` adds pure unit tests
|
||||
(the GCG writer, the away-window / effective-deadline boundaries, the hint
|
||||
budget, the live-game cache and per-game lock, payload round-trips) plus
|
||||
Postgres-backed integration tests in `inttest` (full lifecycle to a natural
|
||||
end, **journal-replay equivalence**, the turn-timeout sweep with away-window
|
||||
grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
|
||||
word-check and complaint capture, and per-game-lock serialisation). The robot
|
||||
balance/margin regression tests arrive with Stage 5.
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
Reference in New Issue
Block a user