From 751e74b14f39e5047d3945b9ae6d254bd6dffd33 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 2 Jun 2026 17:33:49 +0200 Subject: [PATCH] Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats) internal/game drives the engine over a single match and owns everything the engine does not: event-sourced persistence (a games row + an append-only decoded move journal, with the live engine.Game kept warm in a cache and rebuilt by replay on a miss), the play/pass/exchange/resign transitions with validate-at-submit scoring, an unlimited score/legality preview, the hint (per-game allowance + profile wallet), the word-check tool with complaint capture, per-player game state, history and GCG export (Poslfit dialect), and a background turn-timeout sweeper that auto-resigns overdue turns honouring each player's daily away window. Like Stages 1-2 it is a service/store layer with no HTTP; the gateway surface lands in Stage 6. Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/ EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup, ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix so the resigner keeps their score yet never wins (the other player wins a two-player game). Timeout reuses Resign. Persistence: migration 00002 adds games, game_players, game_moves, complaints, account_stats and extends accounts with away_start/away_end/hint_balance; go-jet regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required), BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL; main loads the registry at boot (hard dependency) and starts the sweeper. Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window boundaries, hint budget, cache, keyed mutex, payload); inttest integration (lifecycle, replay equivalence, timeout sweep with away grace, resign stats, hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE, FUNCTIONAL +_ru, TESTING, README) updated. --- PLAN.md | 54 +- backend/README.md | 40 +- backend/cmd/backend/main.go | 28 +- backend/internal/account/account.go | 34 +- backend/internal/config/config.go | 17 + backend/internal/config/config_test.go | 40 ++ backend/internal/engine/decode.go | 44 +- backend/internal/engine/domain.go | 157 +++++ backend/internal/engine/domain_test.go | 180 +++++ backend/internal/engine/engine.go | 12 + backend/internal/engine/game.go | 59 +- backend/internal/engine/registry.go | 23 + backend/internal/engine/resign_test.go | 81 +++ backend/internal/game/away_test.go | 78 +++ backend/internal/game/cache.go | 121 ++++ backend/internal/game/config.go | 54 ++ backend/internal/game/doc.go | 20 + backend/internal/game/gcg.go | 119 ++++ backend/internal/game/gcg_test.go | 79 +++ backend/internal/game/helpers_test.go | 139 ++++ backend/internal/game/payload.go | 89 +++ backend/internal/game/service.go | 629 ++++++++++++++++++ backend/internal/game/store.go | 439 ++++++++++++ backend/internal/game/timeout.go | 119 ++++ backend/internal/game/types.go | 174 +++++ backend/internal/inttest/game_test.go | 500 ++++++++++++++ backend/internal/inttest/main_test.go | 27 + .../jet/backend/model/account_stats.go | 23 + .../postgres/jet/backend/model/accounts.go | 3 + .../postgres/jet/backend/model/complaints.go | 26 + .../postgres/jet/backend/model/game_moves.go | 25 + .../jet/backend/model/game_players.go | 21 + .../postgres/jet/backend/model/games.go | 32 + .../jet/backend/table/account_stats.go | 96 +++ .../postgres/jet/backend/table/accounts.go | 15 +- .../postgres/jet/backend/table/complaints.go | 105 +++ .../postgres/jet/backend/table/game_moves.go | 102 +++ .../jet/backend/table/game_players.go | 93 +++ .../postgres/jet/backend/table/games.go | 123 ++++ .../jet/backend/table/table_use_schema.go | 5 + .../postgres/migrations/00002_game.sql | 133 ++++ docs/ARCHITECTURE.md | 106 ++- docs/FUNCTIONAL.md | 21 +- docs/FUNCTIONAL_ru.md | 22 +- docs/TESTING.md | 16 +- 45 files changed, 4220 insertions(+), 103 deletions(-) create mode 100644 backend/internal/engine/domain.go create mode 100644 backend/internal/engine/domain_test.go create mode 100644 backend/internal/engine/resign_test.go create mode 100644 backend/internal/game/away_test.go create mode 100644 backend/internal/game/cache.go create mode 100644 backend/internal/game/config.go create mode 100644 backend/internal/game/doc.go create mode 100644 backend/internal/game/gcg.go create mode 100644 backend/internal/game/gcg_test.go create mode 100644 backend/internal/game/helpers_test.go create mode 100644 backend/internal/game/payload.go create mode 100644 backend/internal/game/service.go create mode 100644 backend/internal/game/store.go create mode 100644 backend/internal/game/timeout.go create mode 100644 backend/internal/game/types.go create mode 100644 backend/internal/inttest/game_test.go create mode 100644 backend/internal/postgres/jet/backend/model/account_stats.go create mode 100644 backend/internal/postgres/jet/backend/model/complaints.go create mode 100644 backend/internal/postgres/jet/backend/model/game_moves.go create mode 100644 backend/internal/postgres/jet/backend/model/game_players.go create mode 100644 backend/internal/postgres/jet/backend/model/games.go create mode 100644 backend/internal/postgres/jet/backend/table/account_stats.go create mode 100644 backend/internal/postgres/jet/backend/table/complaints.go create mode 100644 backend/internal/postgres/jet/backend/table/game_moves.go create mode 100644 backend/internal/postgres/jet/backend/table/game_players.go create mode 100644 backend/internal/postgres/jet/backend/table/games.go create mode 100644 backend/internal/postgres/migrations/00002_game.sql diff --git a/PLAN.md b/PLAN.md index 778ccdb..2e80fc4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 3–4, so a "light touch" here would have duplicated or pre-empted them. +- **Stage 3** (interview + implementation): + - Scope, as in Stages 1–2: **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:00–07: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) diff --git a/backend/README.md b/backend/README.md index 3ff4ab7..46abaae 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 1–2 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 diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index e4301af..94f7340 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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, diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 26e7444..e18ba05 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -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, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 50eb331..ed23bfe 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 4caa6ff..c568e1b 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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) diff --git a/backend/internal/engine/decode.go b/backend/internal/engine/decode.go index c2dadc0..fa1fd71 100644 --- a/backend/internal/engine/decode.go +++ b/backend/internal/engine/decode.go @@ -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, } } diff --git a/backend/internal/engine/domain.go b/backend/internal/engine/domain.go new file mode 100644 index 0000000..7312d34 --- /dev/null +++ b/backend/internal/engine/domain.go @@ -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 +} diff --git a/backend/internal/engine/domain_test.go b/backend/internal/engine/domain_test.go new file mode 100644 index 0000000..bafe552 --- /dev/null +++ b/backend/internal/engine/domain_test.go @@ -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 +} diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go index 49d75bf..65ce22f 100644 --- a/backend/internal/engine/engine.go +++ b/backend/internal/engine/engine.go @@ -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. diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index 3970d9e..78609a7 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -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 diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go index a125c7b..c195d4f 100644 --- a/backend/internal/engine/registry.go +++ b/backend/internal/engine/registry.go @@ -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. diff --git a/backend/internal/engine/resign_test.go b/backend/internal/engine/resign_test.go new file mode 100644 index 0000000..a89f2df --- /dev/null +++ b/backend/internal/engine/resign_test.go @@ -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") + } +} diff --git a/backend/internal/game/away_test.go b/backend/internal/game/away_test.go new file mode 100644 index 0000000..1762efb --- /dev/null +++ b/backend/internal/game/away_test.go @@ -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") + } +} diff --git a/backend/internal/game/cache.go b/backend/internal/game/cache.go new file mode 100644 index 0000000..5056069 --- /dev/null +++ b/backend/internal/game/cache.go @@ -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) +} diff --git a/backend/internal/game/config.go b/backend/internal/game/config.go new file mode 100644 index 0000000..8d640be --- /dev/null +++ b/backend/internal/game/config.go @@ -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 +} diff --git a/backend/internal/game/doc.go b/backend/internal/game/doc.go new file mode 100644 index 0000000..c5cf0f5 --- /dev/null +++ b/backend/internal/game/doc.go @@ -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 diff --git a/backend/internal/game/gcg.go b/backend/internal/game/gcg.go new file mode 100644 index 0000000..20bbe09 --- /dev/null +++ b/backend/internal/game/gcg.go @@ -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 +} diff --git a/backend/internal/game/gcg_test.go b/backend/internal/game/gcg_test.go new file mode 100644 index 0000000..f00cdd2 --- /dev/null +++ b/backend/internal/game/gcg_test.go @@ -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) + } +} diff --git a/backend/internal/game/helpers_test.go b/backend/internal/game/helpers_test.go new file mode 100644 index 0000000..c5e7f94 --- /dev/null +++ b/backend/internal/game/helpers_test.go @@ -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) + } +} diff --git a/backend/internal/game/payload.go b/backend/internal/game/payload.go new file mode 100644 index 0000000..e23f3ba --- /dev/null +++ b/backend/internal/game/payload.go @@ -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 +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go new file mode 100644 index 0000000..891fd27 --- /dev/null +++ b/backend/internal/game/service.go @@ -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 (2–4), 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[:])) +} diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go new file mode 100644 index 0000000..49919f6 --- /dev/null +++ b/backend/internal/game/store.go @@ -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 +} diff --git a/backend/internal/game/timeout.go b/backend/internal/game/timeout.go new file mode 100644 index 0000000..9c74c1d --- /dev/null +++ b/backend/internal/game/timeout.go @@ -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() + } + } +} diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go new file mode 100644 index 0000000..86b7000 --- /dev/null +++ b/backend/internal/game/types.go @@ -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 +} diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go new file mode 100644 index 0000000..0f9a22a --- /dev/null +++ b/backend/internal/inttest/game_test.go @@ -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 +} diff --git a/backend/internal/inttest/main_test.go b/backend/internal/inttest/main_test.go index b4b1da2..398ab3f 100644 --- a/backend/internal/inttest/main_test.go +++ b/backend/internal/inttest/main_test.go @@ -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) diff --git a/backend/internal/postgres/jet/backend/model/account_stats.go b/backend/internal/postgres/jet/backend/model/account_stats.go new file mode 100644 index 0000000..5199497 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/account_stats.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index ba897b7..a75ea1f 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -21,4 +21,7 @@ type Accounts struct { BlockFriendRequests bool CreatedAt time.Time UpdatedAt time.Time + AwayStart time.Time + AwayEnd time.Time + HintBalance int32 } diff --git a/backend/internal/postgres/jet/backend/model/complaints.go b/backend/internal/postgres/jet/backend/model/complaints.go new file mode 100644 index 0000000..53c5e56 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/complaints.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/game_moves.go b/backend/internal/postgres/jet/backend/model/game_moves.go new file mode 100644 index 0000000..5d30654 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/game_moves.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/game_players.go b/backend/internal/postgres/jet/backend/model/game_players.go new file mode 100644 index 0000000..0135af9 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/game_players.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go new file mode 100644 index 0000000..d578fca --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/games.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/table/account_stats.go b/backend/internal/postgres/jet/backend/table/account_stats.go new file mode 100644 index 0000000..04fae84 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/account_stats.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index 3c1a1a2..55e3784 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -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, diff --git a/backend/internal/postgres/jet/backend/table/complaints.go b/backend/internal/postgres/jet/backend/table/complaints.go new file mode 100644 index 0000000..4ac0588 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/complaints.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/game_moves.go b/backend/internal/postgres/jet/backend/table/game_moves.go new file mode 100644 index 0000000..8bca7da --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/game_moves.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/game_players.go b/backend/internal/postgres/jet/backend/table/game_players.go new file mode 100644 index 0000000..1f720b7 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/game_players.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go new file mode 100644 index 0000000..ab90ff5 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/games.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index e6aee6d..6ab0813 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -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) } diff --git a/backend/internal/postgres/migrations/00002_game.sql b/backend/internal/postgres/migrations/00002_game.sql new file mode 100644 index 0000000..42c41fb --- /dev/null +++ b/backend/internal/postgres/migrations/00002_game.sql @@ -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; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 02e4f07..cd595d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,10 +111,12 @@ Key points: `Erudit()`). Эрудит's specifics (non-doubling centre, `ё` with no tiles, 3 blanks, a 15-point bonus) live entirely in the solver ruleset, so the engine treats every variant uniformly. -- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from a directory - (a parameter today; a configurable `BACKEND_DICT_DIR` is wired when the first - consumer needs it). The `engine.Registry` holds them in memory addressed by - `(variant, dict_version)`, tracking the latest version per variant. +- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from the + directory `BACKEND_DICT_DIR`; `backend` loads the `engine.Registry` at startup + as a hard dependency (like migrations), so a missing dictionary fails the boot. + The registry holds dictionaries in memory addressed by `(variant, + dict_version)`, tracking the latest version per variant, and answers the + word-check tool through `Registry.Lookup`. - **Dictionary versioning — pin per game.** A game records the `dict_version` it started on and finishes on that version; new games use the latest. Multiple versions may be resident at once. An admin reload *(planned, Stage 9)* @@ -130,10 +132,18 @@ Key points: - **`engine.Game`** is the in-memory match state and the pure rules engine: it deals racks, applies legal plays / passes / exchanges / resignations, refills from the bag, keeps the scores and whose turn it is, and **detects the end of - the game** — empty bag with an empty rack, six consecutive scoreless turns, or - a resignation — applying the end-game rack-value adjustment. The 24-hour - timeout / auto-resign, turn scheduling and persistence belong to the game - domain *(Stage 3)*. + the game** — empty bag with an empty rack, or six consecutive scoreless turns, + applying the end-game rack-value adjustment, or a resignation. On a + **resignation the resigner keeps their accumulated score (no rack adjustment) + and never wins**: the win goes to the highest score among the remaining seats, + unconditionally the other player in a two-player game. The engine exposes a + decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/ + `HintView`/`Hand`) so `internal/game` drives it without importing the solver. +- The **game domain** (`internal/game`) owns everything the engine does not — + persistence, turn scheduling, the configurable turn timeout / auto-resign, the + hint budget, word-check complaints, history and GCG — and is the engine's only + consumer. Timeout auto-resign reuses `engine.Resign`, recording the move as a + timeout, so it inherits the resignation win/loss. - History is dictionary-independent (§9.1): the engine emits decoded `MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard` (alphabet only, no dictionary). @@ -143,13 +153,29 @@ Key points: - **Word legality: validate-at-submit.** An illegal play is rejected by `Solver.ValidatePlay`; there is no challenge phase. - **End of game**: the bag is empty **and** a player empties their rack, **or** - **6 consecutive scoreless turns** (passes/exchanges). A move that is not made - within the **24-hour** turn timeout becomes an automatic resignation. + **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or + a missed turn. The **per-game turn timeout** is chosen at creation + (5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h); a turn not made within it + becomes an automatic resignation, applied by a background sweeper. The sweeper + honours each player's **away window** — a daily local-time sleep interval on the + account (default 00:00–07:00, midnight-cross aware) — so a player is never + timed out while asleep. - **Players**: auto-match is always 2 players; friend games are 2–4 players. - `backend` owns turn order and the bag for any player count. -- **Hint**: one per game; reveals the top-1 ranked move (`GenerateMoves[0]`). -- **Word-check tool**: unlimited dictionary lookups; each result offers a - **complaint** that lands in an admin review queue *(admin side planned)*. + `backend` owns turn order and the bag for any player count. A resignation or + timeout in a two-player game ends it with the other player winning; **richer + multi-player drop-out (a leaver's seat skipped while the rest play on, with a + per-game disposition of their tiles) is deferred to Stage 4**, when friend games + are formed. +- **Hint**: governed by two per-game settings — whether hints are allowed and the + starting per-player allowance — plus a per-account hint **wallet** + (`hint_balance`, spent after the allowance; top-ups are a later feature). A hint + reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller + picks the per-game defaults (e.g. one in casual random games, none in + tournaments). +- **Word-check tool**: unlimited dictionary lookups against the game's pinned + dictionary; each result offers a **complaint** (complainant, game, variant, + dict_version, word, the disputed result, an optional note) that lands in an + admin review queue *(admin side planned, Stage 9)*. ## 7. Robot opponent @@ -191,26 +217,46 @@ within 10 seconds. Designed to be indistinguishable from a person. into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`). Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary keys are application-generated **UUIDv7**. -- Stage 1 tables: `accounts` (durable internal accounts), `identities` - (platform/email identities, unique `(kind, external_id)`) and `sessions` - (revoke-only opaque-token hashes). -- **Active game state** is stored structurally with the `dict_version` pinned. -- **Statistics** (computed on finish): wins, losses, max points in a game, max - points for a single word. +- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window + columns `away_start`/`away_end` and the hint wallet `hint_balance`), + `identities` (platform/email identities, unique `(kind, external_id)`), + `sessions` (revoke-only opaque-token hashes), and the Stage 3 game tables + `games`, `game_players`, `game_moves` (the move journal), `complaints` and + `account_stats`. +- **Active games are event-sourced.** A game is a `games` row (pinned + `variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised + turn cursor) plus an append-only, decoded move journal (`game_moves`); the live + position is an `engine.Game` held in an in-memory cache (≈24 h idle TTL) and + rebuilt by replaying the journal on a miss, which the seeded bag makes exact. + Each game is serialised by a per-game lock; a persistence failure evicts the + live game so the next access rebuilds from the journal. `game_players` records + each seat's account, running score, hints used and winner flag. +- **Statistics** (`account_stats`, recomputed on each finish, durable accounts + only — guests never appear): wins, losses, **draws**, max points in a game, and + max points for a single **move** (which already folds in every word the move + formed plus the all-tiles bonus). A tie increments draws only; a resignation or + timeout is a loss for the acting player. ### 9.1 History invariant (must hold forever) Archived games must replay **independently of any dictionary and of the -solver's internal encoding** — at least visually. Therefore the move log -persists only **decoded concrete values**: letters as text, coordinates, blank -flag, action kind (play / pass / exchange / resign / timeout), acting player, -per-move score and running total, timestamp. The board for visual replay is -reconstructed by applying placements onto an empty grid; no dictionary is -needed because moves were validated at play time and scores are stored. -`variant` and `dict_version` are kept as **metadata only** (audit, complaint -review), never as a replay dependency. **GCG export** is derived from the same -rows and is likewise self-contained (we ship our own writer; the solver exposes -no public GCG writer). +solver's internal encoding** — at least visually. Therefore the move journal +persists only **decoded concrete values**: action kind (play / pass / exchange / +resign / timeout), acting player, per-move score and running total, timestamp, +and — in a per-move JSON payload — the acting player's rack before the move (with +`?` for a blank), and for a play its direction, main-word anchor, placed tiles +(letter as text, coordinate, blank flag) and the words formed; for an exchange, +the swapped tiles. This is exactly what is needed both to **replay the game +through the engine** (a cache miss) and to render history or emit GCG **without a +dictionary**: the board for visual replay is reconstructed by applying placements +onto an empty grid, since moves were validated at play time and scores are +stored. `variant` and `dict_version` are kept as **metadata only** (audit, +complaint review), never as a replay dependency. **GCG export** is derived from +the same rows and is likewise self-contained — we ship our own writer (the solver +exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` +pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` +exchanges), plus `#note` lines for resignations and timeouts, which the standard +does not cover. ## 10. Notifications diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 96703e4..57d98ae 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -28,11 +28,18 @@ a `(variant, language)` pool; after 10 s with no human, the robot substitutes. Friend games (2–4) are formed by friend list, internal ID, or deep-link. ### Playing a game *(Stage 3)* -Place tiles, pass, exchange, or resign. A play is validated against the -dictionary at submit time and scored. One hint per game reveals the best move. -The dictionary check tool is unlimited and offers a complaint. The game ends -when the bag empties and a player clears their rack, after 6 consecutive -scoreless turns, or by the 24-hour move timeout (auto-resign). +Place tiles, pass, exchange, or resign. A play is validated against the game's +dictionary at submit time and scored; an unlimited preview reports what a +tentative move would score and whether it is legal. The dictionary check tool is +unlimited and offers a complaint on any result. Hints are governed per game — +whether they are allowed and how many each player starts with — and draw on a +personal hint wallet once the per-game allowance is spent. The game ends when the +bag empties and a player clears their rack, after 6 consecutive scoreless turns, +by resignation, or by the per-game move timeout (5 minutes to 24 hours, default +24 hours): a missed turn auto-resigns, except while the player is inside their +daily away window. A resignation or timeout gives the win to the other player and +the leaver keeps their score (two-player games; multi-player drop-out-and-continue +arrives with the lobby in Stage 4). ### Robot opponent *(Stage 5)* Indistinguishable-from-human substitute in auto-match. Decides once whether to @@ -49,7 +56,9 @@ toggles. ### History & statistics *(Stage 3)* Finished games are archived in a dictionary-independent form and exportable to -GCG. Statistics: wins, losses, max points in a game, max points for one word. +GCG. Statistics (durable accounts only): wins, losses, draws, max points in a +game, and max points for a single move (the best play, which already includes +every word it formed plus the all-tiles bonus). ### Administration *(Stage 9)* Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2fa81a6..146c0ae 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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) разбирает жалобы на слова, управляет версиями diff --git a/docs/TESTING.md b/docs/TESTING.md index a6b5127..3e6618a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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