Merge pull request 'Stage 3: game domain (lifecycle, rules, hint, word-check, history+GCG, stats)' (#3) from feature/stage-3-game-domain into master
Tests · Go / test (push) Successful in 5s
Tests · Integration / integration (push) Successful in 7s

This commit was merged in pull request #3.
This commit is contained in:
2026-06-02 15:40:16 +00:00
45 changed files with 4220 additions and 103 deletions
+53 -1
View File
@@ -36,7 +36,7 @@ independent (see ARCHITECTURE §9.1).
| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | **done** |
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | **done** |
| 2 | Engine package over scrabble-solver | **done** |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | todo |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** |
| 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 |
@@ -208,6 +208,58 @@ Open details: deployment target/host; dashboards; load expectations.
unchanged. Stage 2 adds no user-visible behaviour; the variant, per-game
dictionary and dictionary-independent-history user stories already live in
Stages 34, so a "light touch" here would have duplicated or pre-empted them.
- **Stage 3** (interview + implementation):
- Scope, as in Stages 12: **domain service/store layer + engine wiring, no
HTTP** (`internal/game`). The gateway↔backend REST surface lands in Stage 6;
the only active driver this stage is a background turn-timeout sweeper started
from `main`. The robot (Stage 5) will consume the same service API.
- **Persistence = event-sourcing + warm cache** (interview): durable state is
the `games` row plus an append-only decoded move journal (`game_moves`); the
live position is an `engine.Game` kept in an in-memory cache with a ~24h idle
TTL and rebuilt by replaying the journal on a miss (the seeded bag makes
replay exact). Each game is serialised by a per-game mutex; a persistence
failure evicts the live game so the next access rebuilds. §9 reworded from
"stored structurally" to this model.
- **Resign/timeout split** (interview): 2-player resign/timeout only this stage
(the other player wins); multiplayer drop-out-and-continue + resigned-tiles
disposition deferred to Stage 4. Per-game **turn-timeout duration** setting
(5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h) and a per-user **away window**
(`accounts.away_start/away_end`, default 00:0007:00 local, honoured by the
sweeper with midnight-cross handling) added now; profile editing of the away
window is Stage 4 and the robot's sleep (Stage 5) reuses it.
- **Engine `Resign` fix** (interview, in `internal/engine`): the resigner keeps
their accumulated score (no end-game rack adjustment) and never wins; `winner`
excludes the resigner, so a two-player resign/timeout gives the win to the
other player regardless of score. Timeout reuses `Resign`, so the game domain
needs no winner override.
- **Additive engine domain API**: `Direction`, `Game.SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand`, `MoveRecord.{Dir,MainRow,MainCol}`,
`Registry.Lookup`, `ParseVariant` — so `internal/game` never imports
`scrabble-solver` (keeps the §5 single-importer invariant).
- **Create = atomic with seats** (interview): `Create` seats all accounts and
starts; lobby seat-filling is Stage 4. **Sweeper = periodic goroutine**
(interview; default 60 s, `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL`).
- **Hint = settings + wallet** (interview): per-game `hints_allowed` +
`hints_per_player`, plus a profile wallet `accounts.hint_balance` (spent after
the allowance; purchases later). Category defaults (random 1 / tournament 0 /
friendly 1-or-0) are the caller's job (lobby/tournaments).
- **Stats** (interview): `account_stats` with **`draws`** added beyond §9's
wins/losses; `max_word_points` = best single **move** score; ties draw,
resign/timeout is a loss, guests get no stats.
- **Complaint** (interview): full payload with `game_id`; word-check is scoped
to the game's pinned `(variant, dict_version)`. Stage 9 owns the resolution
lifecycle, so the `status` column carries no value CHECK yet.
- **GCG** (interview): standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchange) plus `#note` lines for resign/timeout; derived from the journal, so
dictionary-independent.
- **Engine wiring + config**: `main` loads the registry (`engine.Open`, a hard
boot dependency like migrations) and starts the sweeper. New config:
`BACKEND_DICT_DIR` (required), `BACKEND_DICT_VERSION` (default `v1`),
`BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` (60 s), `BACKEND_GAME_CACHE_TTL` (24 h).
No CI change — both Go workflows already clone the solver sibling and export
`BACKEND_DICT_DIR`. `accounts` gained `away_start`/`away_end`/`hint_balance`
and the `account` package gained `SpendHint` (it owns its table).
## Deferred TODOs (cross-stage)
+30 -10
View File
@@ -16,14 +16,25 @@ Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver`
library: a versioned dictionary registry, a deterministic tile bag, and a pure
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
detection) that emits dictionary-independent move records. It is a library only;
the game domain wires it into the server in Stage 3.
the game domain wires it into the process in Stage 3.
Stage 3 adds `internal/game`, the game domain over the engine. Active games are
event-sourced: a `games` row plus an append-only decoded move journal, with the
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
provides create, the play/pass/exchange/resign transitions, an unlimited
score/legality preview, the hint (per-game allowance plus a profile wallet), the
word-check tool with complaint capture, per-player game state, history and GCG
export, per-account statistics on finish, and a background turn-timeout sweeper
that auto-resigns overdue turns (honouring each player's daily away window). Like
Stages 12 it is a service/store layer; the HTTP surface lands with the
`gateway` (Stage 6).
## Package layout
```
cmd/backend/ # process entrypoint: telemetry -> db+migrate -> cache -> server
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> server
cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container
internal/config/ # env configuration (composes postgres + telemetry config)
internal/config/ # env configuration (composes postgres + telemetry + game config)
internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
migrations/ # embedded *.sql (goose), schema `backend`
@@ -32,6 +43,7 @@ internal/account/ # durable accounts + platform/email identities (store)
internal/session/ # opaque tokens, sessions store, write-through cache, service
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
```
## Configuration (environment)
@@ -48,18 +60,26 @@ internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, r
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none` or `stdout` (OTLP arrives later). |
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none` or `stdout`. |
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
## Run
```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=../../scrabble-solver/dawg \
go run ./cmd/backend
```
On boot the backend opens the pool, creates the `backend` schema if needed, and
applies the embedded migrations. `GET /healthz` reports liveness; `GET /readyz`
reports 200 only when the database answers and the session cache is warmed.
On boot the backend opens the pool, creates the `backend` schema if needed,
applies the embedded migrations, loads the dictionaries into the engine registry
(a hard dependency — a missing dictionary aborts the boot), warms the session
cache and starts the game turn-timeout sweeper. `GET /healthz` reports liveness;
`GET /readyz` reports 200 only when the database answers and the session cache is
warmed.
## Migrations & generated code
@@ -82,10 +102,10 @@ dependency. CI clones the public solver repository into `../scrabble-solver`
before building (see `.gitea/workflows/`); locally, check it out next to this
repository. Committed dictionaries (`en_sowpods.dawg`, `ru_scrabble.dawg`,
`ru_erudit.dawg`) live in the solver's `dawg/` directory; the engine loads them
by `(variant, dict_version)` from a directory path. A configurable
`BACKEND_DICT_DIR` is wired when the first consumer needs it (Stage 3); the
future versioned-artifact direction is recorded in [`../PLAN.md`](../PLAN.md)
TODO-2.
by `(variant, dict_version)` from a directory path. Since Stage 3 the backend
loads them at startup from the **required** `BACKEND_DICT_DIR` (a missing
dictionary aborts the boot); the future versioned-artifact direction is recorded
in [`../PLAN.md`](../PLAN.md) TODO-2.
## Tests
+24 -4
View File
@@ -1,8 +1,10 @@
// Command backend is the Scrabble platform's internal domain service. It boots
// the OpenTelemetry runtime, opens the Postgres pool and applies migrations,
// warms the session cache, and serves the HTTP listener with the infrastructure
// probes and the /api/v1 route-group skeleton. Domain endpoints are added by
// later stages described in PLAN.md.
// loads the dictionaries into the engine registry, warms the session cache,
// constructs the game domain and starts its turn-timeout sweeper, then serves the
// HTTP listener with the infrastructure probes and the /api/v1 route-group
// skeleton. Domain HTTP endpoints are added with the gateway in a later stage
// described in PLAN.md.
package main
import (
@@ -15,7 +17,10 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/config"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
@@ -46,7 +51,8 @@ func main() {
}
// run wires the process dependencies in order — telemetry, database (with
// migrations), session cache, HTTP server — and blocks until ctx is cancelled.
// migrations), engine dictionaries, session cache, game domain (with its
// turn-timeout sweeper), HTTP server — and blocks until ctx is cancelled.
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
tel, err := telemetry.New(ctx, cfg.Telemetry)
if err != nil {
@@ -74,12 +80,26 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("database migrations applied")
registry, err := engine.Open(cfg.Game.DictDir, cfg.Game.DictVersion)
if err != nil {
return fmt.Errorf("load dictionaries: %w", err)
}
defer func() { _ = registry.Close() }()
logger.Info("dictionaries loaded",
zap.String("dir", cfg.Game.DictDir),
zap.String("version", cfg.Game.DictVersion))
sessions := session.NewService(session.NewStore(db), session.NewCache())
if err := sessions.Warm(ctx); err != nil {
return fmt.Errorf("warm session cache: %w", err)
}
logger.Info("session cache warmed")
games := game.NewService(game.NewStore(db), account.NewStore(db), registry, cfg.Game, logger)
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
srv := server.New(cfg.HTTPAddr, server.Deps{
Logger: logger,
DB: db,
+33 -1
View File
@@ -33,12 +33,19 @@ const uniqueViolation = "23505"
// ErrNotFound is returned when no account matches the lookup.
var ErrNotFound = errors.New("account: not found")
// Account is a durable internal account.
// Account is a durable internal account. AwayStart and AwayEnd bound the daily
// local-time window (in TimeZone) during which the player is asleep: the
// turn-timeout sweeper does not auto-resign them inside it, and the robot reuses
// it for its own sleep in a later stage. HintBalance is the player's wallet of
// purchasable hints, spent after a game's per-seat allowance.
type Account struct {
ID uuid.UUID
DisplayName string
PreferredLanguage string
TimeZone string
AwayStart time.Time
AwayEnd time.Time
HintBalance int
BlockChat bool
BlockFriendRequests bool
CreatedAt time.Time
@@ -165,6 +172,28 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e
return created, nil
}
// SpendHint atomically decrements the account's hint wallet by one, returning
// true when a hint was spent and false when the balance was already empty. The
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(id)).
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
}
return n > 0, nil
}
// modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account {
return Account{
@@ -172,6 +201,9 @@ func modelToAccount(row model.Accounts) Account {
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests,
CreatedAt: row.CreatedAt,
+17
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"time"
"scrabble/backend/internal/game"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/telemetry"
)
@@ -22,6 +23,8 @@ type Config struct {
Postgres postgres.Config
// Telemetry configures the OpenTelemetry providers.
Telemetry telemetry.Config
// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
Game game.Config
}
// Defaults applied when the corresponding environment variable is unset.
@@ -54,11 +57,22 @@ func Load() (Config, error) {
tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
return Config{}, err
}
if gm.CacheTTL, err = envDuration("BACKEND_GAME_CACHE_TTL", gm.CacheTTL); err != nil {
return Config{}, err
}
c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
Postgres: pg,
Telemetry: tel,
Game: gm,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -82,6 +96,9 @@ func (c Config) validate() error {
if err := c.Telemetry.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if err := c.Game.Validate(); err != nil {
return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err)
}
return nil
}
+40
View File
@@ -17,6 +17,10 @@ func TestLoadDefaults(t *testing.T) {
t.Setenv("BACKEND_HTTP_ADDR", "")
t.Setenv("BACKEND_LOG_LEVEL", "")
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_DICT_DIR", "/dict")
t.Setenv("BACKEND_DICT_VERSION", "")
t.Setenv("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", "")
t.Setenv("BACKEND_GAME_CACHE_TTL", "")
c, err := Load()
if err != nil {
@@ -40,6 +44,18 @@ func TestLoadDefaults(t *testing.T) {
if c.Telemetry.TracesExporter != telemetry.ExporterNone {
t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterNone)
}
if c.Game.DictDir != "/dict" {
t.Errorf("Game.DictDir = %q, want /dict", c.Game.DictDir)
}
if c.Game.DictVersion != "v1" {
t.Errorf("Game.DictVersion = %q, want v1", c.Game.DictVersion)
}
if c.Game.TimeoutSweepInterval != time.Minute {
t.Errorf("Game.TimeoutSweepInterval = %s, want 1m", c.Game.TimeoutSweepInterval)
}
if c.Game.CacheTTL != 24*time.Hour {
t.Errorf("Game.CacheTTL = %s, want 24h", c.Game.CacheTTL)
}
}
// TestLoadOverrides verifies that environment variables override the defaults.
@@ -51,6 +67,10 @@ func TestLoadOverrides(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "3s")
t.Setenv("BACKEND_SERVICE_NAME", "scrabble-test")
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "stdout")
t.Setenv("BACKEND_DICT_DIR", "/srv/dict")
t.Setenv("BACKEND_DICT_VERSION", "2026-06")
t.Setenv("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", "30s")
t.Setenv("BACKEND_GAME_CACHE_TTL", "1h")
c, err := Load()
if err != nil {
@@ -59,6 +79,15 @@ func TestLoadOverrides(t *testing.T) {
if c.HTTPAddr != "127.0.0.1:9090" {
t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, "127.0.0.1:9090")
}
if c.Game.DictDir != "/srv/dict" || c.Game.DictVersion != "2026-06" {
t.Errorf("Game dict = %q/%q, want /srv/dict/2026-06", c.Game.DictDir, c.Game.DictVersion)
}
if c.Game.TimeoutSweepInterval != 30*time.Second {
t.Errorf("Game.TimeoutSweepInterval = %s, want 30s", c.Game.TimeoutSweepInterval)
}
if c.Game.CacheTTL != time.Hour {
t.Errorf("Game.CacheTTL = %s, want 1h", c.Game.CacheTTL)
}
if c.LogLevel != "debug" {
t.Errorf("LogLevel = %q", c.LogLevel)
}
@@ -93,6 +122,17 @@ func TestLoadRejectsInvalidLevel(t *testing.T) {
}
}
// TestLoadRejectsMissingDictDir verifies that an unset dictionary directory fails
// validation (the game subsystem cannot load without it).
func TestLoadRejectsMissingDictDir(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_LOG_LEVEL", "info")
t.Setenv("BACKEND_DICT_DIR", "")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a missing dictionary dir, got nil")
}
}
// TestLoadRejectsMalformedInt verifies that a non-numeric pool size is rejected.
func TestLoadRejectsMalformedInt(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
+29 -15
View File
@@ -58,18 +58,31 @@ type TileRecord struct {
// carries only the action; an exchange carries the number of tiles swapped. The
// game domain adds timestamps and persistence around these values.
type MoveRecord struct {
Player int
Action ActionKind
Tiles []TileRecord // ActionPlay only
Words []string // ActionPlay only: the main word first, then cross words
Count int // ActionExchange only: number of tiles swapped
Score int // points scored this turn (0 for non-plays)
Total int // the player's running total after this turn
Player int
Action ActionKind
Dir Direction // ActionPlay only: orientation of the main word (H/V)
MainRow, MainCol int // ActionPlay only: the main word's first-letter coordinate
Tiles []TileRecord // ActionPlay only
Words []string // ActionPlay only: the main word first, then cross words
Count int // ActionExchange only: number of tiles swapped
Score int // points scored this turn (0 for non-plays)
Total int // the player's running total after this turn
}
// recordPlay decodes a scored move into a dictionary-independent MoveRecord for
// the given player.
// recordPlay decodes a scored, committed move into a dictionary-independent
// MoveRecord for the given player, stamping the player and their running total.
func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord {
rec := g.decodeMove(m)
rec.Player = player
rec.Total = g.scores[player]
return rec
}
// decodeMove decodes a scored move's placements and words into a
// dictionary-independent MoveRecord, without the player or running total (which
// only a committed play has). It backs both recordPlay and the non-committing
// previews HintView and EvaluatePlay.
func (g *Game) decodeMove(m scrabble.Move) MoveRecord {
tiles := make([]TileRecord, len(m.Tiles))
for i, p := range m.Tiles {
tiles[i] = TileRecord{Row: p.Row, Col: p.Col, Letter: g.letter(p.Letter), Blank: p.Blank}
@@ -80,12 +93,13 @@ func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord {
words = append(words, g.word(cw))
}
return MoveRecord{
Player: player,
Action: ActionPlay,
Tiles: tiles,
Words: words,
Score: m.Score,
Total: g.scores[player],
Action: ActionPlay,
Dir: fromScrabbleDir(m.Dir),
MainRow: m.Main.Row,
MainCol: m.Main.Col,
Tiles: tiles,
Words: words,
Score: m.Score,
}
}
+157
View File
@@ -0,0 +1,157 @@
package engine
import (
"fmt"
"scrabble-solver/scrabble"
)
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
// in a rack it is an undesignated blank, and in an exchange it is a blank being
// swapped. A blank placed on the board is a TileRecord with Blank set and Letter
// holding the concrete letter it stands for.
const blankLetter = "?"
// Direction is the orientation of a play as seen by the game domain. It decouples
// the domain from the solver's own Direction type: internal/engine is the only
// backend package that imports scrabble-solver (see docs/ARCHITECTURE.md §5), so
// the engine accepts and returns decoded, solver-free values.
type Direction uint8
const (
// Horizontal lays a word left to right along a row.
Horizontal Direction = iota
// Vertical lays a word top to bottom down a column.
Vertical
)
// String renders the direction as "H" or "V", the form the move journal and GCG
// export use.
func (d Direction) String() string {
if d == Vertical {
return "V"
}
return "H"
}
// scrabbleDir maps the domain Direction to the solver's Direction.
func (d Direction) scrabbleDir() scrabble.Direction {
if d == Vertical {
return scrabble.Vertical
}
return scrabble.Horizontal
}
// fromScrabbleDir maps the solver's Direction to the domain Direction.
func fromScrabbleDir(d scrabble.Direction) Direction {
if d == scrabble.Vertical {
return Vertical
}
return Horizontal
}
// SubmitPlay validates and applies the current player's play described in decoded
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
// when Blank is set) and a board coordinate. It encodes the tiles through the
// ruleset alphabet and delegates to Play, so it returns the same errors
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
// letter is outside the variant's alphabet.
func (g *Game) SubmitPlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
placements, err := g.placements(tiles)
if err != nil {
return MoveRecord{}, err
}
return g.Play(dir.scrabbleDir(), placements)
}
// SubmitExchange swaps the current player's tiles, named in decoded terms: a
// concrete letter per tile, or "?" for a blank. It encodes them and delegates to
// Exchange, returning the same errors plus ErrTilesNotOnRack when a letter is
// outside the variant's alphabet.
func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
raw, err := g.encodeTiles(tiles)
if err != nil {
return MoveRecord{}, err
}
return g.Exchange(raw)
}
// EvaluatePlay scores and validates a tentative play without committing it,
// backing the unlimited "what would my next move score, and is it legal?" tool.
// It returns the decoded move (placed tiles, the words it forms and its score)
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
// are left untouched.
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
placements, err := g.placements(tiles)
if err != nil {
return MoveRecord{}, err
}
move, err := g.solver.ValidatePlay(g.board, dir.scrabbleDir(), placements)
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
return g.decodeMove(move), nil
}
// HintView returns the highest-scoring legal play for the current player as a
// decoded MoveRecord and true, or a zero record and false when there is none. It
// is the one-per-game hint's top-1 move in domain-facing form.
func (g *Game) HintView() (MoveRecord, bool) {
move, ok := g.Hint()
if !ok {
return MoveRecord{}, false
}
return g.decodeMove(move), true
}
// Hand returns the player's current rack decoded to concrete letters, with "?"
// for each undesignated blank. The order mirrors the internal hand. It supplies
// the GCG rack field and the per-player game-state view.
func (g *Game) Hand(player int) []string {
hand := g.hands[player]
out := make([]string, len(hand))
for i, t := range hand {
if t == blankTile {
out[i] = blankLetter
continue
}
out[i] = g.letter(t)
}
return out
}
// placements encodes decoded tiles into solver placements via the ruleset
// alphabet, wrapping a bad letter as ErrIllegalPlay.
func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
out := make([]scrabble.Placement, len(tiles))
for i, t := range tiles {
idx, err := g.rules.Alphabet.Index(t.Letter)
if err != nil {
return nil, fmt.Errorf("%w: letter %q at (%d,%d): %v", ErrIllegalPlay, t.Letter, t.Row, t.Col, err)
}
out[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
}
return out, nil
}
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
// concrete letter) into the internal byte form, wrapping a bad letter as
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
func (g *Game) encodeTiles(tiles []string) ([]byte, error) {
raw := make([]byte, len(tiles))
for i, t := range tiles {
if t == blankLetter {
raw[i] = blankTile
continue
}
idx, err := g.rules.Alphabet.Index(t)
if err != nil {
return nil, fmt.Errorf("%w: tile %q: %v", ErrTilesNotOnRack, t, err)
}
raw[i] = idx
}
return raw, nil
}
+180
View File
@@ -0,0 +1,180 @@
package engine
import (
"errors"
"slices"
"testing"
)
// TestDirectionString covers the H/V rendering used by the journal and GCG.
func TestDirectionString(t *testing.T) {
if Horizontal.String() != "H" {
t.Errorf("Horizontal = %q, want H", Horizontal.String())
}
if Vertical.String() != "V" {
t.Errorf("Vertical = %q, want V", Vertical.String())
}
}
// TestSubmitPlayMatchesHint plays the decoded top-1 move through SubmitPlay and
// checks it scores and advances exactly like the underlying solver move, proving
// the decode→encode round trip.
func TestSubmitPlayMatchesHint(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if rec.Score != hint.Score {
t.Errorf("played score = %d, want hint score %d", rec.Score, hint.Score)
}
if rec.Action != ActionPlay {
t.Errorf("action = %v, want play", rec.Action)
}
if g.Score(0) != hint.Score {
t.Errorf("player 0 score = %d, want %d", g.Score(0), hint.Score)
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 after a play", g.ToMove())
}
}
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
// leaves the board, scores, turn and bag untouched.
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
boardBefore := g.BoardClone()
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("evaluate play: %v", err)
}
if rec.Score != hint.Score {
t.Errorf("evaluated score = %d, want %d", rec.Score, hint.Score)
}
if !boardsEqual(boardBefore, g.BoardClone()) {
t.Error("evaluate must not mutate the board")
}
if g.Score(0) != scoreBefore || g.ToMove() != toMoveBefore || g.BagLen() != bagBefore {
t.Errorf("evaluate mutated state: score %d->%d, toMove %d->%d, bag %d->%d",
scoreBefore, g.Score(0), toMoveBefore, g.ToMove(), bagBefore, g.BagLen())
}
}
// TestEvaluatePlayRejectsIllegal reports ErrIllegalPlay for a play the solver
// rejects (a single off-centre opening tile) without committing.
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
g := newEnglishGame(t, 1)
letter := g.Hand(0)[0]
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
if !errors.Is(err, ErrIllegalPlay) {
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
}
}
// TestSubmitExchangeWithBlank exchanges a full rack that includes a blank,
// exercising the "?" encoding path, and checks the turn advances.
func TestSubmitExchangeWithBlank(t *testing.T) {
g := gameWithBlankInHand(t)
hand := g.Hand(0)
if !slices.Contains(hand, blankLetter) {
t.Fatalf("hand %v has no blank", hand)
}
rec, err := g.SubmitExchange(hand)
if err != nil {
t.Fatalf("submit exchange: %v", err)
}
if rec.Action != ActionExchange || rec.Count != len(hand) {
t.Errorf("exchange record = %+v, want action exchange count %d", rec, len(hand))
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 after an exchange", g.ToMove())
}
}
// TestHandDecodesBlank checks Hand returns concrete letters and "?" for a blank,
// agreeing with the internal hand.
func TestHandDecodesBlank(t *testing.T) {
g := gameWithBlankInHand(t)
hand := g.Hand(0)
if len(hand) != g.rules.RackSize {
t.Fatalf("hand size = %d, want %d", len(hand), g.rules.RackSize)
}
var blanks int
for _, s := range hand {
if s == "" {
t.Errorf("hand %v has an empty letter", hand)
}
if s == blankLetter {
blanks++
}
}
var want int
for _, t := range g.hands[0] {
if t == blankTile {
want++
}
}
if blanks != want {
t.Errorf("decoded blanks = %d, want %d", blanks, want)
}
}
// TestRegistryLookup covers word-check membership and its error taxonomy.
func TestRegistryLookup(t *testing.T) {
cases := []struct {
name string
variant Variant
word string
want bool
}{
{"english hit", VariantEnglish, "cat", true},
{"english miss", VariantEnglish, "zzzz", false},
{"russian hit", VariantRussianScrabble, "кот", true},
{"erudit hit", VariantErudit, "кот", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := testReg.Lookup(tc.variant, testVersion, tc.word)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got != tc.want {
t.Errorf("lookup %q = %v, want %v", tc.word, got, tc.want)
}
})
}
if _, err := testReg.Lookup(VariantEnglish, "missing", "cat"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("unknown version = %v, want ErrUnknownVersion", err)
}
if _, err := NewRegistry().Lookup(VariantEnglish, testVersion, "cat"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("empty registry = %v, want ErrUnknownVariant", err)
}
if _, err := testReg.Lookup(VariantEnglish, testVersion, "кот"); err == nil {
t.Error("out-of-alphabet lookup must error")
}
}
// gameWithBlankInHand returns a two-player English game whose player 0 holds at
// least one blank, searching a deterministic range of seeds.
func gameWithBlankInHand(t *testing.T) *Game {
t.Helper()
for seed := int64(1); seed <= 200; seed++ {
g := newEnglishGame(t, seed)
if slices.Contains(g.Hand(0), blankLetter) {
return g
}
}
t.Fatal("no opening rack with a blank found in seeds 1..200")
return nil
}
+12
View File
@@ -66,6 +66,18 @@ func Variants() []Variant {
return []Variant{VariantEnglish, VariantRussianScrabble, VariantErudit}
}
// ParseVariant maps a stable label produced by Variant.String back to its
// Variant, or returns ErrUnknownVariant. It is the inverse the game domain uses
// to read a persisted variant.
func ParseVariant(s string) (Variant, error) {
for _, v := range Variants() {
if v.String() == s {
return v, nil
}
}
return 0, fmt.Errorf("%w: %q", ErrUnknownVariant, s)
}
// Ruleset returns the scrabble-solver ruleset for variant. It needs no
// dictionary, so it supports dictionary-independent board replay (see
// ReplayBoard) from a finished game's variant metadata alone.
+37 -22
View File
@@ -72,6 +72,7 @@ type Game struct {
scorelessRun int
over bool
reason EndReason
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
log []MoveRecord
}
@@ -98,14 +99,15 @@ func New(reg *Registry, opts Options) (*Game, error) {
rs := solver.Rules()
g := &Game{
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resignedSeat: -1,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -193,14 +195,19 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
return rec, nil
}
// Resign ends the game on the current player's turn (EndReason EndResign). In a
// two-player match this is the only resignation case; richer multi-player
// handling belongs to the game domain in a later stage.
// Resign ends the game on the current player's turn (EndReason EndResign). The
// resigner always forfeits the win and keeps their accumulated score (it is
// neither zeroed nor docked a rack adjustment); the win goes to the highest
// score among the remaining seats — in a two-player match, unconditionally to
// the other player. A missed-turn timeout reuses Resign in the game domain, so
// it inherits this win/loss. Richer multi-player drop-out handling belongs to
// the game domain in a later stage.
func (g *Game) Resign() (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resignedSeat = player
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
g.log = append(g.log, rec)
g.finish(EndResign)
@@ -288,10 +295,13 @@ func (g *Game) finish(reason EndReason) {
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
// empty, rack empty) they gain the sum of every opponent's rack value and each
// opponent loses their own; otherwise (scoreless stalemate or resignation) each
// player simply forfeits their own rack value.
// opponent loses their own. A scoreless stalemate forfeits each player's own
// rack value. A resignation freezes the scores: the win is decided by winner
// (which excludes the resigner), so no rack adjustment is applied and the
// resigner keeps their accumulated score.
func (g *Game) applyEndAdjustment(reason EndReason) {
if reason == EndOutOfTiles {
switch reason {
case EndOutOfTiles:
out := g.toMove
var bonus int
for i := range g.hands {
@@ -303,10 +313,10 @@ func (g *Game) applyEndAdjustment(reason EndReason) {
bonus += v
}
g.scores[out] += bonus
return
}
for i := range g.hands {
g.scores[i] -= g.rackValue(i)
case EndScoreless:
for i := range g.hands {
g.scores[i] -= g.rackValue(i)
}
}
}
@@ -324,15 +334,20 @@ func (g *Game) endTurnAfterScoreless() {
func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) }
// winner returns the index of the single highest-scoring player, or -1 on a tie
// for the lead or while the game is unfinished.
// for the lead or while the game is unfinished. After a resignation the resigner
// is excluded, so a two-player game returns the remaining player even when the
// resigner led on score.
func (g *Game) winner() int {
if !g.over {
return -1
}
best, tie := 0, false
for i := 1; i < len(g.scores); i++ {
best, tie := -1, false
for i := range g.scores {
if g.reason == EndResign && i == g.resignedSeat {
continue
}
switch {
case g.scores[i] > g.scores[best]:
case best == -1 || g.scores[i] > g.scores[best]:
best, tie = i, false
case g.scores[i] == g.scores[best]:
tie = true
+23
View File
@@ -135,6 +135,29 @@ func (r *Registry) Versions(v Variant) []string {
return versions
}
// Lookup reports whether word is present in the (variant, version) dictionary,
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
// ErrUnknownVersion when that dictionary is not resident, and an error when word
// contains a character outside the variant's alphabet. The word is matched as
// given; callers normalise case to the variant's alphabet first.
func (r *Registry) Lookup(v Variant, version, word string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
versions, ok := r.entries[v]
if !ok {
return false, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
e, ok := versions[version]
if !ok {
return false, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
}
idx, err := e.finder.IndexOf(word)
if err != nil {
return false, fmt.Errorf("engine: lookup %q in %s/%s: %w", word, v, version, err)
}
return idx >= 0, nil
}
// Close releases every resident dictionary and empties the registry. It is safe
// to call more than once; the first close error is returned after all finders
// have been closed.
+81
View File
@@ -0,0 +1,81 @@
package engine
import "testing"
// TestResignLeadingPlayerStillLoses is the core of the resignation fix: a player
// who resigns loses even when leading on score, the remaining player wins, and
// the resigner's score is frozen (no end-game rack adjustment).
func TestResignLeadingPlayerStillLoses(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("player 0 play: %v", err)
}
if played.Score == 0 {
t.Fatal("opening play scored 0; pick a different seed")
}
if _, err := g.Pass(); err != nil { // player 1
t.Fatalf("player 1 pass: %v", err)
}
// Player 0 is now on turn and leads 0:played.Score; resigning must still lose.
if _, err := g.Resign(); err != nil {
t.Fatalf("player 0 resign: %v", err)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
res := g.Result()
if res.Winner != 1 {
t.Errorf("winner = %d, want 1 (the non-resigner) despite the resigner leading", res.Winner)
}
if g.Score(0) != played.Score {
t.Errorf("resigner score = %d, want frozen at %d (no rack adjustment)", g.Score(0), played.Score)
}
if g.Score(1) != 0 {
t.Errorf("opponent score = %d, want 0", g.Score(1))
}
if g.Score(0) <= g.Score(1) {
t.Fatal("test precondition: resigner should lead on raw score")
}
}
// TestResignTrailingPlayerLoses covers the ordinary case: the trailing player
// resigns and the leader wins.
func TestResignTrailingPlayerLoses(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
t.Fatalf("player 0 play: %v", err)
}
// Player 1 (trailing 0 points) resigns.
if _, err := g.Resign(); err != nil {
t.Fatalf("player 1 resign: %v", err)
}
if res := g.Result(); res.Winner != 0 {
t.Errorf("winner = %d, want 0", res.Winner)
}
}
// TestResignOnFinishedGame rejects a second transition.
func TestResignOnFinishedGame(t *testing.T) {
g := newEnglishGame(t, 1)
if _, err := g.Resign(); err != nil {
t.Fatalf("first resign: %v", err)
}
if _, err := g.Resign(); err == nil {
t.Error("resign on a finished game must error")
}
}
+78
View File
@@ -0,0 +1,78 @@
package game
import (
"testing"
"time"
)
func TestInAwayWindow(t *testing.T) {
cases := []struct {
name string
dl, start, end int
wantIn, wantToday bool
}{
{"non-crossing inside", 120, 0, 420, true, true},
{"non-crossing before", 500, 0, 420, false, false},
{"non-crossing at start", 0, 0, 420, true, true},
{"non-crossing at end excluded", 420, 0, 420, false, false},
{"crossing evening", 1380, 1320, 360, true, false},
{"crossing morning", 180, 1320, 360, true, true},
{"crossing daytime out", 720, 1320, 360, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in, today := inAwayWindow(tc.dl, tc.start, tc.end)
if in != tc.wantIn || today != tc.wantToday {
t.Errorf("inAwayWindow(%d,%d,%d) = (%v,%v), want (%v,%v)",
tc.dl, tc.start, tc.end, in, today, tc.wantIn, tc.wantToday)
}
})
}
}
func TestEffectiveDeadline(t *testing.T) {
utc := time.UTC
day := func(h, m int) time.Time { return time.Date(2026, 6, 2, h, m, 0, 0, utc) }
hour := time.Hour
cases := []struct {
name string
start time.Time
timeout time.Duration
awayStart int
awayEnd int
want time.Time
}{
{"no window", day(1, 0), hour, 0, 0, day(2, 0)},
{"outside window", day(8, 0), hour, 0, 420, day(9, 0)},
{"inside non-crossing pushed to end", day(1, 0), hour, 0, 420, day(7, 0)},
{"inside non-crossing at boundary", day(2, 30), 3 * hour, 0, 420, day(7, 0)},
{"crossing evening pushed to next day", day(22, 0), hour, 1320, 360, day(6, 0).AddDate(0, 0, 1)},
{"crossing morning pushed to today end", day(2, 0), hour, 1320, 360, day(6, 0)},
{"crossing daytime untouched", day(11, 0), hour, 1320, 360, day(12, 0)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := effectiveDeadline(tc.start, tc.timeout, utc, tc.awayStart, tc.awayEnd)
if !got.Equal(tc.want) {
t.Errorf("effectiveDeadline = %s, want %s", got, tc.want)
}
})
}
}
func TestMinutesOfDay(t *testing.T) {
got := minutesOfDay(time.Date(1, 1, 1, 7, 30, 0, 0, time.UTC))
if got != 450 {
t.Errorf("minutesOfDay(07:30) = %d, want 450", got)
}
}
func TestLoadLocationFallsBackToUTC(t *testing.T) {
if loadLocation("") != time.UTC {
t.Error("empty zone must fall back to UTC")
}
if loadLocation("Totally/Bogus") != time.UTC {
t.Error("unknown zone must fall back to UTC")
}
}
+121
View File
@@ -0,0 +1,121 @@
package game
import (
"sync"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// keyedMutex hands out one mutex per game id, serialising every operation on a
// single game (engine.Game is not safe for concurrent use) while letting
// different games proceed in parallel. Locks are reference-counted and removed
// once no caller holds or awaits them.
type keyedMutex struct {
mu sync.Mutex
locks map[uuid.UUID]*lockRef
}
type lockRef struct {
mu sync.Mutex
refs int
}
func newKeyedMutex() *keyedMutex {
return &keyedMutex{locks: make(map[uuid.UUID]*lockRef)}
}
// lock acquires the mutex for id and returns its release function.
func (k *keyedMutex) lock(id uuid.UUID) func() {
k.mu.Lock()
ref := k.locks[id]
if ref == nil {
ref = &lockRef{}
k.locks[id] = ref
}
ref.refs++
k.mu.Unlock()
ref.mu.Lock()
return func() {
ref.mu.Unlock()
k.mu.Lock()
ref.refs--
if ref.refs == 0 {
delete(k.locks, id)
}
k.mu.Unlock()
}
}
// gameCache holds live engine.Game values keyed by game id and evicts an entry
// once it has been idle for ttl. An evicted game is transparently rebuilt from
// the journal on next access, so eviction never affects correctness. It is safe
// for concurrent use.
type gameCache struct {
mu sync.Mutex
entries map[uuid.UUID]*cachedGame
ttl time.Duration
now func() time.Time
}
type cachedGame struct {
game *engine.Game
lastAccess time.Time
}
func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
}
// get returns the live game for id and refreshes its idle timer, or (nil, false).
func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[id]
if !ok {
return nil, false
}
e.lastAccess = c.now()
return e.game, true
}
// put stores g as the live game for id.
func (c *gameCache) put(id uuid.UUID, g *engine.Game) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[id] = &cachedGame{game: g, lastAccess: c.now()}
}
// remove drops id from the cache (used on a finished game and after a failed
// persist, so the next access rebuilds from the journal).
func (c *gameCache) remove(id uuid.UUID) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, id)
}
// sweep evicts every entry idle longer than ttl and returns how many were
// dropped.
func (c *gameCache) sweep() int {
c.mu.Lock()
defer c.mu.Unlock()
cutoff := c.now().Add(-c.ttl)
var n int
for id, e := range c.entries {
if e.lastAccess.Before(cutoff) {
delete(c.entries, id)
n++
}
}
return n
}
// size reports the number of resident games (for diagnostics and tests).
func (c *gameCache) size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.entries)
}
+54
View File
@@ -0,0 +1,54 @@
package game
import (
"errors"
"fmt"
"time"
)
// Config configures the game subsystem: where the engine loads its dictionaries,
// which version new games pin, and the two background knobs — the turn-timeout
// sweep cadence and the idle window after which the live-game cache evicts a game
// (it is then rebuilt from the journal on next access, so this never affects
// correctness). It composes into the backend configuration.
type Config struct {
// DictDir is the directory holding the committed DAWG files. Sourced from
// BACKEND_DICT_DIR; it has no default and must be set.
DictDir string
// DictVersion labels the dictionary version new games pin. Sourced from
// BACKEND_DICT_VERSION.
DictVersion string
// TimeoutSweepInterval is how often the sweeper scans for overdue turns.
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
TimeoutSweepInterval time.Duration
// CacheTTL is how long an idle game stays resident before eviction. Sourced
// from BACKEND_GAME_CACHE_TTL.
CacheTTL time.Duration
}
// DefaultConfig returns the game configuration defaults. DictDir is deliberately
// empty: it must be supplied through the environment.
func DefaultConfig() Config {
return Config{
DictVersion: "v1",
TimeoutSweepInterval: time.Minute,
CacheTTL: 24 * time.Hour,
}
}
// Validate reports whether the configuration is usable.
func (c Config) Validate() error {
if c.DictDir == "" {
return errors.New("game: BACKEND_DICT_DIR must be set")
}
if c.DictVersion == "" {
return errors.New("game: BACKEND_DICT_VERSION must not be empty")
}
if c.TimeoutSweepInterval <= 0 {
return fmt.Errorf("game: timeout sweep interval must be positive, got %s", c.TimeoutSweepInterval)
}
if c.CacheTTL <= 0 {
return fmt.Errorf("game: cache TTL must be positive, got %s", c.CacheTTL)
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
// Package game is the backend's game domain. It drives the in-process
// scrabble-solver engine (internal/engine) over a single match and owns
// everything the engine deliberately does not: persistence, scheduling and the
// player-facing operations.
//
// Active games are event-sourced. Durably, a game is its games row plus an
// append-only, dictionary-independent move journal (game_moves); the live
// position is an engine.Game kept warm in an in-memory cache and rebuilt by
// replaying the journal on a cache miss, which the engine's seeded bag makes
// exact (docs/ARCHITECTURE.md §9). Each game is serialised by a per-game lock,
// since engine.Game is not safe for concurrent use; on a persistence failure the
// live game is evicted so the next access rebuilds from the journal.
//
// The Service exposes create, the play/pass/exchange/resign transitions with
// validate-at-submit scoring, the one-per-game-plus-wallet hint, the unlimited
// word-check tool with complaint capture, per-player game state, history and GCG
// export, and the per-game turn-timeout sweeper that auto-resigns an overdue
// player (honouring their daily away window). The HTTP surface that fronts these
// operations is added with the gateway in a later stage.
package game
+119
View File
@@ -0,0 +1,119 @@
package game
import (
"fmt"
"strconv"
"strings"
)
// writeGCG renders a game as GCG text in the standard (Poslfit) dialect, plus
// #note lines for resignations and timeouts, which the standard does not cover.
// It is derived entirely from the decoded journal, so it needs no dictionary
// (docs/ARCHITECTURE.md §9.1). names supplies each seat's display name; the
// GCG nicknames are p1, p2, … .
func writeGCG(g Game, names []string, moves []HistoryMove) string {
var b strings.Builder
fmt.Fprintln(&b, "#character-encoding UTF-8")
for seat := 0; seat < g.Players; seat++ {
fmt.Fprintf(&b, "#player%d %s %s\n", seat+1, nick(seat), playerName(names, seat))
}
fmt.Fprintf(&b, "#lexicon %s/%s\n", g.Variant, g.DictVersion)
fmt.Fprintf(&b, "#title game %s\n", g.ID)
for _, mv := range moves {
rack := gcgTiles(mv.Rack)
switch mv.Action {
case "play":
fmt.Fprintf(&b, ">%s: %s %s %s +%d %d\n",
nick(mv.Seat), rack, gcgPos(mv), gcgWord(mv), mv.Score, mv.RunningTotal)
case "pass":
fmt.Fprintf(&b, ">%s: %s - +0 %d\n", nick(mv.Seat), rack, mv.RunningTotal)
case "exchange":
fmt.Fprintf(&b, ">%s: %s -%s +0 %d\n", nick(mv.Seat), rack, gcgTiles(mv.Exchanged), mv.RunningTotal)
case "resign":
fmt.Fprintf(&b, "#note %s resigned (rack %s)\n", nick(mv.Seat), rack)
case "timeout":
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
}
}
return b.String()
}
// nick is the GCG nickname for a seat: p1, p2, … (space-free, as GCG requires).
func nick(seat int) string { return "p" + strconv.Itoa(seat+1) }
// playerName returns the display name for a seat, or a generic fallback.
func playerName(names []string, seat int) string {
if seat < len(names) && names[seat] != "" {
return names[seat]
}
return "Player " + strconv.Itoa(seat+1)
}
// gcgTiles renders a rack or exchanged set in GCG form: upper-cased letters with
// "?" for a blank.
func gcgTiles(tiles []string) string {
var b strings.Builder
for _, t := range tiles {
if t == "?" {
b.WriteByte('?')
continue
}
b.WriteString(strings.ToUpper(t))
}
return b.String()
}
// gcgPos renders a play's board coordinate: row-then-column (e.g. 8G) for an
// across play, column-then-row (e.g. H8) for a down play. Rows are 1-based and
// columns are lettered from A.
func gcgPos(mv HistoryMove) string {
col := string(rune('A' + mv.MainCol))
row := strconv.Itoa(mv.MainRow + 1)
if mv.Dir == "V" {
return col + row
}
return row + col
}
// gcgWord renders the main word: each cell along it is the newly-placed tile's
// letter (lower-cased for a blank, upper-cased otherwise) or "." for a tile
// already on the board.
func gcgWord(mv HistoryMove) string {
placed := make(map[[2]int]tileLetter, len(mv.Tiles))
for _, t := range mv.Tiles {
placed[[2]int{t.Row, t.Col}] = tileLetter{letter: t.Letter, blank: t.Blank}
}
var word string
if len(mv.Words) > 0 {
word = mv.Words[0]
}
n := len([]rune(word))
var b strings.Builder
for i := range n {
row, col := mv.MainRow, mv.MainCol
if mv.Dir == "V" {
row += i
} else {
col += i
}
t, ok := placed[[2]int{row, col}]
if !ok {
b.WriteByte('.')
continue
}
if t.blank {
b.WriteString(strings.ToLower(t.letter))
} else {
b.WriteString(strings.ToUpper(t.letter))
}
}
return b.String()
}
// tileLetter is a placed tile's concrete letter and blank flag, keyed by cell in
// gcgWord.
type tileLetter struct {
letter string
blank bool
}
+79
View File
@@ -0,0 +1,79 @@
package game
import (
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
func TestWriteGCG(t *testing.T) {
g := Game{
ID: uuid.MustParse("00000000-0000-7000-8000-000000000001"),
Variant: engine.VariantEnglish,
DictVersion: "v1",
Players: 2,
}
moves := []HistoryMove{
{
Seq: 0, Seat: 0, Action: "play", Score: 10, RunningTotal: 10,
Dir: "H", MainRow: 7, MainCol: 7,
Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "c"}, {Row: 7, Col: 8, Letter: "a"}, {Row: 7, Col: 9, Letter: "t"}},
Words: []string{"cat"}, Rack: []string{"c", "a", "t", "s", "e", "r", "?"},
},
{
Seq: 1, Seat: 1, Action: "play", Score: 2, RunningTotal: 2,
Dir: "V", MainRow: 7, MainCol: 8,
Tiles: []engine.TileRecord{{Row: 8, Col: 8, Letter: "s", Blank: true}},
Words: []string{"as"}, Rack: []string{"a", "s", "?", "e"},
},
{Seq: 2, Seat: 0, Action: "pass", RunningTotal: 10, Rack: []string{"x", "y", "z"}},
{Seq: 3, Seat: 1, Action: "exchange", RunningTotal: 2, Exchanged: []string{"q", "u"}, Rack: []string{"q", "u", "i"}},
{Seq: 4, Seat: 0, Action: "resign", RunningTotal: 10, Rack: []string{"a", "b"}},
{Seq: 5, Seat: 1, Action: "timeout", RunningTotal: 2, Rack: []string{"c"}},
}
out := writeGCG(g, []string{"Alice", "Bob"}, moves)
wantLines := []string{
"#character-encoding UTF-8",
"#player1 p1 Alice",
"#player2 p2 Bob",
"#lexicon english/v1",
"#title game 00000000-0000-7000-8000-000000000001",
">p1: CATSER? 8H CAT +10 10",
">p2: AS?E I8 .s +2 2",
">p1: XYZ - +0 10",
">p2: QUI -QU +0 2",
"#note p1 resigned (rack AB)",
"#note p2 timed out (rack C)",
}
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
got := make(map[string]bool, len(lines))
for _, l := range lines {
got[l] = true
}
for _, want := range wantLines {
if !got[want] {
t.Errorf("GCG missing line %q\n--- full output ---\n%s", want, out)
}
}
}
func TestGCGTilesUppercasesCyrillic(t *testing.T) {
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
t.Errorf("gcgTiles = %q, want КОТ?", got)
}
}
func TestGCGPos(t *testing.T) {
across := gcgPos(HistoryMove{Dir: "H", MainRow: 7, MainCol: 6})
if across != "8G" {
t.Errorf("across pos = %q, want 8G", across)
}
down := gcgPos(HistoryMove{Dir: "V", MainRow: 6, MainCol: 7})
if down != "H7" {
t.Errorf("down pos = %q, want H7", down)
}
}
+139
View File
@@ -0,0 +1,139 @@
package game
import (
"sync"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
func TestPayloadPlayRoundTrip(t *testing.T) {
rec := engine.MoveRecord{
Action: engine.ActionPlay, Dir: engine.Vertical, MainRow: 3, MainCol: 4,
Tiles: []engine.TileRecord{{Row: 3, Col: 4, Letter: "q", Blank: true}, {Row: 4, Col: 4, Letter: "i"}},
Words: []string{"qi"},
}
s, err := buildPayload(rec, []string{"q", "i", "?"}, nil).marshal()
if err != nil {
t.Fatalf("marshal: %v", err)
}
p, err := parsePayload(s)
if err != nil {
t.Fatalf("parse: %v", err)
}
if p.direction() != engine.Vertical || p.MainRow != 3 || p.MainCol != 4 {
t.Errorf("dir/anchor = %v/(%d,%d)", p.direction(), p.MainRow, p.MainCol)
}
tiles := p.tileRecords()
if len(tiles) != 2 || tiles[0].Letter != "q" || !tiles[0].Blank || tiles[1].Letter != "i" {
t.Errorf("tiles = %+v", tiles)
}
if len(p.Rack) != 3 || p.Rack[2] != "?" {
t.Errorf("rack = %v", p.Rack)
}
}
func TestPayloadExchangeRoundTrip(t *testing.T) {
rec := engine.MoveRecord{Action: engine.ActionExchange, Count: 2}
s, err := buildPayload(rec, []string{"a", "b", "c"}, []string{"a", "b"}).marshal()
if err != nil {
t.Fatalf("marshal: %v", err)
}
p, err := parsePayload(s)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(p.Exchanged) != 2 || p.Exchanged[0] != "a" {
t.Errorf("exchanged = %v", p.Exchanged)
}
if len(p.Tiles) != 0 || p.Dir != "" {
t.Errorf("exchange payload carried play fields: %+v", p)
}
}
func TestHintsRemaining(t *testing.T) {
cases := []struct{ allowance, used, wallet, want int }{
{1, 0, 3, 4},
{1, 1, 3, 3},
{1, 2, 3, 3}, // used past allowance clamps to 0
{0, 0, 5, 5},
{2, 1, 0, 1},
}
for _, c := range cases {
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
}
}
}
func TestAllowedTimeout(t *testing.T) {
if !allowedTimeout(24 * time.Hour) {
t.Error("24h must be allowed")
}
if !allowedTimeout(5 * time.Minute) {
t.Error("5m must be allowed")
}
if allowedTimeout(7 * time.Minute) {
t.Error("7m must not be allowed")
}
if allowedTimeout(0) {
t.Error("zero must not be allowed")
}
}
func TestNormalizeWord(t *testing.T) {
if got := normalizeWord(" CaT \n"); got != "cat" {
t.Errorf("normalizeWord = %q, want cat", got)
}
}
func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New()
cache.put(id, nil)
if _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
cur = cur.Add(30 * time.Minute)
cache.get(id) // refresh idle timer
cur = cur.Add(90 * time.Minute)
if n := cache.sweep(); n != 1 {
t.Errorf("sweep evicted %d, want 1", n)
}
if _, ok := cache.get(id); ok {
t.Error("game must be evicted after idle TTL")
}
if cache.size() != 0 {
t.Errorf("cache size = %d, want 0", cache.size())
}
}
func TestKeyedMutexSerializes(t *testing.T) {
km := newKeyedMutex()
id := uuid.New()
var counter int
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
unlock := km.lock(id)
counter++ // serialised; -race would flag a missing lock
unlock()
}()
}
wg.Wait()
if counter != 200 {
t.Errorf("counter = %d, want 200", counter)
}
km.mu.Lock()
left := len(km.locks)
km.mu.Unlock()
if left != 0 {
t.Errorf("lock map not cleaned up: %d entries left", left)
}
}
+89
View File
@@ -0,0 +1,89 @@
package game
import (
"encoding/json"
"fmt"
"scrabble/backend/internal/engine"
)
// movePayload is the JSON stored in game_moves.payload. It holds the decoded,
// dictionary-independent values needed both to replay the game through the engine
// and to render history / emit GCG without a dictionary (docs/ARCHITECTURE.md
// §9.1): the acting player's rack before the move, and per action the play's
// direction, main-word anchor, placed tiles and formed words, or an exchange's
// swapped tiles.
type movePayload struct {
Rack []string `json:"rack"`
Dir string `json:"dir,omitempty"`
MainRow int `json:"main_row,omitempty"`
MainCol int `json:"main_col,omitempty"`
Tiles []tilePayload `json:"tiles,omitempty"`
Words []string `json:"words,omitempty"`
Exchanged []string `json:"exchanged,omitempty"`
}
// tilePayload is one placed tile in a play payload.
type tilePayload struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank,omitempty"`
}
// buildPayload assembles the journal payload from the engine's decoded record,
// the acting rack captured before the move, and (for an exchange) the swapped
// tiles.
func buildPayload(rec engine.MoveRecord, rackBefore, exchanged []string) movePayload {
p := movePayload{Rack: rackBefore}
switch rec.Action {
case engine.ActionPlay:
p.Dir = rec.Dir.String()
p.MainRow = rec.MainRow
p.MainCol = rec.MainCol
p.Words = rec.Words
p.Tiles = make([]tilePayload, len(rec.Tiles))
for i, t := range rec.Tiles {
p.Tiles[i] = tilePayload{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
}
case engine.ActionExchange:
p.Exchanged = exchanged
}
return p
}
// marshal renders the payload as the JSON text stored in the column.
func (p movePayload) marshal() (string, error) {
b, err := json.Marshal(p)
if err != nil {
return "", fmt.Errorf("game: marshal move payload: %w", err)
}
return string(b), nil
}
// parsePayload parses a stored payload back into its decoded fields.
func parsePayload(s string) (movePayload, error) {
var p movePayload
if err := json.Unmarshal([]byte(s), &p); err != nil {
return movePayload{}, fmt.Errorf("game: parse move payload: %w", err)
}
return p, nil
}
// tileRecords converts payload tiles back into engine TileRecords for replay and
// history.
func (p movePayload) tileRecords() []engine.TileRecord {
out := make([]engine.TileRecord, len(p.Tiles))
for i, t := range p.Tiles {
out[i] = engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
}
return out
}
// direction parses the stored "H"/"V" into an engine.Direction.
func (p movePayload) direction() engine.Direction {
if p.Dir == engine.Vertical.String() {
return engine.Vertical
}
return engine.Horizontal
}
+629
View File
@@ -0,0 +1,629 @@
package game
import (
"context"
crand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
)
// Service is the game domain: it drives the engine over a single match, persists
// the event-sourced journal, keeps live games warm in a cache, serves hints and
// the word-check tool, exports GCG and runs the turn-timeout sweeper. It is the
// only writer of the game tables and is safe for concurrent use (per-game
// serialised by an internal keyed mutex).
type Service struct {
store *Store
accounts *account.Store
registry *engine.Registry
cache *gameCache
locks *keyedMutex
version string
clock func() time.Time
rng func() int64
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
// registry holds the resident dictionaries; cfg supplies the pinned version and
// the cache idle window; log is used by the background sweeper.
func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service {
clock := func() time.Time { return time.Now().UTC() }
return &Service{
store: store,
accounts: accounts,
registry: registry,
cache: newGameCache(cfg.CacheTTL, clock),
locks: newKeyedMutex(),
version: cfg.DictVersion,
clock: clock,
rng: randomSeed,
log: log,
}
}
// Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat
// is a distinct existing account.
func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, error) {
if n := len(params.Seats); n < 2 || n > 4 {
return Game{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidConfig, n)
}
if params.HintsPerPlayer < 0 {
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
}
timeout := params.TurnTimeout
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats {
if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
}
return Game{}, err
}
}
seed := params.Seed
if seed == 0 {
seed = svc.rng()
}
g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
})
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
return Game{}, err
}
id, err := uuid.NewV7()
if err != nil {
return Game{}, fmt.Errorf("game: new id: %w", err)
}
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
seed: seed,
players: len(params.Seats),
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g)
return svc.store.GetGame(ctx, id)
}
// engineOp applies one transition to the live game, returning the decoded record
// and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
// SubmitPlay validates, scores and commits the player's placement.
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.SubmitPlay(dir, tiles)
return rec, nil, err
})
}
// Pass commits a forfeited turn.
func (svc *Service) Pass(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Pass()
return rec, nil, err
})
}
// Exchange swaps the named tiles ("?" for a blank) and commits the turn.
func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.SubmitExchange(tiles)
return rec, tiles, err
})
}
// Resign ends the game on the player's turn; the remaining player wins.
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Resign()
return rec, nil, err
})
}
// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return MoveResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return MoveResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return MoveResult{}, ErrFinished
}
if pre.ToMove != seat {
return MoveResult{}, ErrNotYourTurn
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return MoveResult{}, err
}
if g.Over() {
return MoveResult{}, ErrFinished
}
if g.ToMove() != seat {
return MoveResult{}, ErrNotYourTurn
}
rackBefore := g.Hand(seat)
rec, exchanged, err := op(g)
if err != nil {
return MoveResult{}, err
}
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats)
if err != nil {
return MoveResult{}, err
}
return MoveResult{Move: rec, Game: post}, nil
}
// commit persists a just-applied transition: the journal row, the post-move turn
// cursor and scores, and on a game-ending move the finish stamp and statistics.
// On a persistence failure it evicts the now-divergent live game so the next
// access rebuilds from the journal.
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat) (Game, error) {
now := svc.clock()
logLen := len(g.Log())
scores := make([]int, g.Players())
for i := range scores {
scores[i] = g.Score(i)
}
c := commit{
gameID: gameID,
seq: logLen - 1,
seat: rec.Player,
action: action,
score: rec.Score,
runningTotal: rec.Total,
exchanged: exchanged,
rec: rec,
rackBefore: rackBefore,
toMove: g.ToMove(),
turnStartedAt: now,
moveCount: logLen,
scores: scores,
now: now,
}
if g.Over() {
c.finished = true
c.finishedAt = now
c.endReason = g.Reason().String()
if action == "timeout" {
c.endReason = "timeout"
}
c.winner = g.Result().Winner
c.stats = buildStats(g, seats)
}
if err := svc.store.CommitMove(ctx, c); err != nil {
svc.cache.remove(gameID)
return Game{}, err
}
if c.finished {
svc.cache.remove(gameID)
}
return svc.store.GetGame(ctx, gameID)
}
// timeoutGame auto-resigns the to-move player of an overdue game. It re-checks,
// under the per-game lock, that the game is still active and still past the
// effective deadline (so a move made since the sweep is not clobbered), records
// the move as a timeout, and reports whether it timed the game out.
func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.Time) (bool, error) {
unlock := svc.locks.lock(gameID)
defer unlock()
cur, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return false, err
}
if cur.Status != StatusActive {
return false, nil
}
seat := cur.ToMove
if seat < 0 || seat >= len(cur.Seats) {
return false, nil
}
acc, err := svc.accounts.GetByID(ctx, cur.Seats[seat].AccountID)
if err != nil {
return false, err
}
deadline := effectiveDeadline(cur.TurnStartedAt, cur.TurnTimeout, loadLocation(acc.TimeZone), minutesOfDay(acc.AwayStart), minutesOfDay(acc.AwayEnd))
if now.Before(deadline) {
return false, nil
}
g, err := svc.liveGame(ctx, cur)
if err != nil {
return false, err
}
if g.Over() {
return false, nil
}
rackBefore := g.Hand(g.ToMove())
rec, err := g.Resign()
if err != nil {
return false, err
}
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
return false, err
}
return true, nil
}
// EvaluatePlay previews a tentative play for a seated player against the current
// board without committing it: whether it is legal and what it would score.
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (EvalResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return EvalResult{}, err
}
if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return EvalResult{}, ErrFinished
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return EvalResult{}, err
}
rec, err := g.EvaluatePlay(dir, tiles)
if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
return EvalResult{Valid: false}, nil
}
return EvalResult{}, err
}
return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words}, nil
}
// CheckWord reports whether word is in the game's pinned dictionary. It is the
// unlimited word-check tool; an input outside the variant's alphabet is simply
// not a word.
func (svc *Service) CheckWord(ctx context.Context, gameID uuid.UUID, word string) (bool, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return false, err
}
return svc.lookupWord(pre.Variant, pre.DictVersion, word)
}
// FileComplaint records a word-check complaint against the game's dictionary for
// later admin review, stamping the disputed lookup result.
func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UUID, word, note string) (Complaint, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Complaint{}, err
}
if _, ok := pre.seatOf(accountID); !ok {
return Complaint{}, ErrNotAPlayer
}
normalized := normalizeWord(word)
valid, err := svc.lookupWord(pre.Variant, pre.DictVersion, normalized)
if err != nil {
return Complaint{}, err
}
return svc.store.FileComplaint(ctx, Complaint{
ComplainantID: accountID,
GameID: gameID,
Variant: pre.Variant,
DictVersion: pre.DictVersion,
Word: normalized,
WasValid: valid,
Note: note,
})
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
// appropriate.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return HintResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return HintResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return HintResult{}, ErrFinished
}
if pre.ToMove != seat {
return HintResult{}, ErrNotYourTurn
}
if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return HintResult{}, err
}
used := pre.Seats[seat].HintsUsed
fromAllowance := used < pre.HintsPerPlayer
if !fromAllowance && acc.HintBalance <= 0 {
return HintResult{}, ErrNoHintsLeft
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return HintResult{}, err
}
move, ok := g.HintView()
if !ok {
return HintResult{}, ErrNoHintAvailable
}
walletAfter := acc.HintBalance
if fromAllowance {
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
} else {
spent, err := svc.accounts.SpendHint(ctx, accountID)
if err != nil {
return HintResult{}, err
}
if !spent {
return HintResult{}, ErrNoHintsLeft
}
walletAfter--
}
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
}
// GameState returns a seated player's view of the game: the shared summary plus
// their private rack, the bag size and their remaining hint budget.
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return StateView{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return StateView{}, ErrNotAPlayer
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return StateView{}, err
}
return StateView{
Game: pre,
Seat: seat,
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
}, nil
}
// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return HistoryView{}, err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return HistoryView{}, err
}
return HistoryView{Game: g, Moves: moves}, nil
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return "", err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return "", err
}
return writeGCG(g, svc.seatNames(ctx, g), moves), nil
}
// liveGame returns the live engine.Game for pre, rebuilding it from the journal
// on a cache miss. Callers must hold the per-game lock.
func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) {
if g, ok := svc.cache.get(pre.ID); ok {
return g, nil
}
g, err := svc.replay(ctx, pre)
if err != nil {
return nil, err
}
if !g.Over() {
svc.cache.put(pre.ID, g)
}
return g, nil
}
// replay reconstructs an engine.Game by dealing from the pinned seed and
// re-applying every journalled move in order. The deterministic bag makes the
// reconstruction exact.
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
seed, err := svc.store.GameSeed(ctx, pre.ID)
if err != nil {
return nil, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
})
if err != nil {
return nil, err
}
moves, err := svc.store.GetJournal(ctx, pre.ID)
if err != nil {
return nil, err
}
for _, mv := range moves {
if err := replayMove(g, mv); err != nil {
return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err)
}
}
return g, nil
}
// replayMove re-applies one journalled move to g through the decoded engine API.
func replayMove(g *engine.Game, mv HistoryMove) error {
switch mv.Action {
case "play":
dir := engine.Horizontal
if mv.Dir == "V" {
dir = engine.Vertical
}
_, err := g.SubmitPlay(dir, mv.Tiles)
return err
case "pass":
_, err := g.Pass()
return err
case "exchange":
_, err := g.SubmitExchange(mv.Exchanged)
return err
case "resign", "timeout":
_, err := g.Resign()
return err
default:
return fmt.Errorf("unknown action %q", mv.Action)
}
}
// buildStats derives each seat's statistics contribution from a finished game:
// win/loss/draw from the (resignation-aware) winner, the final score, and the
// best single-move score from the log.
func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result()
best := make(map[int]int)
for _, rec := range g.Log() {
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
best[rec.Player] = rec.Score
}
}
out := make([]statDelta, 0, len(seats))
for _, s := range seats {
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
switch {
case res.Winner < 0:
d.draws = 1
case res.Winner == s.Seat:
d.wins = 1
default:
d.losses = 1
}
out = append(out, d)
}
return out
}
// seatNames resolves each seat's display name for GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players)
for _, s := range g.Seats {
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
names[s.Seat] = acc.DisplayName
}
}
return names
}
// lookupWord checks word against a variant/version dictionary, treating an
// out-of-alphabet input as simply not a word (a real registry error still
// surfaces).
func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bool, error) {
present, err := svc.registry.Lookup(variant, version, normalizeWord(word))
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
return false, err
}
return false, nil
}
return present, nil
}
// hintsRemaining is a player's remaining hint budget: the unspent per-game
// allowance plus the profile wallet.
func hintsRemaining(allowance, used, wallet int) int {
return max(0, allowance-used) + wallet
}
// allowedTimeout reports whether d is one of the offered move clocks.
func allowedTimeout(d time.Duration) bool {
return slices.Contains(AllowedTurnTimeouts, d)
}
// normalizeWord lower-cases and trims a word-check input to the alphabet's form.
func normalizeWord(word string) string {
return strings.ToLower(strings.TrimSpace(word))
}
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
// system source fails.
func randomSeed() int64 {
var b [8]byte
if _, err := crand.Read(b[:]); err != nil {
return time.Now().UnixNano()
}
return int64(binary.LittleEndian.Uint64(b[:]))
}
+439
View File
@@ -0,0 +1,439 @@
package game
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Store is the Postgres-backed query surface for games, seats, the move journal,
// complaints and per-account statistics.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// gameInsert carries the immutable fields of a new game.
type gameInsert struct {
id uuid.UUID
variant string
dictVersion string
seed int64
players int
turnTimeoutSecs int
hintsAllowed bool
hintsPerPlayer int
}
// statDelta is one account's contribution to its statistics on a game finish.
type statDelta struct {
accountID uuid.UUID
wins int
losses int
draws int
gamePoints int
wordPoints int
}
// commit is everything a single committed transition persists: the journal row,
// the post-move game cursor and per-seat scores, and — when the move ended the
// game — the finish stamp and the statistics deltas.
type commit struct {
gameID uuid.UUID
seq int
seat int
action string
score int
runningTotal int
exchanged []string
rec engine.MoveRecord
rackBefore []string
toMove int
turnStartedAt time.Time
moveCount int
scores []int
now time.Time
finished bool
endReason string
finishedAt time.Time
winner int // -1 on a draw
stats []statDelta
}
// activeGame is the sweeper's view of an in-progress game's turn clock.
type activeGame struct {
gameID uuid.UUID
toMove int
turnStartedAt time.Time
turnTimeoutSecs int
}
// CreateGame inserts the games row and one game_players row per seat (seat 0
// first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
gi := table.Games.INSERT(
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
for seat, accountID := range seats {
pi := table.GamePlayers.INSERT(
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
).VALUES(ins.id, seat, accountID)
if _, err := pi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert seat %d: %w", seat, err)
}
}
return nil
})
}
// GetGame loads the games row joined with its seats (ordered by seat), or
// ErrNotFound.
func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
gstmt := postgres.SELECT(table.Games.AllColumns).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var grow model.Games
if err := gstmt.QueryContext(ctx, s.db, &grow); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Game{}, ErrNotFound
}
return Game{}, fmt.Errorf("game: get %s: %w", id, err)
}
sstmt := postgres.SELECT(table.GamePlayers.AllColumns).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GamePlayers.Seat.ASC())
var srows []model.GamePlayers
if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil {
return Game{}, fmt.Errorf("game: get seats %s: %w", id, err)
}
return projectGame(grow, srows)
}
// GetJournal loads the ordered, decoded move journal for a game.
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
stmt := postgres.SELECT(table.GameMoves.AllColumns).
FROM(table.GameMoves).
WHERE(table.GameMoves.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GameMoves.Seq.ASC())
var rows []model.GameMoves
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: get journal %s: %w", id, err)
}
out := make([]HistoryMove, 0, len(rows))
for _, r := range rows {
p, err := parsePayload(r.Payload)
if err != nil {
return nil, err
}
out = append(out, HistoryMove{
Seq: int(r.Seq),
Seat: int(r.Seat),
Action: r.Action,
Score: int(r.Score),
RunningTotal: int(r.RunningTotal),
Dir: p.Dir,
MainRow: p.MainRow,
MainCol: p.MainCol,
Tiles: p.tileRecords(),
Words: p.Words,
Exchanged: p.Exchanged,
Rack: p.Rack,
})
}
return out, nil
}
// CommitMove appends the move and applies the post-move game state — the turn
// cursor and per-seat scores, plus the finish stamp and statistics when the move
// ended the game — in one transaction.
func (s *Store) CommitMove(ctx context.Context, c commit) error {
payload, err := buildPayload(c.rec, c.rackBefore, c.exchanged).marshal()
if err != nil {
return err
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
mi := table.GameMoves.INSERT(
table.GameMoves.GameID, table.GameMoves.Seq, table.GameMoves.Seat, table.GameMoves.Action,
table.GameMoves.Score, table.GameMoves.RunningTotal, table.GameMoves.ExchangedCount, table.GameMoves.Payload,
).VALUES(c.gameID, c.seq, c.seat, c.action, c.score, c.runningTotal, len(c.exchanged), payload)
if _, err := mi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("append move: %w", err)
}
if c.finished {
gu := table.Games.UPDATE(
table.Games.Status, table.Games.ToMove, table.Games.MoveCount,
table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt,
).SET(
postgres.String(StatusFinished), postgres.Int(int64(c.toMove)), postgres.Int(int64(c.moveCount)),
postgres.String(c.endReason), postgres.TimestampzT(c.now), postgres.TimestampzT(c.finishedAt),
).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID)))
if _, err := gu.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("finish game: %w", err)
}
} else {
gu := table.Games.UPDATE(
table.Games.ToMove, table.Games.TurnStartedAt, table.Games.MoveCount, table.Games.UpdatedAt,
).SET(
postgres.Int(int64(c.toMove)), postgres.TimestampzT(c.turnStartedAt), postgres.Int(int64(c.moveCount)), postgres.TimestampzT(c.now),
).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID)))
if _, err := gu.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("advance game: %w", err)
}
}
for seat, score := range c.scores {
if err := updateSeatScore(ctx, tx, c.gameID, seat, score, c.finished, c.finished && seat == c.winner); err != nil {
return fmt.Errorf("update seat %d: %w", seat, err)
}
}
if c.finished {
for _, d := range c.stats {
if err := upsertStats(ctx, tx, d, c.now); err != nil {
return err
}
}
}
return nil
})
}
// updateSeatScore writes a seat's running score, also stamping is_winner when the
// game has finished.
func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error {
where := table.GamePlayers.GameID.EQ(postgres.UUID(gameID)).
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat))))
var stmt postgres.UpdateStatement
if finished {
stmt = table.GamePlayers.
UPDATE(table.GamePlayers.Score, table.GamePlayers.IsWinner).
SET(postgres.Int(int64(score)), postgres.Bool(isWinner)).
WHERE(where)
} else {
stmt = table.GamePlayers.
UPDATE(table.GamePlayers.Score).
SET(postgres.Int(int64(score))).
WHERE(where)
}
_, err := stmt.ExecContext(ctx, tx)
return err
}
// upsertStats folds one account's deltas into account_stats, locking the row for
// the read-modify-write so concurrent finishes accumulate correctly.
func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
ensure := table.AccountStats.
INSERT(table.AccountStats.AccountID).
VALUES(d.accountID).
ON_CONFLICT(table.AccountStats.AccountID).
DO_NOTHING()
if _, err := ensure.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("ensure stats %s: %w", d.accountID, err)
}
sel := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID))).
FOR(postgres.UPDATE())
var row model.AccountStats
if err := sel.QueryContext(ctx, tx, &row); err != nil {
return fmt.Errorf("lock stats %s: %w", d.accountID, err)
}
wins := row.Wins + int32(d.wins)
losses := row.Losses + int32(d.losses)
draws := row.Draws + int32(d.draws)
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
).SET(
postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("update stats %s: %w", d.accountID, err)
}
return nil
}
// SpendHintAllowance increments a seat's per-game hint counter by one.
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
stmt := table.GamePlayers.
UPDATE(table.GamePlayers.HintsUsed).
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
WHERE(
table.GamePlayers.GameID.EQ(postgres.UUID(gameID)).
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("game: spend hint allowance: %w", err)
}
return nil
}
// FileComplaint persists a word-check complaint in status open and returns the
// stored row.
func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, error) {
id, err := uuid.NewV7()
if err != nil {
return Complaint{}, fmt.Errorf("game: new complaint id: %w", err)
}
stmt := table.Complaints.INSERT(
table.Complaints.ComplaintID, table.Complaints.ComplainantID, table.Complaints.GameID,
table.Complaints.Variant, table.Complaints.DictVersion, table.Complaints.Word,
table.Complaints.WasValid, table.Complaints.Note,
).VALUES(
id, c.ComplainantID, c.GameID, c.Variant.String(), c.DictVersion, c.Word, c.WasValid, c.Note,
).RETURNING(table.Complaints.AllColumns)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Complaint{}, fmt.Errorf("game: file complaint: %w", err)
}
return projectComplaint(row)
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
stmt := postgres.SELECT(
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, table.Games.TurnTimeoutSecs,
).FROM(table.Games).
WHERE(table.Games.Status.EQ(postgres.String(StatusActive))).
ORDER_BY(table.Games.TurnStartedAt.ASC())
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list active: %w", err)
}
out := make([]activeGame, 0, len(rows))
for _, r := range rows {
out = append(out, activeGame{
gameID: r.GameID,
toMove: int(r.ToMove),
turnStartedAt: r.TurnStartedAt,
turnTimeoutSecs: int(r.TurnTimeoutSecs),
})
}
return out, nil
}
// GameSeed returns the bag seed a game was dealt from, used to replay it. The
// seed is server-only state and never travels in the public Game view.
func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
stmt := postgres.SELECT(table.Games.Seed).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("game: get seed %s: %w", id, err)
}
return row.Seed, nil
}
// projectGame builds a Game from a games row and its ordered seat rows.
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
variant, err := engine.ParseVariant(g.Variant)
if err != nil {
return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
}
out := Game{
ID: g.GameID,
Variant: variant,
DictVersion: g.DictVersion,
Status: g.Status,
Players: int(g.Players),
ToMove: int(g.ToMove),
TurnStartedAt: g.TurnStartedAt,
TurnTimeout: time.Duration(g.TurnTimeoutSecs) * time.Second,
HintsAllowed: g.HintsAllowed,
HintsPerPlayer: int(g.HintsPerPlayer),
MoveCount: int(g.MoveCount),
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
if g.EndReason != nil {
out.EndReason = *g.EndReason
}
if g.FinishedAt != nil {
t := *g.FinishedAt
out.FinishedAt = &t
}
out.Seats = make([]Seat, 0, len(seats))
for _, p := range seats {
out.Seats = append(out.Seats, Seat{
Seat: int(p.Seat),
AccountID: p.AccountID,
Score: int(p.Score),
HintsUsed: int(p.HintsUsed),
IsWinner: p.IsWinner,
})
}
return out, nil
}
// projectComplaint builds a Complaint from a stored row.
func projectComplaint(row model.Complaints) (Complaint, error) {
variant, err := engine.ParseVariant(row.Variant)
if err != nil {
return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
}
return Complaint{
ID: row.ComplaintID,
ComplainantID: row.ComplainantID,
GameID: row.GameID,
Variant: variant,
DictVersion: row.DictVersion,
Word: row.Word,
WasValid: row.WasValid,
Note: row.Note,
Status: row.Status,
CreatedAt: row.CreatedAt,
}, nil
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package game
import (
"context"
"time"
"go.uber.org/zap"
)
// effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline
// (turn start plus the per-game timeout) unless that instant falls inside the
// acting player's away window (a daily local-time interval in loc), in which case
// it is pushed to the end of that window — so a player is never timed out while
// asleep. awayStartMin and awayEndMin are minutes since local midnight; an empty
// window (start == end) disables the grace. The function is pure and total.
func effectiveDeadline(turnStartedAt time.Time, timeout time.Duration, loc *time.Location, awayStartMin, awayEndMin int) time.Time {
raw := turnStartedAt.Add(timeout)
if awayStartMin == awayEndMin {
return raw
}
local := raw.In(loc)
dlMin := local.Hour()*60 + local.Minute()
in, endToday := inAwayWindow(dlMin, awayStartMin, awayEndMin)
if !in {
return raw
}
y, m, d := local.Date()
end := time.Date(y, m, d, awayEndMin/60, awayEndMin%60, 0, 0, loc)
if !endToday {
end = end.AddDate(0, 0, 1)
}
return end
}
// inAwayWindow reports whether the minute-of-day dlMin lies inside the window
// [start, end) (which may wrap past midnight) and whether the window's end falls
// on the same local day as dlMin.
func inAwayWindow(dlMin, start, end int) (in, endToday bool) {
if start < end {
inside := dlMin >= start && dlMin < end
return inside, inside // a non-wrapping window always ends the same day
}
// Wraps past midnight: [start, 1440) on the evening side, [0, end) on the
// morning side.
switch {
case dlMin >= start:
return true, false // evening: the window ends the next local day
case dlMin < end:
return true, true // morning: the window ends later today
default:
return false, false
}
}
// minutesOfDay returns a time-of-day value's minutes since midnight.
func minutesOfDay(t time.Time) int {
return t.Hour()*60 + t.Minute()
}
// loadLocation resolves an IANA timezone name, falling back to UTC when it is
// empty or unknown (so a bad profile value never breaks the sweeper).
func loadLocation(name string) *time.Location {
if name == "" {
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
}
// SweepTimeouts auto-resigns every active game whose current turn has exceeded
// its effective deadline as of now. It cheaply filters games past the raw
// deadline, then defers to timeoutGame, which confirms the away-window-adjusted
// deadline under the per-game lock. It returns the number of games timed out; a
// per-game failure is logged and skipped so one bad game does not stall the
// sweep.
func (svc *Service) SweepTimeouts(ctx context.Context, now time.Time) (int, error) {
games, err := svc.store.ActiveGames(ctx)
if err != nil {
return 0, err
}
var timedOut int
for _, ag := range games {
if now.Before(ag.turnStartedAt.Add(time.Duration(ag.turnTimeoutSecs) * time.Second)) {
continue // not even past the raw deadline
}
did, err := svc.timeoutGame(ctx, ag.gameID, now)
if err != nil {
svc.log.Warn("timeout sweep", zap.String("game", ag.gameID.String()), zap.Error(err))
continue
}
if did {
timedOut++
}
}
return timedOut, nil
}
// RunSweeper drives SweepTimeouts and evicts idle games from the cache on each
// tick until ctx is cancelled. It is started once from main.
func (svc *Service) RunSweeper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := svc.SweepTimeouts(ctx, svc.clock()); err != nil {
svc.log.Warn("timeout sweep failed", zap.Error(err))
} else if n > 0 {
svc.log.Info("timed out games", zap.Int("count", n))
}
svc.cache.sweep()
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package game
import (
"errors"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// Status values persisted in the games.status column.
const (
StatusActive = "active"
StatusFinished = "finished"
)
// ComplaintStatus values; Stage 9 owns the resolution lifecycle, Stage 3 only
// ever writes StatusComplaintOpen.
const StatusComplaintOpen = "open"
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
// transitions, so callers may match either taxonomy with errors.Is.
var (
// ErrNotFound is returned when no game matches the lookup.
ErrNotFound = errors.New("game: not found")
// ErrNotYourTurn is returned when an account acts out of turn.
ErrNotYourTurn = errors.New("game: not the player's turn")
// ErrFinished is returned when a transition is attempted on a finished game.
ErrFinished = errors.New("game: game is finished")
// ErrNotAPlayer is returned when an account is not seated in the game.
ErrNotAPlayer = errors.New("game: account is not a player in this game")
// ErrInvalidConfig is returned when CreateParams are not acceptable.
ErrInvalidConfig = errors.New("game: invalid game configuration")
// ErrHintsDisabled is returned when hints are switched off for the game.
ErrHintsDisabled = errors.New("game: hints are disabled for this game")
// ErrNoHintsLeft is returned when the player's allowance and wallet are spent.
ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
)
// AllowedTurnTimeouts is the set of per-game move clocks a game may be created
// with, matching the values offered at creation. DefaultTurnTimeout is used when
// none is requested.
var AllowedTurnTimeouts = []time.Duration{
5 * time.Minute, 10 * time.Minute, 15 * time.Minute, 30 * time.Minute,
1 * time.Hour, 2 * time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour,
}
// DefaultTurnTimeout is the move clock applied when CreateParams.TurnTimeout is
// zero (the owner's default: a full day).
const DefaultTurnTimeout = 24 * time.Hour
// CreateParams describes a new game. Seats lists the seated accounts in turn
// order (seat 0 moves first); lobby/matchmaking assembles it in a later stage.
type CreateParams struct {
Variant engine.Variant
Seats []uuid.UUID
TurnTimeout time.Duration // one of AllowedTurnTimeouts; zero → DefaultTurnTimeout
HintsAllowed bool
HintsPerPlayer int // starting per-seat hint allowance
Seed int64 // zero → a random seed is chosen
}
// Game is the persisted state of a match: the games row joined with its seats.
type Game struct {
ID uuid.UUID
Variant engine.Variant
DictVersion string
Status string
Players int
ToMove int
TurnStartedAt time.Time
TurnTimeout time.Duration
HintsAllowed bool
HintsPerPlayer int
MoveCount int
EndReason string // "" while active
Seats []Seat
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
}
// Seat is one player's standing in a game.
type Seat struct {
Seat int
AccountID uuid.UUID
Score int
HintsUsed int
IsWinner bool
}
// seatOf returns the seat index of accountID and true, or (0, false) when the
// account is not seated.
func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
for _, s := range g.Seats {
if s.AccountID == accountID {
return s.Seat, true
}
}
return 0, false
}
// MoveResult is the outcome of a committed transition: the decoded move and the
// post-move game.
type MoveResult struct {
Move engine.MoveRecord
Game Game
}
// HintResult is a revealed hint and the requesting player's remaining hint
// budget (per-seat allowance plus profile wallet) after spending one.
type HintResult struct {
Move engine.MoveRecord
HintsRemaining int
}
// EvalResult previews a tentative play without committing it.
type EvalResult struct {
Valid bool
Score int
Words []string
}
// StateView is a player's view of a game: the shared game plus their private
// rack and remaining hint budget. The board for live rendering is reconstructed
// by the client from History; it is added to the gateway surface in a later
// stage.
type StateView struct {
Game Game
Seat int
Rack []string
BagLen int
HintsRemaining int
}
// HistoryMove is one decoded journal row, independent of any dictionary.
type HistoryMove struct {
Seq int
Seat int
Action string
Score int
RunningTotal int
Dir string // play only: "H"/"V"
MainRow int // play only: main word's first-letter row
MainCol int // play only: main word's first-letter column
Tiles []engine.TileRecord
Words []string
Exchanged []string // exchange only
Rack []string // the acting player's rack before the move
}
// HistoryView is a game's full, dictionary-independent move history.
type HistoryView struct {
Game Game
Moves []HistoryMove
}
// Complaint is a word-check complaint awaiting admin review (Stage 9).
type Complaint struct {
ID uuid.UUID
ComplainantID uuid.UUID
GameID uuid.UUID
Variant engine.Variant
DictVersion string
Word string
WasValid bool
Note string
Status string
CreatedAt time.Time
}
+500
View File
@@ -0,0 +1,500 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"sync"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// newGameService builds a game service over the shared pool and registry.
func newGameService() *game.Service {
return game.NewService(
game.NewStore(testDB),
account.NewStore(testDB),
testRegistry,
game.Config{
DictDir: dictDir(),
DictVersion: testDictVersion,
TimeoutSweepInterval: time.Minute,
CacheTTL: time.Hour,
},
zap.NewNop(),
)
}
// provisionAccount creates a fresh durable account and returns its id.
func provisionAccount(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision account: %v", err)
}
return acc.ID
}
// openingSeed returns a seed whose fresh two-player English opening rack has a
// legal move, so a greedy mirror can drive a game.
func openingSeed(t *testing.T) int64 {
t.Helper()
for seed := int64(1); seed <= 200; seed++ {
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("engine new: %v", err)
}
if _, ok := g.HintView(); ok {
return seed
}
}
t.Fatal("no opening seed found")
return 0
}
// newMirror builds a parallel engine game with the same seed, used to compute
// legal moves to feed the service under test.
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
t.Helper()
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
if err != nil {
t.Fatalf("mirror new: %v", err)
}
return g
}
// readStats reads an account's statistics row.
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
t.Helper()
row := testDB.QueryRowContext(context.Background(),
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, 0, 0, 0, false
}
t.Fatalf("read stats: %v", err)
}
return wins, losses, draws, maxGame, maxWord, true
}
// TestGameLifecycleAndStats drives a greedy two-player game to its natural end
// through the service and checks the finish state and statistics.
func TestGameLifecycleAndStats(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if g.Status != game.StatusActive || g.Players != 2 {
t.Fatalf("unexpected new game: %+v", g)
}
mirror := newMirror(t, seed, 2)
var last game.MoveResult
for i := 0; i < 300 && !mirror.Over(); i++ {
cur := seats[mirror.ToMove()]
if hint, ok := mirror.HintView(); ok {
last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if _, err := mirror.SubmitPlay(hint.Dir, hint.Tiles); err != nil {
t.Fatalf("mirror play: %v", err)
}
} else {
last, err = svc.Pass(ctx, g.ID, cur)
if err != nil {
t.Fatalf("pass: %v", err)
}
if _, err := mirror.Pass(); err != nil {
t.Fatalf("mirror pass: %v", err)
}
}
}
if !mirror.Over() {
t.Fatal("greedy game did not finish")
}
if last.Game.Status != game.StatusFinished || last.Game.EndReason == "" {
t.Fatalf("final game not finished: %+v", last.Game)
}
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
if !ok0 || !ok1 {
t.Fatal("both players must have a stats row")
}
if mg0 <= 0 || mg1 <= 0 {
t.Errorf("max game points must be positive: %d, %d", mg0, mg1)
}
decisive := (w0 == 1 && l1 == 1) || (w1 == 1 && l0 == 1)
draw := d0 == 1 && d1 == 1 && w0 == 0 && w1 == 0
if !decisive && !draw {
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
}
}
// TestReplayEquivalence plays a few moves through one service, then proves a
// second service with a cold cache rebuilds the identical hidden state from the
// journal.
func TestReplayEquivalence(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
mirror := newMirror(t, seed, 2)
for i := 0; i < 6 && !mirror.Over(); i++ {
cur := seats[mirror.ToMove()]
if hint, ok := mirror.HintView(); ok {
if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles); err != nil {
t.Fatalf("submit: %v", err)
}
mirror.SubmitPlay(hint.Dir, hint.Tiles)
} else {
if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
t.Fatalf("pass: %v", err)
}
mirror.Pass()
}
}
warm, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil {
t.Fatalf("warm state: %v", err)
}
cold, err := newGameService().GameState(ctx, g.ID, seats[0]) // fresh cache → replay
if err != nil {
t.Fatalf("cold state: %v", err)
}
if warm.BagLen != cold.BagLen {
t.Errorf("bag len warm %d != cold %d", warm.BagLen, cold.BagLen)
}
if !equalStrings(warm.Rack, cold.Rack) {
t.Errorf("rack warm %v != replayed %v", warm.Rack, cold.Rack)
}
}
// TestResignWinnerAndStats checks that a resigner loses and keeps their score
// while the opponent wins, with statistics to match.
func TestResignWinnerAndStats(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
mirror := newMirror(t, seed, 2)
hint, ok := mirror.HintView()
if !ok {
t.Fatal("no opening move")
}
played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) // p0 scores
if err != nil {
t.Fatalf("p0 play: %v", err)
}
score0 := played.Game.Seats[0].Score
res, err := svc.Resign(ctx, g.ID, seats[1]) // p1 (trailing 0) resigns
if err != nil {
t.Fatalf("resign: %v", err)
}
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
t.Fatalf("after resign: %+v", res.Game)
}
if !res.Game.Seats[0].IsWinner || res.Game.Seats[1].IsWinner {
t.Errorf("winner flags wrong: %+v", res.Game.Seats)
}
if res.Game.Seats[0].Score != score0 {
t.Errorf("winner score changed on resign: %d -> %d", score0, res.Game.Seats[0].Score)
}
w0, l0, _, _, _, _ := readStats(t, seats[0])
w1, l1, _, _, _, _ := readStats(t, seats[1])
if w0 != 1 || l0 != 0 || w1 != 0 || l1 != 1 {
t.Errorf("resign stats wrong: p0(%d/%d) p1(%d/%d)", w0, l0, w1, l1)
}
}
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
func TestTimeoutSweep(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
// The sweep is global over the shared pool; assert the target game itself,
// not the count, since other tests leave active games behind.
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
}
st, err := svc.History(ctx, g.ID)
if err != nil {
t.Fatalf("history: %v", err)
}
if st.Game.Status != game.StatusFinished || st.Game.EndReason != "timeout" {
t.Fatalf("game not timed out: %+v", st.Game)
}
if !st.Game.Seats[1].IsWinner { // seat 0 was to move and timed out
t.Errorf("opponent should win on timeout: %+v", st.Game.Seats)
}
w1, _, _, _, _, _ := readStats(t, seats[1])
if w1 != 1 {
t.Errorf("opponent wins = %d, want 1", w1)
}
}
// TestTimeoutRespectsAwayWindow keeps a player who is asleep from being timed
// out until their away window ends.
func TestTimeoutRespectsAwayWindow(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
// Seat 0 (to move) sleeps the whole UTC day except a one-minute gap, so any
// deadline lands inside the window.
setAway(t, seats[0], "UTC", "00:00", "23:59")
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Deadline at 12:00 UTC, well inside the away window.
turnStart := time.Date(2026, 6, 2, 11, 0, 0, 0, time.UTC)
backdate(t, g.ID, turnStart)
// A sweep whose clock sits inside the away window must leave the target game
// active. (The sweep is global; assert the target, not the count.)
if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 2, 12, 30, 0, 0, time.UTC)); err != nil {
t.Fatalf("sweep inside away window: %v", err)
}
if status, _ := gameStatus(t, svc, g.ID); status != game.StatusActive {
t.Fatalf("target timed out inside its away window (status %q)", status)
}
// Once the clock passes the window's end, it must time out.
if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 3, 23, 59, 0, 0, time.UTC)); err != nil {
t.Fatalf("sweep after away window: %v", err)
}
if status, reason := gameStatus(t, svc, g.ID); status != game.StatusFinished || reason != "timeout" {
t.Fatalf("target not timed out after window: status %q reason %q", status, reason)
}
}
// gameStatus returns a game's status and end reason via the service.
func gameStatus(t *testing.T, svc *game.Service, id uuid.UUID) (status, endReason string) {
t.Helper()
h, err := svc.History(context.Background(), id)
if err != nil {
t.Fatalf("game status: %v", err)
}
return h.Game.Status, h.Game.EndReason
}
// TestHintPolicy exercises the per-game allowance, the profile wallet and the
// disabled switch.
func TestHintPolicy(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
t.Fatalf("first hint: %v", err)
}
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
}
setHintBalance(t, seats[0], 2)
res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet
if err != nil {
t.Fatalf("wallet hint: %v", err)
}
if res.HintsRemaining != 1 {
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
}
off, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
HintsAllowed: false, HintsPerPlayer: 1, Seed: seed,
})
if err != nil {
t.Fatalf("create off: %v", err)
}
if _, err := svc.Hint(ctx, off.ID, seats[0]); !errors.Is(err, game.ErrHintsDisabled) {
t.Fatalf("disabled hint = %v, want ErrHintsDisabled", err)
}
}
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
func TestCheckWordAndComplaint(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
if ok, err := svc.CheckWord(ctx, g.ID, "CAT"); err != nil || !ok {
t.Errorf("CheckWord cat = %v, %v; want true", ok, err)
}
if ok, err := svc.CheckWord(ctx, g.ID, "zzzzzz"); err != nil || ok {
t.Errorf("CheckWord zzzzzz = %v, %v; want false", ok, err)
}
c, err := svc.FileComplaint(ctx, g.ID, seats[0], "ZZZZZZ", "should be a word")
if err != nil {
t.Fatalf("file complaint: %v", err)
}
if c.ID == uuid.Nil || c.Word != "zzzzzz" || c.WasValid || c.Status != game.StatusComplaintOpen {
t.Errorf("unexpected complaint: %+v", c)
}
}
// TestEvaluatePlayPreview previews a legal and an illegal play without committing.
func TestEvaluatePlayPreview(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
hint, _ := newMirror(t, seed, 2).HintView()
eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("evaluate: %v", err)
}
if !eval.Valid || eval.Score <= 0 {
t.Errorf("legal preview = %+v, want valid with score", eval)
}
// The same play must still be available afterwards (no commit).
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("submit after evaluate: %v", err)
}
bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], engine.Horizontal, []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}})
if err != nil {
t.Fatalf("evaluate illegal: %v", err)
}
if bad.Valid {
t.Error("disconnected play must be invalid")
}
}
// TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two
// racing identical submissions win.
func TestConcurrentSubmitSerialized(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
hint, _ := newMirror(t, seed, 2).HintView()
var wg sync.WaitGroup
var mu sync.Mutex
var ok int
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err == nil {
mu.Lock()
ok++
mu.Unlock()
}
}()
}
wg.Wait()
if ok != 1 {
t.Errorf("successful submits = %d, want exactly 1", ok)
}
}
func backdate(t *testing.T, gameID uuid.UUID, at time.Time) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.games SET turn_started_at = $1 WHERE game_id = $2`, at, gameID); err != nil {
t.Fatalf("backdate: %v", err)
}
}
func setAway(t *testing.T, id uuid.UUID, tz, start, end string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET time_zone = $1, away_start = $2::time, away_end = $3::time WHERE account_id = $4`,
tz, start, end, id); err != nil {
t.Fatalf("set away: %v", err)
}
}
func setHintBalance(t *testing.T, id uuid.UUID, n int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance = $1 WHERE account_id = $2`, n, id); err != nil {
t.Fatalf("set hint balance: %v", err)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+27
View File
@@ -8,6 +8,8 @@ import (
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"
"time"
@@ -15,6 +17,7 @@ import (
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/postgres"
)
@@ -22,6 +25,13 @@ import (
// hydrated once by TestMain.
var testDB *sql.DB
// testRegistry holds the committed dictionaries the game service plays over,
// loaded once under testDictVersion (the version the game tests pin).
var testRegistry *engine.Registry
// testDictVersion is the single dictionary version the integration suite loads.
const testDictVersion = "v1"
const (
pgImage = "postgres:17-alpine"
pgDatabase = "scrabble_backend"
@@ -89,10 +99,27 @@ func runSuite(m *testing.M) (int, error) {
return 0, fmt.Errorf("apply migrations: %w", err)
}
reg, err := engine.Open(dictDir(), testDictVersion)
if err != nil {
return 0, fmt.Errorf("load dictionaries: %w", err)
}
defer func() { _ = reg.Close() }()
testDB = db
testRegistry = reg
return m.Run(), nil
}
// dictDir resolves the committed DAWG directory: BACKEND_DICT_DIR when set (CI),
// otherwise the sibling scrabble-solver checkout located relative to this file.
func dictDir() string {
if dir := os.Getenv("BACKEND_DICT_DIR"); dir != "" {
return dir
}
_, file, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "scrabble-solver", "dawg")
}
// withSearchPath rewrites dsn so every connection pins search_path to schema.
func withSearchPath(dsn, schema string) (string, error) {
u, err := url.Parse(dsn)
@@ -0,0 +1,23 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type AccountStats struct {
AccountID uuid.UUID `sql:"primary_key"`
Wins int32
Losses int32
Draws int32
MaxGamePoints int32
MaxWordPoints int32
UpdatedAt time.Time
}
@@ -21,4 +21,7 @@ type Accounts struct {
BlockFriendRequests bool
CreatedAt time.Time
UpdatedAt time.Time
AwayStart time.Time
AwayEnd time.Time
HintBalance int32
}
@@ -0,0 +1,26 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Complaints struct {
ComplaintID uuid.UUID `sql:"primary_key"`
ComplainantID uuid.UUID
GameID uuid.UUID
Variant string
DictVersion string
Word string
WasValid bool
Note string
Status string
CreatedAt time.Time
}
@@ -0,0 +1,25 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type GameMoves struct {
GameID uuid.UUID `sql:"primary_key"`
Seq int32 `sql:"primary_key"`
Seat int16
Action string
Score int32
RunningTotal int32
ExchangedCount int16
Payload string
CreatedAt time.Time
}
@@ -0,0 +1,21 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
)
type GamePlayers struct {
GameID uuid.UUID `sql:"primary_key"`
Seat int16 `sql:"primary_key"`
AccountID uuid.UUID
Score int32
HintsUsed int16
IsWinner bool
}
@@ -0,0 +1,32 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Games struct {
GameID uuid.UUID `sql:"primary_key"`
Variant string
DictVersion string
Seed int64
Status string
Players int16
ToMove int16
TurnStartedAt time.Time
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
MoveCount int32
EndReason *string
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
}
@@ -0,0 +1,96 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var AccountStats = newAccountStatsTable("backend", "account_stats", "")
type accountStatsTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Wins postgres.ColumnInteger
Losses postgres.ColumnInteger
Draws postgres.ColumnInteger
MaxGamePoints postgres.ColumnInteger
MaxWordPoints postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountStatsTable struct {
accountStatsTable
EXCLUDED accountStatsTable
}
// AS creates new AccountStatsTable with assigned alias
func (a AccountStatsTable) AS(alias string) *AccountStatsTable {
return newAccountStatsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountStatsTable with assigned schema name
func (a AccountStatsTable) FromSchema(schemaName string) *AccountStatsTable {
return newAccountStatsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountStatsTable with assigned table prefix
func (a AccountStatsTable) WithPrefix(prefix string) *AccountStatsTable {
return newAccountStatsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountStatsTable with assigned table suffix
func (a AccountStatsTable) WithSuffix(suffix string) *AccountStatsTable {
return newAccountStatsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountStatsTable(schemaName, tableName, alias string) *AccountStatsTable {
return &AccountStatsTable{
accountStatsTable: newAccountStatsTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountStatsTableImpl("", "excluded", ""),
}
}
func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
WinsColumn = postgres.IntegerColumn("wins")
LossesColumn = postgres.IntegerColumn("losses")
DrawsColumn = postgres.IntegerColumn("draws")
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
)
return accountStatsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Wins: WinsColumn,
Losses: LossesColumn,
Draws: DrawsColumn,
MaxGamePoints: MaxGamePointsColumn,
MaxWordPoints: MaxWordPointsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -25,6 +25,9 @@ type accountsTable struct {
BlockFriendRequests postgres.ColumnBool
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AwayStart postgres.ColumnTime
AwayEnd postgres.ColumnTime
HintBalance postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -74,9 +77,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
AwayStartColumn = postgres.TimeColumn("away_start")
AwayEndColumn = postgres.TimeColumn("away_end")
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
)
return accountsTable{
@@ -91,6 +97,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
BlockFriendRequests: BlockFriendRequestsColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AwayStart: AwayStartColumn,
AwayEnd: AwayEndColumn,
HintBalance: HintBalanceColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,105 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Complaints = newComplaintsTable("backend", "complaints", "")
type complaintsTable struct {
postgres.Table
// Columns
ComplaintID postgres.ColumnString
ComplainantID postgres.ColumnString
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Word postgres.ColumnString
WasValid postgres.ColumnBool
Note postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ComplaintsTable struct {
complaintsTable
EXCLUDED complaintsTable
}
// AS creates new ComplaintsTable with assigned alias
func (a ComplaintsTable) AS(alias string) *ComplaintsTable {
return newComplaintsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ComplaintsTable with assigned schema name
func (a ComplaintsTable) FromSchema(schemaName string) *ComplaintsTable {
return newComplaintsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ComplaintsTable with assigned table prefix
func (a ComplaintsTable) WithPrefix(prefix string) *ComplaintsTable {
return newComplaintsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ComplaintsTable with assigned table suffix
func (a ComplaintsTable) WithSuffix(suffix string) *ComplaintsTable {
return newComplaintsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newComplaintsTable(schemaName, tableName, alias string) *ComplaintsTable {
return &ComplaintsTable{
complaintsTable: newComplaintsTableImpl(schemaName, tableName, alias),
EXCLUDED: newComplaintsTableImpl("", "excluded", ""),
}
}
func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable {
var (
ComplaintIDColumn = postgres.StringColumn("complaint_id")
ComplainantIDColumn = postgres.StringColumn("complainant_id")
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
WordColumn = postgres.StringColumn("word")
WasValidColumn = postgres.BoolColumn("was_valid")
NoteColumn = postgres.StringColumn("note")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn}
)
return complaintsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ComplaintID: ComplaintIDColumn,
ComplainantID: ComplainantIDColumn,
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Word: WordColumn,
WasValid: WasValidColumn,
Note: NoteColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,102 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GameMoves = newGameMovesTable("backend", "game_moves", "")
type gameMovesTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Seq postgres.ColumnInteger
Seat postgres.ColumnInteger
Action postgres.ColumnString
Score postgres.ColumnInteger
RunningTotal postgres.ColumnInteger
ExchangedCount postgres.ColumnInteger
Payload postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameMovesTable struct {
gameMovesTable
EXCLUDED gameMovesTable
}
// AS creates new GameMovesTable with assigned alias
func (a GameMovesTable) AS(alias string) *GameMovesTable {
return newGameMovesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameMovesTable with assigned schema name
func (a GameMovesTable) FromSchema(schemaName string) *GameMovesTable {
return newGameMovesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameMovesTable with assigned table prefix
func (a GameMovesTable) WithPrefix(prefix string) *GameMovesTable {
return newGameMovesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameMovesTable with assigned table suffix
func (a GameMovesTable) WithSuffix(suffix string) *GameMovesTable {
return newGameMovesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameMovesTable(schemaName, tableName, alias string) *GameMovesTable {
return &GameMovesTable{
gameMovesTable: newGameMovesTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameMovesTableImpl("", "excluded", ""),
}
}
func newGameMovesTableImpl(schemaName, tableName, alias string) gameMovesTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
SeqColumn = postgres.IntegerColumn("seq")
SeatColumn = postgres.IntegerColumn("seat")
ActionColumn = postgres.StringColumn("action")
ScoreColumn = postgres.IntegerColumn("score")
RunningTotalColumn = postgres.IntegerColumn("running_total")
ExchangedCountColumn = postgres.IntegerColumn("exchanged_count")
PayloadColumn = postgres.StringColumn("payload")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{GameIDColumn, SeqColumn, SeatColumn, ActionColumn, ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{SeatColumn, ActionColumn, ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
)
return gameMovesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Seq: SeqColumn,
Seat: SeatColumn,
Action: ActionColumn,
Score: ScoreColumn,
RunningTotal: RunningTotalColumn,
ExchangedCount: ExchangedCountColumn,
Payload: PayloadColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,93 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GamePlayers = newGamePlayersTable("backend", "game_players", "")
type gamePlayersTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Seat postgres.ColumnInteger
AccountID postgres.ColumnString
Score postgres.ColumnInteger
HintsUsed postgres.ColumnInteger
IsWinner postgres.ColumnBool
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GamePlayersTable struct {
gamePlayersTable
EXCLUDED gamePlayersTable
}
// AS creates new GamePlayersTable with assigned alias
func (a GamePlayersTable) AS(alias string) *GamePlayersTable {
return newGamePlayersTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GamePlayersTable with assigned schema name
func (a GamePlayersTable) FromSchema(schemaName string) *GamePlayersTable {
return newGamePlayersTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GamePlayersTable with assigned table prefix
func (a GamePlayersTable) WithPrefix(prefix string) *GamePlayersTable {
return newGamePlayersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GamePlayersTable with assigned table suffix
func (a GamePlayersTable) WithSuffix(suffix string) *GamePlayersTable {
return newGamePlayersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGamePlayersTable(schemaName, tableName, alias string) *GamePlayersTable {
return &GamePlayersTable{
gamePlayersTable: newGamePlayersTableImpl(schemaName, tableName, alias),
EXCLUDED: newGamePlayersTableImpl("", "excluded", ""),
}
}
func newGamePlayersTableImpl(schemaName, tableName, alias string) gamePlayersTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
SeatColumn = postgres.IntegerColumn("seat")
AccountIDColumn = postgres.StringColumn("account_id")
ScoreColumn = postgres.IntegerColumn("score")
HintsUsedColumn = postgres.IntegerColumn("hints_used")
IsWinnerColumn = postgres.BoolColumn("is_winner")
allColumns = postgres.ColumnList{GameIDColumn, SeatColumn, AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn}
defaultColumns = postgres.ColumnList{ScoreColumn, HintsUsedColumn, IsWinnerColumn}
)
return gamePlayersTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Seat: SeatColumn,
AccountID: AccountIDColumn,
Score: ScoreColumn,
HintsUsed: HintsUsedColumn,
IsWinner: IsWinnerColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,123 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Games = newGamesTable("backend", "games", "")
type gamesTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Seed postgres.ColumnInteger
Status postgres.ColumnString
Players postgres.ColumnInteger
ToMove postgres.ColumnInteger
TurnStartedAt postgres.ColumnTimestampz
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
MoveCount postgres.ColumnInteger
EndReason postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GamesTable struct {
gamesTable
EXCLUDED gamesTable
}
// AS creates new GamesTable with assigned alias
func (a GamesTable) AS(alias string) *GamesTable {
return newGamesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GamesTable with assigned schema name
func (a GamesTable) FromSchema(schemaName string) *GamesTable {
return newGamesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GamesTable with assigned table prefix
func (a GamesTable) WithPrefix(prefix string) *GamesTable {
return newGamesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GamesTable with assigned table suffix
func (a GamesTable) WithSuffix(suffix string) *GamesTable {
return newGamesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGamesTable(schemaName, tableName, alias string) *GamesTable {
return &GamesTable{
gamesTable: newGamesTableImpl(schemaName, tableName, alias),
EXCLUDED: newGamesTableImpl("", "excluded", ""),
}
}
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
SeedColumn = postgres.IntegerColumn("seed")
StatusColumn = postgres.StringColumn("status")
PlayersColumn = postgres.IntegerColumn("players")
ToMoveColumn = postgres.IntegerColumn("to_move")
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
MoveCountColumn = postgres.IntegerColumn("move_count")
EndReasonColumn = postgres.StringColumn("end_reason")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn}
)
return gamesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Seed: SeedColumn,
Status: StatusColumn,
Players: PlayersColumn,
ToMove: ToMoveColumn,
TurnStartedAt: TurnStartedAtColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
MoveCount: MoveCountColumn,
EndReason: EndReasonColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -10,7 +10,12 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
AccountStats = AccountStats.FromSchema(schema)
Accounts = Accounts.FromSchema(schema)
Complaints = Complaints.FromSchema(schema)
GameMoves = GameMoves.FromSchema(schema)
GamePlayers = GamePlayers.FromSchema(schema)
Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
}
@@ -0,0 +1,133 @@
-- +goose Up
-- Stage 3 game domain: the per-game lifecycle, the dictionary-independent move
-- journal, word-check complaints and per-account statistics, plus two account
-- columns the game domain needs.
SET search_path = backend, pg_catalog;
-- Extend accounts with the per-user away window (one interval per day, in the
-- account's local time_zone) honoured by the turn-timeout sweeper -- and, in a
-- later stage, by the robot's sleep -- and a hint wallet (purchasable hints; the
-- purchase flow lands later, so the balance defaults to empty). Profile editing
-- of the away window arrives with the profile surface (Stage 4).
ALTER TABLE accounts
ADD COLUMN away_start time NOT NULL DEFAULT '00:00',
ADD COLUMN away_end time NOT NULL DEFAULT '07:00',
ADD COLUMN hint_balance integer NOT NULL DEFAULT 0,
ADD CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0);
-- One match. The live position is event-sourced: this row carries the pinned
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs,
-- while game_moves is the append-only journal the in-memory engine.Game is
-- replayed from (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move
-- clock; its allowed values are enforced in Go. variant uses engine.Variant's
-- stable labels.
CREATE TABLE games (
game_id uuid PRIMARY KEY,
variant text NOT NULL,
dict_version text NOT NULL,
seed bigint NOT NULL,
status text NOT NULL DEFAULT 'active',
players smallint NOT NULL,
to_move smallint NOT NULL DEFAULT 0,
turn_started_at timestamptz NOT NULL DEFAULT now(),
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
move_count integer NOT NULL DEFAULT 0,
end_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
CONSTRAINT games_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
)
);
-- The sweeper scans active games oldest-turn-first; a partial index keeps it
-- off the finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
-- durable account (guests and robots are revisited when they arrive). score is
-- the running/final score, is_winner is stamped on finish (false for every seat
-- on a draw), hints_used counts the per-game allowance consumed before the
-- profile wallet.
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (account_id),
score integer NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
is_winner boolean NOT NULL DEFAULT false,
PRIMARY KEY (game_id, seat),
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
);
CREATE INDEX game_players_account_idx ON game_players (account_id);
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md
-- §9.1). seq orders the moves from 0. payload holds the decoded values needed to
-- both replay the game through the engine and emit GCG without a dictionary: the
-- acting rack, and for a play its direction, placed tiles and formed words; for
-- an exchange the swapped tiles. score / running_total / exchanged_count are
-- lifted out for cheap history rendering.
CREATE TABLE game_moves (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seq integer NOT NULL,
seat smallint NOT NULL,
action text NOT NULL,
score integer NOT NULL DEFAULT 0,
running_total integer NOT NULL DEFAULT 0,
exchanged_count smallint NOT NULL DEFAULT 0,
payload text NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, seq),
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
);
-- Word-check complaints captured in the context of a game's pinned dictionary.
-- The admin review queue and the resolution lifecycle land in Stage 9, which
-- owns the status state machine; Stage 3 only ever writes 'open'.
CREATE TABLE complaints (
complaint_id uuid PRIMARY KEY,
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
variant text NOT NULL,
dict_version text NOT NULL,
word text NOT NULL,
was_valid boolean NOT NULL,
note text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'open',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX complaints_status_idx ON complaints (status);
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
-- Guests have no durable account and never appear here. A draw increments draws
-- only (neither wins nor losses). max_word_points is the best single move score
-- (which already folds in every word the move formed and the all-tiles bonus).
CREATE TABLE account_stats (
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
wins integer NOT NULL DEFAULT 0,
losses integer NOT NULL DEFAULT 0,
draws integer NOT NULL DEFAULT 0,
max_game_points integer NOT NULL DEFAULT 0,
max_word_points integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE account_stats;
DROP TABLE complaints;
DROP TABLE game_moves;
DROP TABLE game_players;
DROP TABLE games;
ALTER TABLE accounts
DROP CONSTRAINT accounts_hint_balance_chk,
DROP COLUMN hint_balance,
DROP COLUMN away_end,
DROP COLUMN away_start;
+76 -30
View File
@@ -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:0007:00, midnight-cross aware) — so a player is never
timed out while asleep.
- **Players**: auto-match is always 2 players; friend games are 24 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
View File
@@ -28,11 +28,18 @@ a `(variant, language)` pool; after 10 s with no human, the robot substitutes.
Friend games (24) 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
View File
@@ -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
View File
@@ -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