Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.
Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.
Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.
Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+30
-10
@@ -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
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Command backend is the Scrabble platform's internal domain service. It boots
|
||||
// the OpenTelemetry runtime, opens the Postgres pool and applies migrations,
|
||||
// warms the session cache, and serves the HTTP listener with the infrastructure
|
||||
// probes and the /api/v1 route-group skeleton. Domain endpoints are added by
|
||||
// later stages described in PLAN.md.
|
||||
// loads the dictionaries into the engine registry, warms the session cache,
|
||||
// constructs the game domain and starts its turn-timeout sweeper, then serves the
|
||||
// HTTP listener with the infrastructure probes and the /api/v1 route-group
|
||||
// skeleton. Domain HTTP endpoints are added with the gateway in a later stage
|
||||
// described in PLAN.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,7 +17,10 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/config"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -46,7 +51,8 @@ func main() {
|
||||
}
|
||||
|
||||
// run wires the process dependencies in order — telemetry, database (with
|
||||
// migrations), session cache, HTTP server — and blocks until ctx is cancelled.
|
||||
// migrations), engine dictionaries, session cache, game domain (with its
|
||||
// turn-timeout sweeper), HTTP server — and blocks until ctx is cancelled.
|
||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
tel, err := telemetry.New(ctx, cfg.Telemetry)
|
||||
if err != nil {
|
||||
@@ -74,12 +80,26 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
logger.Info("database migrations applied")
|
||||
|
||||
registry, err := engine.Open(cfg.Game.DictDir, cfg.Game.DictVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dictionaries: %w", err)
|
||||
}
|
||||
defer func() { _ = registry.Close() }()
|
||||
logger.Info("dictionaries loaded",
|
||||
zap.String("dir", cfg.Game.DictDir),
|
||||
zap.String("version", cfg.Game.DictVersion))
|
||||
|
||||
sessions := session.NewService(session.NewStore(db), session.NewCache())
|
||||
if err := sessions.Warm(ctx); err != nil {
|
||||
return fmt.Errorf("warm session cache: %w", err)
|
||||
}
|
||||
logger.Info("session cache warmed")
|
||||
|
||||
games := game.NewService(game.NewStore(db), account.NewStore(db), registry, cfg.Game, logger)
|
||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||
logger.Info("game turn-timeout sweeper started",
|
||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||
|
||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||
Logger: logger,
|
||||
DB: db,
|
||||
|
||||
@@ -33,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:]))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/postgres"
|
||||
)
|
||||
|
||||
@@ -22,6 +25,13 @@ import (
|
||||
// hydrated once by TestMain.
|
||||
var testDB *sql.DB
|
||||
|
||||
// testRegistry holds the committed dictionaries the game service plays over,
|
||||
// loaded once under testDictVersion (the version the game tests pin).
|
||||
var testRegistry *engine.Registry
|
||||
|
||||
// testDictVersion is the single dictionary version the integration suite loads.
|
||||
const testDictVersion = "v1"
|
||||
|
||||
const (
|
||||
pgImage = "postgres:17-alpine"
|
||||
pgDatabase = "scrabble_backend"
|
||||
@@ -89,10 +99,27 @@ func runSuite(m *testing.M) (int, error) {
|
||||
return 0, fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
|
||||
reg, err := engine.Open(dictDir(), testDictVersion)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load dictionaries: %w", err)
|
||||
}
|
||||
defer func() { _ = reg.Close() }()
|
||||
|
||||
testDB = db
|
||||
testRegistry = reg
|
||||
return m.Run(), nil
|
||||
}
|
||||
|
||||
// dictDir resolves the committed DAWG directory: BACKEND_DICT_DIR when set (CI),
|
||||
// otherwise the sibling scrabble-solver checkout located relative to this file.
|
||||
func dictDir() string {
|
||||
if dir := os.Getenv("BACKEND_DICT_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "scrabble-solver", "dawg")
|
||||
}
|
||||
|
||||
// withSearchPath rewrites dsn so every connection pins search_path to schema.
|
||||
func withSearchPath(dsn, schema string) (string, error) {
|
||||
u, err := url.Parse(dsn)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AccountStats struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Wins int32
|
||||
Losses int32
|
||||
Draws int32
|
||||
MaxGamePoints int32
|
||||
MaxWordPoints int32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -21,4 +21,7 @@ type Accounts struct {
|
||||
BlockFriendRequests bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int32
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Complaints struct {
|
||||
ComplaintID uuid.UUID `sql:"primary_key"`
|
||||
ComplainantID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
Variant string
|
||||
DictVersion string
|
||||
Word string
|
||||
WasValid bool
|
||||
Note string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GameMoves struct {
|
||||
GameID uuid.UUID `sql:"primary_key"`
|
||||
Seq int32 `sql:"primary_key"`
|
||||
Seat int16
|
||||
Action string
|
||||
Score int32
|
||||
RunningTotal int32
|
||||
ExchangedCount int16
|
||||
Payload string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GamePlayers struct {
|
||||
GameID uuid.UUID `sql:"primary_key"`
|
||||
Seat int16 `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
Score int32
|
||||
HintsUsed int16
|
||||
IsWinner bool
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Games struct {
|
||||
GameID uuid.UUID `sql:"primary_key"`
|
||||
Variant string
|
||||
DictVersion string
|
||||
Seed int64
|
||||
Status string
|
||||
Players int16
|
||||
ToMove int16
|
||||
TurnStartedAt time.Time
|
||||
TurnTimeoutSecs int32
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int16
|
||||
MoveCount int32
|
||||
EndReason *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var AccountStats = newAccountStatsTable("backend", "account_stats", "")
|
||||
|
||||
type accountStatsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
Wins postgres.ColumnInteger
|
||||
Losses postgres.ColumnInteger
|
||||
Draws postgres.ColumnInteger
|
||||
MaxGamePoints postgres.ColumnInteger
|
||||
MaxWordPoints postgres.ColumnInteger
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type AccountStatsTable struct {
|
||||
accountStatsTable
|
||||
|
||||
EXCLUDED accountStatsTable
|
||||
}
|
||||
|
||||
// AS creates new AccountStatsTable with assigned alias
|
||||
func (a AccountStatsTable) AS(alias string) *AccountStatsTable {
|
||||
return newAccountStatsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new AccountStatsTable with assigned schema name
|
||||
func (a AccountStatsTable) FromSchema(schemaName string) *AccountStatsTable {
|
||||
return newAccountStatsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new AccountStatsTable with assigned table prefix
|
||||
func (a AccountStatsTable) WithPrefix(prefix string) *AccountStatsTable {
|
||||
return newAccountStatsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new AccountStatsTable with assigned table suffix
|
||||
func (a AccountStatsTable) WithSuffix(suffix string) *AccountStatsTable {
|
||||
return newAccountStatsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newAccountStatsTable(schemaName, tableName, alias string) *AccountStatsTable {
|
||||
return &AccountStatsTable{
|
||||
accountStatsTable: newAccountStatsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newAccountStatsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
WinsColumn = postgres.IntegerColumn("wins")
|
||||
LossesColumn = postgres.IntegerColumn("losses")
|
||||
DrawsColumn = postgres.IntegerColumn("draws")
|
||||
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
|
||||
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return accountStatsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
Wins: WinsColumn,
|
||||
Losses: LossesColumn,
|
||||
Draws: DrawsColumn,
|
||||
MaxGamePoints: MaxGamePointsColumn,
|
||||
MaxWordPoints: MaxWordPointsColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type accountsTable struct {
|
||||
BlockFriendRequests postgres.ColumnBool
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
AwayStart postgres.ColumnTime
|
||||
AwayEnd postgres.ColumnTime
|
||||
HintBalance postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -74,9 +77,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
AwayStartColumn = postgres.TimeColumn("away_start")
|
||||
AwayEndColumn = postgres.TimeColumn("away_end")
|
||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
|
||||
)
|
||||
|
||||
return accountsTable{
|
||||
@@ -91,6 +97,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
BlockFriendRequests: BlockFriendRequestsColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
AwayStart: AwayStartColumn,
|
||||
AwayEnd: AwayEndColumn,
|
||||
HintBalance: HintBalanceColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Complaints = newComplaintsTable("backend", "complaints", "")
|
||||
|
||||
type complaintsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ComplaintID postgres.ColumnString
|
||||
ComplainantID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
Variant postgres.ColumnString
|
||||
DictVersion postgres.ColumnString
|
||||
Word postgres.ColumnString
|
||||
WasValid postgres.ColumnBool
|
||||
Note postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ComplaintsTable struct {
|
||||
complaintsTable
|
||||
|
||||
EXCLUDED complaintsTable
|
||||
}
|
||||
|
||||
// AS creates new ComplaintsTable with assigned alias
|
||||
func (a ComplaintsTable) AS(alias string) *ComplaintsTable {
|
||||
return newComplaintsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ComplaintsTable with assigned schema name
|
||||
func (a ComplaintsTable) FromSchema(schemaName string) *ComplaintsTable {
|
||||
return newComplaintsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ComplaintsTable with assigned table prefix
|
||||
func (a ComplaintsTable) WithPrefix(prefix string) *ComplaintsTable {
|
||||
return newComplaintsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ComplaintsTable with assigned table suffix
|
||||
func (a ComplaintsTable) WithSuffix(suffix string) *ComplaintsTable {
|
||||
return newComplaintsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newComplaintsTable(schemaName, tableName, alias string) *ComplaintsTable {
|
||||
return &ComplaintsTable{
|
||||
complaintsTable: newComplaintsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newComplaintsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable {
|
||||
var (
|
||||
ComplaintIDColumn = postgres.StringColumn("complaint_id")
|
||||
ComplainantIDColumn = postgres.StringColumn("complainant_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
VariantColumn = postgres.StringColumn("variant")
|
||||
DictVersionColumn = postgres.StringColumn("dict_version")
|
||||
WordColumn = postgres.StringColumn("word")
|
||||
WasValidColumn = postgres.BoolColumn("was_valid")
|
||||
NoteColumn = postgres.StringColumn("note")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return complaintsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ComplaintID: ComplaintIDColumn,
|
||||
ComplainantID: ComplainantIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
Variant: VariantColumn,
|
||||
DictVersion: DictVersionColumn,
|
||||
Word: WordColumn,
|
||||
WasValid: WasValidColumn,
|
||||
Note: NoteColumn,
|
||||
Status: StatusColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GameMoves = newGameMovesTable("backend", "game_moves", "")
|
||||
|
||||
type gameMovesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
GameID postgres.ColumnString
|
||||
Seq postgres.ColumnInteger
|
||||
Seat postgres.ColumnInteger
|
||||
Action postgres.ColumnString
|
||||
Score postgres.ColumnInteger
|
||||
RunningTotal postgres.ColumnInteger
|
||||
ExchangedCount postgres.ColumnInteger
|
||||
Payload postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GameMovesTable struct {
|
||||
gameMovesTable
|
||||
|
||||
EXCLUDED gameMovesTable
|
||||
}
|
||||
|
||||
// AS creates new GameMovesTable with assigned alias
|
||||
func (a GameMovesTable) AS(alias string) *GameMovesTable {
|
||||
return newGameMovesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GameMovesTable with assigned schema name
|
||||
func (a GameMovesTable) FromSchema(schemaName string) *GameMovesTable {
|
||||
return newGameMovesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GameMovesTable with assigned table prefix
|
||||
func (a GameMovesTable) WithPrefix(prefix string) *GameMovesTable {
|
||||
return newGameMovesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GameMovesTable with assigned table suffix
|
||||
func (a GameMovesTable) WithSuffix(suffix string) *GameMovesTable {
|
||||
return newGameMovesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGameMovesTable(schemaName, tableName, alias string) *GameMovesTable {
|
||||
return &GameMovesTable{
|
||||
gameMovesTable: newGameMovesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGameMovesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGameMovesTableImpl(schemaName, tableName, alias string) gameMovesTable {
|
||||
var (
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
SeqColumn = postgres.IntegerColumn("seq")
|
||||
SeatColumn = postgres.IntegerColumn("seat")
|
||||
ActionColumn = postgres.StringColumn("action")
|
||||
ScoreColumn = postgres.IntegerColumn("score")
|
||||
RunningTotalColumn = postgres.IntegerColumn("running_total")
|
||||
ExchangedCountColumn = postgres.IntegerColumn("exchanged_count")
|
||||
PayloadColumn = postgres.StringColumn("payload")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, SeqColumn, SeatColumn, ActionColumn, ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{SeatColumn, ActionColumn, ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return gameMovesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
GameID: GameIDColumn,
|
||||
Seq: SeqColumn,
|
||||
Seat: SeatColumn,
|
||||
Action: ActionColumn,
|
||||
Score: ScoreColumn,
|
||||
RunningTotal: RunningTotalColumn,
|
||||
ExchangedCount: ExchangedCountColumn,
|
||||
Payload: PayloadColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GamePlayers = newGamePlayersTable("backend", "game_players", "")
|
||||
|
||||
type gamePlayersTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
GameID postgres.ColumnString
|
||||
Seat postgres.ColumnInteger
|
||||
AccountID postgres.ColumnString
|
||||
Score postgres.ColumnInteger
|
||||
HintsUsed postgres.ColumnInteger
|
||||
IsWinner postgres.ColumnBool
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GamePlayersTable struct {
|
||||
gamePlayersTable
|
||||
|
||||
EXCLUDED gamePlayersTable
|
||||
}
|
||||
|
||||
// AS creates new GamePlayersTable with assigned alias
|
||||
func (a GamePlayersTable) AS(alias string) *GamePlayersTable {
|
||||
return newGamePlayersTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GamePlayersTable with assigned schema name
|
||||
func (a GamePlayersTable) FromSchema(schemaName string) *GamePlayersTable {
|
||||
return newGamePlayersTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GamePlayersTable with assigned table prefix
|
||||
func (a GamePlayersTable) WithPrefix(prefix string) *GamePlayersTable {
|
||||
return newGamePlayersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GamePlayersTable with assigned table suffix
|
||||
func (a GamePlayersTable) WithSuffix(suffix string) *GamePlayersTable {
|
||||
return newGamePlayersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGamePlayersTable(schemaName, tableName, alias string) *GamePlayersTable {
|
||||
return &GamePlayersTable{
|
||||
gamePlayersTable: newGamePlayersTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGamePlayersTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGamePlayersTableImpl(schemaName, tableName, alias string) gamePlayersTable {
|
||||
var (
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
SeatColumn = postgres.IntegerColumn("seat")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
ScoreColumn = postgres.IntegerColumn("score")
|
||||
HintsUsedColumn = postgres.IntegerColumn("hints_used")
|
||||
IsWinnerColumn = postgres.BoolColumn("is_winner")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, SeatColumn, AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn}
|
||||
defaultColumns = postgres.ColumnList{ScoreColumn, HintsUsedColumn, IsWinnerColumn}
|
||||
)
|
||||
|
||||
return gamePlayersTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
GameID: GameIDColumn,
|
||||
Seat: SeatColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Score: ScoreColumn,
|
||||
HintsUsed: HintsUsedColumn,
|
||||
IsWinner: IsWinnerColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Games = newGamesTable("backend", "games", "")
|
||||
|
||||
type gamesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
GameID postgres.ColumnString
|
||||
Variant postgres.ColumnString
|
||||
DictVersion postgres.ColumnString
|
||||
Seed postgres.ColumnInteger
|
||||
Status postgres.ColumnString
|
||||
Players postgres.ColumnInteger
|
||||
ToMove postgres.ColumnInteger
|
||||
TurnStartedAt postgres.ColumnTimestampz
|
||||
TurnTimeoutSecs postgres.ColumnInteger
|
||||
HintsAllowed postgres.ColumnBool
|
||||
HintsPerPlayer postgres.ColumnInteger
|
||||
MoveCount postgres.ColumnInteger
|
||||
EndReason postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
FinishedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GamesTable struct {
|
||||
gamesTable
|
||||
|
||||
EXCLUDED gamesTable
|
||||
}
|
||||
|
||||
// AS creates new GamesTable with assigned alias
|
||||
func (a GamesTable) AS(alias string) *GamesTable {
|
||||
return newGamesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GamesTable with assigned schema name
|
||||
func (a GamesTable) FromSchema(schemaName string) *GamesTable {
|
||||
return newGamesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GamesTable with assigned table prefix
|
||||
func (a GamesTable) WithPrefix(prefix string) *GamesTable {
|
||||
return newGamesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GamesTable with assigned table suffix
|
||||
func (a GamesTable) WithSuffix(suffix string) *GamesTable {
|
||||
return newGamesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGamesTable(schemaName, tableName, alias string) *GamesTable {
|
||||
return &GamesTable{
|
||||
gamesTable: newGamesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGamesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
var (
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
VariantColumn = postgres.StringColumn("variant")
|
||||
DictVersionColumn = postgres.StringColumn("dict_version")
|
||||
SeedColumn = postgres.IntegerColumn("seed")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
PlayersColumn = postgres.IntegerColumn("players")
|
||||
ToMoveColumn = postgres.IntegerColumn("to_move")
|
||||
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
|
||||
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
|
||||
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
|
||||
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
|
||||
MoveCountColumn = postgres.IntegerColumn("move_count")
|
||||
EndReasonColumn = postgres.StringColumn("end_reason")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return gamesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
GameID: GameIDColumn,
|
||||
Variant: VariantColumn,
|
||||
DictVersion: DictVersionColumn,
|
||||
Seed: SeedColumn,
|
||||
Status: StatusColumn,
|
||||
Players: PlayersColumn,
|
||||
ToMove: ToMoveColumn,
|
||||
TurnStartedAt: TurnStartedAtColumn,
|
||||
TurnTimeoutSecs: TurnTimeoutSecsColumn,
|
||||
HintsAllowed: HintsAllowedColumn,
|
||||
HintsPerPlayer: HintsPerPlayerColumn,
|
||||
MoveCount: MoveCountColumn,
|
||||
EndReason: EndReasonColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
FinishedAt: FinishedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,12 @@ package table
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
AccountStats = AccountStats.FromSchema(schema)
|
||||
Accounts = Accounts.FromSchema(schema)
|
||||
Complaints = Complaints.FromSchema(schema)
|
||||
GameMoves = GameMoves.FromSchema(schema)
|
||||
GamePlayers = GamePlayers.FromSchema(schema)
|
||||
Games = Games.FromSchema(schema)
|
||||
Identities = Identities.FromSchema(schema)
|
||||
Sessions = Sessions.FromSchema(schema)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- +goose Up
|
||||
-- Stage 3 game domain: the per-game lifecycle, the dictionary-independent move
|
||||
-- journal, word-check complaints and per-account statistics, plus two account
|
||||
-- columns the game domain needs.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- Extend accounts with the per-user away window (one interval per day, in the
|
||||
-- account's local time_zone) honoured by the turn-timeout sweeper -- and, in a
|
||||
-- later stage, by the robot's sleep -- and a hint wallet (purchasable hints; the
|
||||
-- purchase flow lands later, so the balance defaults to empty). Profile editing
|
||||
-- of the away window arrives with the profile surface (Stage 4).
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN away_start time NOT NULL DEFAULT '00:00',
|
||||
ADD COLUMN away_end time NOT NULL DEFAULT '07:00',
|
||||
ADD COLUMN hint_balance integer NOT NULL DEFAULT 0,
|
||||
ADD CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0);
|
||||
|
||||
-- One match. The live position is event-sourced: this row carries the pinned
|
||||
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs,
|
||||
-- while game_moves is the append-only journal the in-memory engine.Game is
|
||||
-- replayed from (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move
|
||||
-- clock; its allowed values are enforced in Go. variant uses engine.Variant's
|
||||
-- stable labels.
|
||||
CREATE TABLE games (
|
||||
game_id uuid PRIMARY KEY,
|
||||
variant text NOT NULL,
|
||||
dict_version text NOT NULL,
|
||||
seed bigint NOT NULL,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
players smallint NOT NULL,
|
||||
to_move smallint NOT NULL DEFAULT 0,
|
||||
turn_started_at timestamptz NOT NULL DEFAULT now(),
|
||||
turn_timeout_secs integer NOT NULL,
|
||||
hints_allowed boolean NOT NULL DEFAULT true,
|
||||
hints_per_player smallint NOT NULL DEFAULT 1,
|
||||
move_count integer NOT NULL DEFAULT 0,
|
||||
end_reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
CONSTRAINT games_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
|
||||
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
|
||||
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
|
||||
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
|
||||
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
||||
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
|
||||
CONSTRAINT games_end_reason_chk CHECK (
|
||||
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
|
||||
)
|
||||
);
|
||||
-- The sweeper scans active games oldest-turn-first; a partial index keeps it
|
||||
-- off the finished archive.
|
||||
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
|
||||
|
||||
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
|
||||
-- durable account (guests and robots are revisited when they arrive). score is
|
||||
-- the running/final score, is_winner is stamped on finish (false for every seat
|
||||
-- on a draw), hints_used counts the per-game allowance consumed before the
|
||||
-- profile wallet.
|
||||
CREATE TABLE game_players (
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
seat smallint NOT NULL,
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||
score integer NOT NULL DEFAULT 0,
|
||||
hints_used smallint NOT NULL DEFAULT 0,
|
||||
is_winner boolean NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (game_id, seat),
|
||||
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
|
||||
);
|
||||
CREATE INDEX game_players_account_idx ON game_players (account_id);
|
||||
|
||||
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md
|
||||
-- §9.1). seq orders the moves from 0. payload holds the decoded values needed to
|
||||
-- both replay the game through the engine and emit GCG without a dictionary: the
|
||||
-- acting rack, and for a play its direction, placed tiles and formed words; for
|
||||
-- an exchange the swapped tiles. score / running_total / exchanged_count are
|
||||
-- lifted out for cheap history rendering.
|
||||
CREATE TABLE game_moves (
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
seq integer NOT NULL,
|
||||
seat smallint NOT NULL,
|
||||
action text NOT NULL,
|
||||
score integer NOT NULL DEFAULT 0,
|
||||
running_total integer NOT NULL DEFAULT 0,
|
||||
exchanged_count smallint NOT NULL DEFAULT 0,
|
||||
payload text NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (game_id, seq),
|
||||
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
|
||||
);
|
||||
|
||||
-- Word-check complaints captured in the context of a game's pinned dictionary.
|
||||
-- The admin review queue and the resolution lifecycle land in Stage 9, which
|
||||
-- owns the status state machine; Stage 3 only ever writes 'open'.
|
||||
CREATE TABLE complaints (
|
||||
complaint_id uuid PRIMARY KEY,
|
||||
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
variant text NOT NULL,
|
||||
dict_version text NOT NULL,
|
||||
word text NOT NULL,
|
||||
was_valid boolean NOT NULL,
|
||||
note text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'open',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX complaints_status_idx ON complaints (status);
|
||||
|
||||
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
|
||||
-- Guests have no durable account and never appear here. A draw increments draws
|
||||
-- only (neither wins nor losses). max_word_points is the best single move score
|
||||
-- (which already folds in every word the move formed and the all-tiles bonus).
|
||||
CREATE TABLE account_stats (
|
||||
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
wins integer NOT NULL DEFAULT 0,
|
||||
losses integer NOT NULL DEFAULT 0,
|
||||
draws integer NOT NULL DEFAULT 0,
|
||||
max_game_points integer NOT NULL DEFAULT 0,
|
||||
max_word_points integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE account_stats;
|
||||
DROP TABLE complaints;
|
||||
DROP TABLE game_moves;
|
||||
DROP TABLE game_players;
|
||||
DROP TABLE games;
|
||||
ALTER TABLE accounts
|
||||
DROP CONSTRAINT accounts_hint_balance_chk,
|
||||
DROP COLUMN hint_balance,
|
||||
DROP COLUMN away_end,
|
||||
DROP COLUMN away_start;
|
||||
+76
-30
@@ -111,10 +111,12 @@ Key points:
|
||||
`Erudit()`). Эрудит's specifics (non-doubling centre, `ё` with no tiles, 3
|
||||
blanks, a 15-point bonus) live entirely in the solver ruleset, so the engine
|
||||
treats every variant uniformly.
|
||||
- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from a directory
|
||||
(a parameter today; a configurable `BACKEND_DICT_DIR` is wired when the first
|
||||
consumer needs it). The `engine.Registry` holds them in memory addressed by
|
||||
`(variant, dict_version)`, tracking the latest version per variant.
|
||||
- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from the
|
||||
directory `BACKEND_DICT_DIR`; `backend` loads the `engine.Registry` at startup
|
||||
as a hard dependency (like migrations), so a missing dictionary fails the boot.
|
||||
The registry holds dictionaries in memory addressed by `(variant,
|
||||
dict_version)`, tracking the latest version per variant, and answers the
|
||||
word-check tool through `Registry.Lookup`.
|
||||
- **Dictionary versioning — pin per game.** A game records the `dict_version` it
|
||||
started on and finishes on that version; new games use the latest. Multiple
|
||||
versions may be resident at once. An admin reload *(planned, Stage 9)*
|
||||
@@ -130,10 +132,18 @@ Key points:
|
||||
- **`engine.Game`** is the in-memory match state and the pure rules engine: it
|
||||
deals racks, applies legal plays / passes / exchanges / resignations, refills
|
||||
from the bag, keeps the scores and whose turn it is, and **detects the end of
|
||||
the game** — empty bag with an empty rack, six consecutive scoreless turns, or
|
||||
a resignation — applying the end-game rack-value adjustment. The 24-hour
|
||||
timeout / auto-resign, turn scheduling and persistence belong to the game
|
||||
domain *(Stage 3)*.
|
||||
the game** — empty bag with an empty rack, or six consecutive scoreless turns,
|
||||
applying the end-game rack-value adjustment, or a resignation. On a
|
||||
**resignation the resigner keeps their accumulated score (no rack adjustment)
|
||||
and never wins**: the win goes to the highest score among the remaining seats,
|
||||
unconditionally the other player in a two-player game. The engine exposes a
|
||||
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
||||
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
||||
- The **game domain** (`internal/game`) owns everything the engine does not —
|
||||
persistence, turn scheduling, the configurable turn timeout / auto-resign, the
|
||||
hint budget, word-check complaints, history and GCG — and is the engine's only
|
||||
consumer. Timeout auto-resign reuses `engine.Resign`, recording the move as a
|
||||
timeout, so it inherits the resignation win/loss.
|
||||
- History is dictionary-independent (§9.1): the engine emits decoded
|
||||
`MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard`
|
||||
(alphabet only, no dictionary).
|
||||
@@ -143,13 +153,29 @@ Key points:
|
||||
- **Word legality: validate-at-submit.** An illegal play is rejected by
|
||||
`Solver.ValidatePlay`; there is no challenge phase.
|
||||
- **End of game**: the bag is empty **and** a player empties their rack, **or**
|
||||
**6 consecutive scoreless turns** (passes/exchanges). A move that is not made
|
||||
within the **24-hour** turn timeout becomes an automatic resignation.
|
||||
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
|
||||
a missed turn. The **per-game turn timeout** is chosen at creation
|
||||
(5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h); a turn not made within it
|
||||
becomes an automatic resignation, applied by a background sweeper. The sweeper
|
||||
honours each player's **away window** — a daily local-time sleep interval on the
|
||||
account (default 00:00–07:00, midnight-cross aware) — so a player is never
|
||||
timed out while asleep.
|
||||
- **Players**: auto-match is always 2 players; friend games are 2–4 players.
|
||||
`backend` owns turn order and the bag for any player count.
|
||||
- **Hint**: one per game; reveals the top-1 ranked move (`GenerateMoves[0]`).
|
||||
- **Word-check tool**: unlimited dictionary lookups; each result offers a
|
||||
**complaint** that lands in an admin review queue *(admin side planned)*.
|
||||
`backend` owns turn order and the bag for any player count. A resignation or
|
||||
timeout in a two-player game ends it with the other player winning; **richer
|
||||
multi-player drop-out (a leaver's seat skipped while the rest play on, with a
|
||||
per-game disposition of their tiles) is deferred to Stage 4**, when friend games
|
||||
are formed.
|
||||
- **Hint**: governed by two per-game settings — whether hints are allowed and the
|
||||
starting per-player allowance — plus a per-account hint **wallet**
|
||||
(`hint_balance`, spent after the allowance; top-ups are a later feature). A hint
|
||||
reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller
|
||||
picks the per-game defaults (e.g. one in casual random games, none in
|
||||
tournaments).
|
||||
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
|
||||
dictionary; each result offers a **complaint** (complainant, game, variant,
|
||||
dict_version, word, the disputed result, an optional note) that lands in an
|
||||
admin review queue *(admin side planned, Stage 9)*.
|
||||
|
||||
## 7. Robot opponent
|
||||
|
||||
@@ -191,26 +217,46 @@ within 10 seconds. Designed to be indistinguishable from a person.
|
||||
into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
|
||||
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
|
||||
keys are application-generated **UUIDv7**.
|
||||
- Stage 1 tables: `accounts` (durable internal accounts), `identities`
|
||||
(platform/email identities, unique `(kind, external_id)`) and `sessions`
|
||||
(revoke-only opaque-token hashes).
|
||||
- **Active game state** is stored structurally with the `dict_version` pinned.
|
||||
- **Statistics** (computed on finish): wins, losses, max points in a game, max
|
||||
points for a single word.
|
||||
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
|
||||
columns `away_start`/`away_end` and the hint wallet `hint_balance`),
|
||||
`identities` (platform/email identities, unique `(kind, external_id)`),
|
||||
`sessions` (revoke-only opaque-token hashes), and the Stage 3 game tables
|
||||
`games`, `game_players`, `game_moves` (the move journal), `complaints` and
|
||||
`account_stats`.
|
||||
- **Active games are event-sourced.** A game is a `games` row (pinned
|
||||
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
|
||||
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
|
||||
position is an `engine.Game` held in an in-memory cache (≈24 h idle TTL) and
|
||||
rebuilt by replaying the journal on a miss, which the seeded bag makes exact.
|
||||
Each game is serialised by a per-game lock; a persistence failure evicts the
|
||||
live game so the next access rebuilds from the journal. `game_players` records
|
||||
each seat's account, running score, hints used and winner flag.
|
||||
- **Statistics** (`account_stats`, recomputed on each finish, durable accounts
|
||||
only — guests never appear): wins, losses, **draws**, max points in a game, and
|
||||
max points for a single **move** (which already folds in every word the move
|
||||
formed plus the all-tiles bonus). A tie increments draws only; a resignation or
|
||||
timeout is a loss for the acting player.
|
||||
|
||||
### 9.1 History invariant (must hold forever)
|
||||
|
||||
Archived games must replay **independently of any dictionary and of the
|
||||
solver's internal encoding** — at least visually. Therefore the move log
|
||||
persists only **decoded concrete values**: letters as text, coordinates, blank
|
||||
flag, action kind (play / pass / exchange / resign / timeout), acting player,
|
||||
per-move score and running total, timestamp. The board for visual replay is
|
||||
reconstructed by applying placements onto an empty grid; no dictionary is
|
||||
needed because moves were validated at play time and scores are stored.
|
||||
`variant` and `dict_version` are kept as **metadata only** (audit, complaint
|
||||
review), never as a replay dependency. **GCG export** is derived from the same
|
||||
rows and is likewise self-contained (we ship our own writer; the solver exposes
|
||||
no public GCG writer).
|
||||
solver's internal encoding** — at least visually. Therefore the move journal
|
||||
persists only **decoded concrete values**: action kind (play / pass / exchange /
|
||||
resign / timeout), acting player, per-move score and running total, timestamp,
|
||||
and — in a per-move JSON payload — the acting player's rack before the move (with
|
||||
`?` for a blank), and for a play its direction, main-word anchor, placed tiles
|
||||
(letter as text, coordinate, blank flag) and the words formed; for an exchange,
|
||||
the swapped tiles. This is exactly what is needed both to **replay the game
|
||||
through the engine** (a cache miss) and to render history or emit GCG **without a
|
||||
dictionary**: the board for visual replay is reconstructed by applying placements
|
||||
onto an empty grid, since moves were validated at play time and scores are
|
||||
stored. `variant` and `dict_version` are kept as **metadata only** (audit,
|
||||
complaint review), never as a replay dependency. **GCG export** is derived from
|
||||
the same rows and is likewise self-contained — we ship our own writer (the solver
|
||||
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
||||
does not cover.
|
||||
|
||||
## 10. Notifications
|
||||
|
||||
|
||||
+15
-6
@@ -28,11 +28,18 @@ a `(variant, language)` pool; after 10 s with no human, the robot substitutes.
|
||||
Friend games (2–4) are formed by friend list, internal ID, or deep-link.
|
||||
|
||||
### Playing a game *(Stage 3)*
|
||||
Place tiles, pass, exchange, or resign. A play is validated against the
|
||||
dictionary at submit time and scored. One hint per game reveals the best move.
|
||||
The dictionary check tool is unlimited and offers a complaint. The game ends
|
||||
when the bag empties and a player clears their rack, after 6 consecutive
|
||||
scoreless turns, or by the 24-hour move timeout (auto-resign).
|
||||
Place tiles, pass, exchange, or resign. A play is validated against the game's
|
||||
dictionary at submit time and scored; an unlimited preview reports what a
|
||||
tentative move would score and whether it is legal. The dictionary check tool is
|
||||
unlimited and offers a complaint on any result. Hints are governed per game —
|
||||
whether they are allowed and how many each player starts with — and draw on a
|
||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||
daily away window. A resignation or timeout gives the win to the other player and
|
||||
the leaver keeps their score (two-player games; multi-player drop-out-and-continue
|
||||
arrives with the lobby in Stage 4).
|
||||
|
||||
### Robot opponent *(Stage 5)*
|
||||
Indistinguishable-from-human substitute in auto-match. Decides once whether to
|
||||
@@ -49,7 +56,9 @@ toggles.
|
||||
|
||||
### History & statistics *(Stage 3)*
|
||||
Finished games are archived in a dictionary-independent form and exportable to
|
||||
GCG. Statistics: wins, losses, max points in a game, max points for one word.
|
||||
GCG. Statistics (durable accounts only): wins, losses, draws, max points in a
|
||||
game, and max points for a single move (the best play, which already includes
|
||||
every word it formed plus the all-tiles bonus).
|
||||
|
||||
### Administration *(Stage 9)*
|
||||
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
|
||||
|
||||
+15
-7
@@ -28,11 +28,18 @@ session-токен; backend сопоставляет его с внутренн
|
||||
или deep-link.
|
||||
|
||||
### Игровой процесс *(Stage 3)*
|
||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю при
|
||||
сдаче и считается. Одна подсказка на партию показывает лучший ход. Инструмент
|
||||
проверки слова безлимитный и предлагает пожаловаться. Партия завершается, когда
|
||||
мешок пуст и игрок выложил стойку, после 6 подряд бесплодных ходов, либо по
|
||||
таймауту хода в 24 часа (авто-сдача).
|
||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
|
||||
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||
суточного окна отсутствия (away). Сдача или таймаут отдают победу другому игроку,
|
||||
а вышедший сохраняет свои очки (партии на двоих; выход одного с продолжением для
|
||||
остальных появится вместе с лобби в Stage 4).
|
||||
|
||||
### Робот-соперник *(Stage 5)*
|
||||
Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на
|
||||
@@ -50,8 +57,9 @@ session-токен; backend сопоставляет его с внутренн
|
||||
|
||||
### История и статистика *(Stage 3)*
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG. Статистика: победы, поражения, макс. очков за партию, макс. очков за
|
||||
слово.
|
||||
в GCG. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи,
|
||||
макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все
|
||||
образованные им слова и бонус за все фишки).
|
||||
|
||||
### Администрирование *(Stage 9)*
|
||||
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
|
||||
|
||||
+12
-4
@@ -22,10 +22,18 @@ tests or touching CI.
|
||||
and exchange accounting, the `Game` end-conditions (empty bag with an empty
|
||||
rack, and six scoreless turns) with end-game rack scoring, and
|
||||
**dictionary-independent history replay** (`ReplayBoard` reproduces a full
|
||||
greedy game's final board from decoded records alone). The engine tests read
|
||||
the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver` checkout)
|
||||
and fail loudly when it is absent. The 24-hour timeout / auto-resign and robot
|
||||
balance/margin regression tests arrive with those stages.
|
||||
greedy game's final board from decoded records alone), and the **resignation
|
||||
win/loss rule** (the resigner keeps their score yet loses). The engine tests
|
||||
read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver`
|
||||
checkout) and fail loudly when it is absent.
|
||||
- **Game domain** *(Stage 3+)* — `backend/internal/game` adds pure unit tests
|
||||
(the GCG writer, the away-window / effective-deadline boundaries, the hint
|
||||
budget, the live-game cache and per-game lock, payload round-trips) plus
|
||||
Postgres-backed integration tests in `inttest` (full lifecycle to a natural
|
||||
end, **journal-replay equivalence**, the turn-timeout sweep with away-window
|
||||
grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
|
||||
word-check and complaint capture, and per-game-lock serialisation). The robot
|
||||
balance/margin regression tests arrive with Stage 5.
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
Reference in New Issue
Block a user