Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s

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:
Ilia Denisov
2026-06-02 17:33:49 +02:00
parent f36f3df748
commit 751e74b14f
45 changed files with 4220 additions and 103 deletions
+78
View File
@@ -0,0 +1,78 @@
package game
import (
"testing"
"time"
)
func TestInAwayWindow(t *testing.T) {
cases := []struct {
name string
dl, start, end int
wantIn, wantToday bool
}{
{"non-crossing inside", 120, 0, 420, true, true},
{"non-crossing before", 500, 0, 420, false, false},
{"non-crossing at start", 0, 0, 420, true, true},
{"non-crossing at end excluded", 420, 0, 420, false, false},
{"crossing evening", 1380, 1320, 360, true, false},
{"crossing morning", 180, 1320, 360, true, true},
{"crossing daytime out", 720, 1320, 360, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in, today := inAwayWindow(tc.dl, tc.start, tc.end)
if in != tc.wantIn || today != tc.wantToday {
t.Errorf("inAwayWindow(%d,%d,%d) = (%v,%v), want (%v,%v)",
tc.dl, tc.start, tc.end, in, today, tc.wantIn, tc.wantToday)
}
})
}
}
func TestEffectiveDeadline(t *testing.T) {
utc := time.UTC
day := func(h, m int) time.Time { return time.Date(2026, 6, 2, h, m, 0, 0, utc) }
hour := time.Hour
cases := []struct {
name string
start time.Time
timeout time.Duration
awayStart int
awayEnd int
want time.Time
}{
{"no window", day(1, 0), hour, 0, 0, day(2, 0)},
{"outside window", day(8, 0), hour, 0, 420, day(9, 0)},
{"inside non-crossing pushed to end", day(1, 0), hour, 0, 420, day(7, 0)},
{"inside non-crossing at boundary", day(2, 30), 3 * hour, 0, 420, day(7, 0)},
{"crossing evening pushed to next day", day(22, 0), hour, 1320, 360, day(6, 0).AddDate(0, 0, 1)},
{"crossing morning pushed to today end", day(2, 0), hour, 1320, 360, day(6, 0)},
{"crossing daytime untouched", day(11, 0), hour, 1320, 360, day(12, 0)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := effectiveDeadline(tc.start, tc.timeout, utc, tc.awayStart, tc.awayEnd)
if !got.Equal(tc.want) {
t.Errorf("effectiveDeadline = %s, want %s", got, tc.want)
}
})
}
}
func TestMinutesOfDay(t *testing.T) {
got := minutesOfDay(time.Date(1, 1, 1, 7, 30, 0, 0, time.UTC))
if got != 450 {
t.Errorf("minutesOfDay(07:30) = %d, want 450", got)
}
}
func TestLoadLocationFallsBackToUTC(t *testing.T) {
if loadLocation("") != time.UTC {
t.Error("empty zone must fall back to UTC")
}
if loadLocation("Totally/Bogus") != time.UTC {
t.Error("unknown zone must fall back to UTC")
}
}
+121
View File
@@ -0,0 +1,121 @@
package game
import (
"sync"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// keyedMutex hands out one mutex per game id, serialising every operation on a
// single game (engine.Game is not safe for concurrent use) while letting
// different games proceed in parallel. Locks are reference-counted and removed
// once no caller holds or awaits them.
type keyedMutex struct {
mu sync.Mutex
locks map[uuid.UUID]*lockRef
}
type lockRef struct {
mu sync.Mutex
refs int
}
func newKeyedMutex() *keyedMutex {
return &keyedMutex{locks: make(map[uuid.UUID]*lockRef)}
}
// lock acquires the mutex for id and returns its release function.
func (k *keyedMutex) lock(id uuid.UUID) func() {
k.mu.Lock()
ref := k.locks[id]
if ref == nil {
ref = &lockRef{}
k.locks[id] = ref
}
ref.refs++
k.mu.Unlock()
ref.mu.Lock()
return func() {
ref.mu.Unlock()
k.mu.Lock()
ref.refs--
if ref.refs == 0 {
delete(k.locks, id)
}
k.mu.Unlock()
}
}
// gameCache holds live engine.Game values keyed by game id and evicts an entry
// once it has been idle for ttl. An evicted game is transparently rebuilt from
// the journal on next access, so eviction never affects correctness. It is safe
// for concurrent use.
type gameCache struct {
mu sync.Mutex
entries map[uuid.UUID]*cachedGame
ttl time.Duration
now func() time.Time
}
type cachedGame struct {
game *engine.Game
lastAccess time.Time
}
func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
}
// get returns the live game for id and refreshes its idle timer, or (nil, false).
func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[id]
if !ok {
return nil, false
}
e.lastAccess = c.now()
return e.game, true
}
// put stores g as the live game for id.
func (c *gameCache) put(id uuid.UUID, g *engine.Game) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[id] = &cachedGame{game: g, lastAccess: c.now()}
}
// remove drops id from the cache (used on a finished game and after a failed
// persist, so the next access rebuilds from the journal).
func (c *gameCache) remove(id uuid.UUID) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, id)
}
// sweep evicts every entry idle longer than ttl and returns how many were
// dropped.
func (c *gameCache) sweep() int {
c.mu.Lock()
defer c.mu.Unlock()
cutoff := c.now().Add(-c.ttl)
var n int
for id, e := range c.entries {
if e.lastAccess.Before(cutoff) {
delete(c.entries, id)
n++
}
}
return n
}
// size reports the number of resident games (for diagnostics and tests).
func (c *gameCache) size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.entries)
}
+54
View File
@@ -0,0 +1,54 @@
package game
import (
"errors"
"fmt"
"time"
)
// Config configures the game subsystem: where the engine loads its dictionaries,
// which version new games pin, and the two background knobs — the turn-timeout
// sweep cadence and the idle window after which the live-game cache evicts a game
// (it is then rebuilt from the journal on next access, so this never affects
// correctness). It composes into the backend configuration.
type Config struct {
// DictDir is the directory holding the committed DAWG files. Sourced from
// BACKEND_DICT_DIR; it has no default and must be set.
DictDir string
// DictVersion labels the dictionary version new games pin. Sourced from
// BACKEND_DICT_VERSION.
DictVersion string
// TimeoutSweepInterval is how often the sweeper scans for overdue turns.
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
TimeoutSweepInterval time.Duration
// CacheTTL is how long an idle game stays resident before eviction. Sourced
// from BACKEND_GAME_CACHE_TTL.
CacheTTL time.Duration
}
// DefaultConfig returns the game configuration defaults. DictDir is deliberately
// empty: it must be supplied through the environment.
func DefaultConfig() Config {
return Config{
DictVersion: "v1",
TimeoutSweepInterval: time.Minute,
CacheTTL: 24 * time.Hour,
}
}
// Validate reports whether the configuration is usable.
func (c Config) Validate() error {
if c.DictDir == "" {
return errors.New("game: BACKEND_DICT_DIR must be set")
}
if c.DictVersion == "" {
return errors.New("game: BACKEND_DICT_VERSION must not be empty")
}
if c.TimeoutSweepInterval <= 0 {
return fmt.Errorf("game: timeout sweep interval must be positive, got %s", c.TimeoutSweepInterval)
}
if c.CacheTTL <= 0 {
return fmt.Errorf("game: cache TTL must be positive, got %s", c.CacheTTL)
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
// Package game is the backend's game domain. It drives the in-process
// scrabble-solver engine (internal/engine) over a single match and owns
// everything the engine deliberately does not: persistence, scheduling and the
// player-facing operations.
//
// Active games are event-sourced. Durably, a game is its games row plus an
// append-only, dictionary-independent move journal (game_moves); the live
// position is an engine.Game kept warm in an in-memory cache and rebuilt by
// replaying the journal on a cache miss, which the engine's seeded bag makes
// exact (docs/ARCHITECTURE.md §9). Each game is serialised by a per-game lock,
// since engine.Game is not safe for concurrent use; on a persistence failure the
// live game is evicted so the next access rebuilds from the journal.
//
// The Service exposes create, the play/pass/exchange/resign transitions with
// validate-at-submit scoring, the one-per-game-plus-wallet hint, the unlimited
// word-check tool with complaint capture, per-player game state, history and GCG
// export, and the per-game turn-timeout sweeper that auto-resigns an overdue
// player (honouring their daily away window). The HTTP surface that fronts these
// operations is added with the gateway in a later stage.
package game
+119
View File
@@ -0,0 +1,119 @@
package game
import (
"fmt"
"strconv"
"strings"
)
// writeGCG renders a game as GCG text in the standard (Poslfit) dialect, plus
// #note lines for resignations and timeouts, which the standard does not cover.
// It is derived entirely from the decoded journal, so it needs no dictionary
// (docs/ARCHITECTURE.md §9.1). names supplies each seat's display name; the
// GCG nicknames are p1, p2, … .
func writeGCG(g Game, names []string, moves []HistoryMove) string {
var b strings.Builder
fmt.Fprintln(&b, "#character-encoding UTF-8")
for seat := 0; seat < g.Players; seat++ {
fmt.Fprintf(&b, "#player%d %s %s\n", seat+1, nick(seat), playerName(names, seat))
}
fmt.Fprintf(&b, "#lexicon %s/%s\n", g.Variant, g.DictVersion)
fmt.Fprintf(&b, "#title game %s\n", g.ID)
for _, mv := range moves {
rack := gcgTiles(mv.Rack)
switch mv.Action {
case "play":
fmt.Fprintf(&b, ">%s: %s %s %s +%d %d\n",
nick(mv.Seat), rack, gcgPos(mv), gcgWord(mv), mv.Score, mv.RunningTotal)
case "pass":
fmt.Fprintf(&b, ">%s: %s - +0 %d\n", nick(mv.Seat), rack, mv.RunningTotal)
case "exchange":
fmt.Fprintf(&b, ">%s: %s -%s +0 %d\n", nick(mv.Seat), rack, gcgTiles(mv.Exchanged), mv.RunningTotal)
case "resign":
fmt.Fprintf(&b, "#note %s resigned (rack %s)\n", nick(mv.Seat), rack)
case "timeout":
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
}
}
return b.String()
}
// nick is the GCG nickname for a seat: p1, p2, … (space-free, as GCG requires).
func nick(seat int) string { return "p" + strconv.Itoa(seat+1) }
// playerName returns the display name for a seat, or a generic fallback.
func playerName(names []string, seat int) string {
if seat < len(names) && names[seat] != "" {
return names[seat]
}
return "Player " + strconv.Itoa(seat+1)
}
// gcgTiles renders a rack or exchanged set in GCG form: upper-cased letters with
// "?" for a blank.
func gcgTiles(tiles []string) string {
var b strings.Builder
for _, t := range tiles {
if t == "?" {
b.WriteByte('?')
continue
}
b.WriteString(strings.ToUpper(t))
}
return b.String()
}
// gcgPos renders a play's board coordinate: row-then-column (e.g. 8G) for an
// across play, column-then-row (e.g. H8) for a down play. Rows are 1-based and
// columns are lettered from A.
func gcgPos(mv HistoryMove) string {
col := string(rune('A' + mv.MainCol))
row := strconv.Itoa(mv.MainRow + 1)
if mv.Dir == "V" {
return col + row
}
return row + col
}
// gcgWord renders the main word: each cell along it is the newly-placed tile's
// letter (lower-cased for a blank, upper-cased otherwise) or "." for a tile
// already on the board.
func gcgWord(mv HistoryMove) string {
placed := make(map[[2]int]tileLetter, len(mv.Tiles))
for _, t := range mv.Tiles {
placed[[2]int{t.Row, t.Col}] = tileLetter{letter: t.Letter, blank: t.Blank}
}
var word string
if len(mv.Words) > 0 {
word = mv.Words[0]
}
n := len([]rune(word))
var b strings.Builder
for i := range n {
row, col := mv.MainRow, mv.MainCol
if mv.Dir == "V" {
row += i
} else {
col += i
}
t, ok := placed[[2]int{row, col}]
if !ok {
b.WriteByte('.')
continue
}
if t.blank {
b.WriteString(strings.ToLower(t.letter))
} else {
b.WriteString(strings.ToUpper(t.letter))
}
}
return b.String()
}
// tileLetter is a placed tile's concrete letter and blank flag, keyed by cell in
// gcgWord.
type tileLetter struct {
letter string
blank bool
}
+79
View File
@@ -0,0 +1,79 @@
package game
import (
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
func TestWriteGCG(t *testing.T) {
g := Game{
ID: uuid.MustParse("00000000-0000-7000-8000-000000000001"),
Variant: engine.VariantEnglish,
DictVersion: "v1",
Players: 2,
}
moves := []HistoryMove{
{
Seq: 0, Seat: 0, Action: "play", Score: 10, RunningTotal: 10,
Dir: "H", MainRow: 7, MainCol: 7,
Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "c"}, {Row: 7, Col: 8, Letter: "a"}, {Row: 7, Col: 9, Letter: "t"}},
Words: []string{"cat"}, Rack: []string{"c", "a", "t", "s", "e", "r", "?"},
},
{
Seq: 1, Seat: 1, Action: "play", Score: 2, RunningTotal: 2,
Dir: "V", MainRow: 7, MainCol: 8,
Tiles: []engine.TileRecord{{Row: 8, Col: 8, Letter: "s", Blank: true}},
Words: []string{"as"}, Rack: []string{"a", "s", "?", "e"},
},
{Seq: 2, Seat: 0, Action: "pass", RunningTotal: 10, Rack: []string{"x", "y", "z"}},
{Seq: 3, Seat: 1, Action: "exchange", RunningTotal: 2, Exchanged: []string{"q", "u"}, Rack: []string{"q", "u", "i"}},
{Seq: 4, Seat: 0, Action: "resign", RunningTotal: 10, Rack: []string{"a", "b"}},
{Seq: 5, Seat: 1, Action: "timeout", RunningTotal: 2, Rack: []string{"c"}},
}
out := writeGCG(g, []string{"Alice", "Bob"}, moves)
wantLines := []string{
"#character-encoding UTF-8",
"#player1 p1 Alice",
"#player2 p2 Bob",
"#lexicon english/v1",
"#title game 00000000-0000-7000-8000-000000000001",
">p1: CATSER? 8H CAT +10 10",
">p2: AS?E I8 .s +2 2",
">p1: XYZ - +0 10",
">p2: QUI -QU +0 2",
"#note p1 resigned (rack AB)",
"#note p2 timed out (rack C)",
}
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
got := make(map[string]bool, len(lines))
for _, l := range lines {
got[l] = true
}
for _, want := range wantLines {
if !got[want] {
t.Errorf("GCG missing line %q\n--- full output ---\n%s", want, out)
}
}
}
func TestGCGTilesUppercasesCyrillic(t *testing.T) {
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
t.Errorf("gcgTiles = %q, want КОТ?", got)
}
}
func TestGCGPos(t *testing.T) {
across := gcgPos(HistoryMove{Dir: "H", MainRow: 7, MainCol: 6})
if across != "8G" {
t.Errorf("across pos = %q, want 8G", across)
}
down := gcgPos(HistoryMove{Dir: "V", MainRow: 6, MainCol: 7})
if down != "H7" {
t.Errorf("down pos = %q, want H7", down)
}
}
+139
View File
@@ -0,0 +1,139 @@
package game
import (
"sync"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
func TestPayloadPlayRoundTrip(t *testing.T) {
rec := engine.MoveRecord{
Action: engine.ActionPlay, Dir: engine.Vertical, MainRow: 3, MainCol: 4,
Tiles: []engine.TileRecord{{Row: 3, Col: 4, Letter: "q", Blank: true}, {Row: 4, Col: 4, Letter: "i"}},
Words: []string{"qi"},
}
s, err := buildPayload(rec, []string{"q", "i", "?"}, nil).marshal()
if err != nil {
t.Fatalf("marshal: %v", err)
}
p, err := parsePayload(s)
if err != nil {
t.Fatalf("parse: %v", err)
}
if p.direction() != engine.Vertical || p.MainRow != 3 || p.MainCol != 4 {
t.Errorf("dir/anchor = %v/(%d,%d)", p.direction(), p.MainRow, p.MainCol)
}
tiles := p.tileRecords()
if len(tiles) != 2 || tiles[0].Letter != "q" || !tiles[0].Blank || tiles[1].Letter != "i" {
t.Errorf("tiles = %+v", tiles)
}
if len(p.Rack) != 3 || p.Rack[2] != "?" {
t.Errorf("rack = %v", p.Rack)
}
}
func TestPayloadExchangeRoundTrip(t *testing.T) {
rec := engine.MoveRecord{Action: engine.ActionExchange, Count: 2}
s, err := buildPayload(rec, []string{"a", "b", "c"}, []string{"a", "b"}).marshal()
if err != nil {
t.Fatalf("marshal: %v", err)
}
p, err := parsePayload(s)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(p.Exchanged) != 2 || p.Exchanged[0] != "a" {
t.Errorf("exchanged = %v", p.Exchanged)
}
if len(p.Tiles) != 0 || p.Dir != "" {
t.Errorf("exchange payload carried play fields: %+v", p)
}
}
func TestHintsRemaining(t *testing.T) {
cases := []struct{ allowance, used, wallet, want int }{
{1, 0, 3, 4},
{1, 1, 3, 3},
{1, 2, 3, 3}, // used past allowance clamps to 0
{0, 0, 5, 5},
{2, 1, 0, 1},
}
for _, c := range cases {
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
}
}
}
func TestAllowedTimeout(t *testing.T) {
if !allowedTimeout(24 * time.Hour) {
t.Error("24h must be allowed")
}
if !allowedTimeout(5 * time.Minute) {
t.Error("5m must be allowed")
}
if allowedTimeout(7 * time.Minute) {
t.Error("7m must not be allowed")
}
if allowedTimeout(0) {
t.Error("zero must not be allowed")
}
}
func TestNormalizeWord(t *testing.T) {
if got := normalizeWord(" CaT \n"); got != "cat" {
t.Errorf("normalizeWord = %q, want cat", got)
}
}
func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New()
cache.put(id, nil)
if _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
cur = cur.Add(30 * time.Minute)
cache.get(id) // refresh idle timer
cur = cur.Add(90 * time.Minute)
if n := cache.sweep(); n != 1 {
t.Errorf("sweep evicted %d, want 1", n)
}
if _, ok := cache.get(id); ok {
t.Error("game must be evicted after idle TTL")
}
if cache.size() != 0 {
t.Errorf("cache size = %d, want 0", cache.size())
}
}
func TestKeyedMutexSerializes(t *testing.T) {
km := newKeyedMutex()
id := uuid.New()
var counter int
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
unlock := km.lock(id)
counter++ // serialised; -race would flag a missing lock
unlock()
}()
}
wg.Wait()
if counter != 200 {
t.Errorf("counter = %d, want 200", counter)
}
km.mu.Lock()
left := len(km.locks)
km.mu.Unlock()
if left != 0 {
t.Errorf("lock map not cleaned up: %d entries left", left)
}
}
+89
View File
@@ -0,0 +1,89 @@
package game
import (
"encoding/json"
"fmt"
"scrabble/backend/internal/engine"
)
// movePayload is the JSON stored in game_moves.payload. It holds the decoded,
// dictionary-independent values needed both to replay the game through the engine
// and to render history / emit GCG without a dictionary (docs/ARCHITECTURE.md
// §9.1): the acting player's rack before the move, and per action the play's
// direction, main-word anchor, placed tiles and formed words, or an exchange's
// swapped tiles.
type movePayload struct {
Rack []string `json:"rack"`
Dir string `json:"dir,omitempty"`
MainRow int `json:"main_row,omitempty"`
MainCol int `json:"main_col,omitempty"`
Tiles []tilePayload `json:"tiles,omitempty"`
Words []string `json:"words,omitempty"`
Exchanged []string `json:"exchanged,omitempty"`
}
// tilePayload is one placed tile in a play payload.
type tilePayload struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank,omitempty"`
}
// buildPayload assembles the journal payload from the engine's decoded record,
// the acting rack captured before the move, and (for an exchange) the swapped
// tiles.
func buildPayload(rec engine.MoveRecord, rackBefore, exchanged []string) movePayload {
p := movePayload{Rack: rackBefore}
switch rec.Action {
case engine.ActionPlay:
p.Dir = rec.Dir.String()
p.MainRow = rec.MainRow
p.MainCol = rec.MainCol
p.Words = rec.Words
p.Tiles = make([]tilePayload, len(rec.Tiles))
for i, t := range rec.Tiles {
p.Tiles[i] = tilePayload{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
}
case engine.ActionExchange:
p.Exchanged = exchanged
}
return p
}
// marshal renders the payload as the JSON text stored in the column.
func (p movePayload) marshal() (string, error) {
b, err := json.Marshal(p)
if err != nil {
return "", fmt.Errorf("game: marshal move payload: %w", err)
}
return string(b), nil
}
// parsePayload parses a stored payload back into its decoded fields.
func parsePayload(s string) (movePayload, error) {
var p movePayload
if err := json.Unmarshal([]byte(s), &p); err != nil {
return movePayload{}, fmt.Errorf("game: parse move payload: %w", err)
}
return p, nil
}
// tileRecords converts payload tiles back into engine TileRecords for replay and
// history.
func (p movePayload) tileRecords() []engine.TileRecord {
out := make([]engine.TileRecord, len(p.Tiles))
for i, t := range p.Tiles {
out[i] = engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
}
return out
}
// direction parses the stored "H"/"V" into an engine.Direction.
func (p movePayload) direction() engine.Direction {
if p.Dir == engine.Vertical.String() {
return engine.Vertical
}
return engine.Horizontal
}
+629
View File
@@ -0,0 +1,629 @@
package game
import (
"context"
crand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
)
// Service is the game domain: it drives the engine over a single match, persists
// the event-sourced journal, keeps live games warm in a cache, serves hints and
// the word-check tool, exports GCG and runs the turn-timeout sweeper. It is the
// only writer of the game tables and is safe for concurrent use (per-game
// serialised by an internal keyed mutex).
type Service struct {
store *Store
accounts *account.Store
registry *engine.Registry
cache *gameCache
locks *keyedMutex
version string
clock func() time.Time
rng func() int64
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
// registry holds the resident dictionaries; cfg supplies the pinned version and
// the cache idle window; log is used by the background sweeper.
func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service {
clock := func() time.Time { return time.Now().UTC() }
return &Service{
store: store,
accounts: accounts,
registry: registry,
cache: newGameCache(cfg.CacheTTL, clock),
locks: newKeyedMutex(),
version: cfg.DictVersion,
clock: clock,
rng: randomSeed,
log: log,
}
}
// Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat
// is a distinct existing account.
func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, error) {
if n := len(params.Seats); n < 2 || n > 4 {
return Game{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidConfig, n)
}
if params.HintsPerPlayer < 0 {
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
}
timeout := params.TurnTimeout
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats {
if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
}
return Game{}, err
}
}
seed := params.Seed
if seed == 0 {
seed = svc.rng()
}
g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
})
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
return Game{}, err
}
id, err := uuid.NewV7()
if err != nil {
return Game{}, fmt.Errorf("game: new id: %w", err)
}
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
seed: seed,
players: len(params.Seats),
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g)
return svc.store.GetGame(ctx, id)
}
// engineOp applies one transition to the live game, returning the decoded record
// and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
// SubmitPlay validates, scores and commits the player's placement.
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.SubmitPlay(dir, tiles)
return rec, nil, err
})
}
// Pass commits a forfeited turn.
func (svc *Service) Pass(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Pass()
return rec, nil, err
})
}
// Exchange swaps the named tiles ("?" for a blank) and commits the turn.
func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.SubmitExchange(tiles)
return rec, tiles, err
})
}
// Resign ends the game on the player's turn; the remaining player wins.
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Resign()
return rec, nil, err
})
}
// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return MoveResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return MoveResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return MoveResult{}, ErrFinished
}
if pre.ToMove != seat {
return MoveResult{}, ErrNotYourTurn
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return MoveResult{}, err
}
if g.Over() {
return MoveResult{}, ErrFinished
}
if g.ToMove() != seat {
return MoveResult{}, ErrNotYourTurn
}
rackBefore := g.Hand(seat)
rec, exchanged, err := op(g)
if err != nil {
return MoveResult{}, err
}
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats)
if err != nil {
return MoveResult{}, err
}
return MoveResult{Move: rec, Game: post}, nil
}
// commit persists a just-applied transition: the journal row, the post-move turn
// cursor and scores, and on a game-ending move the finish stamp and statistics.
// On a persistence failure it evicts the now-divergent live game so the next
// access rebuilds from the journal.
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat) (Game, error) {
now := svc.clock()
logLen := len(g.Log())
scores := make([]int, g.Players())
for i := range scores {
scores[i] = g.Score(i)
}
c := commit{
gameID: gameID,
seq: logLen - 1,
seat: rec.Player,
action: action,
score: rec.Score,
runningTotal: rec.Total,
exchanged: exchanged,
rec: rec,
rackBefore: rackBefore,
toMove: g.ToMove(),
turnStartedAt: now,
moveCount: logLen,
scores: scores,
now: now,
}
if g.Over() {
c.finished = true
c.finishedAt = now
c.endReason = g.Reason().String()
if action == "timeout" {
c.endReason = "timeout"
}
c.winner = g.Result().Winner
c.stats = buildStats(g, seats)
}
if err := svc.store.CommitMove(ctx, c); err != nil {
svc.cache.remove(gameID)
return Game{}, err
}
if c.finished {
svc.cache.remove(gameID)
}
return svc.store.GetGame(ctx, gameID)
}
// timeoutGame auto-resigns the to-move player of an overdue game. It re-checks,
// under the per-game lock, that the game is still active and still past the
// effective deadline (so a move made since the sweep is not clobbered), records
// the move as a timeout, and reports whether it timed the game out.
func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.Time) (bool, error) {
unlock := svc.locks.lock(gameID)
defer unlock()
cur, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return false, err
}
if cur.Status != StatusActive {
return false, nil
}
seat := cur.ToMove
if seat < 0 || seat >= len(cur.Seats) {
return false, nil
}
acc, err := svc.accounts.GetByID(ctx, cur.Seats[seat].AccountID)
if err != nil {
return false, err
}
deadline := effectiveDeadline(cur.TurnStartedAt, cur.TurnTimeout, loadLocation(acc.TimeZone), minutesOfDay(acc.AwayStart), minutesOfDay(acc.AwayEnd))
if now.Before(deadline) {
return false, nil
}
g, err := svc.liveGame(ctx, cur)
if err != nil {
return false, err
}
if g.Over() {
return false, nil
}
rackBefore := g.Hand(g.ToMove())
rec, err := g.Resign()
if err != nil {
return false, err
}
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
return false, err
}
return true, nil
}
// EvaluatePlay previews a tentative play for a seated player against the current
// board without committing it: whether it is legal and what it would score.
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (EvalResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return EvalResult{}, err
}
if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return EvalResult{}, ErrFinished
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return EvalResult{}, err
}
rec, err := g.EvaluatePlay(dir, tiles)
if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
return EvalResult{Valid: false}, nil
}
return EvalResult{}, err
}
return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words}, nil
}
// CheckWord reports whether word is in the game's pinned dictionary. It is the
// unlimited word-check tool; an input outside the variant's alphabet is simply
// not a word.
func (svc *Service) CheckWord(ctx context.Context, gameID uuid.UUID, word string) (bool, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return false, err
}
return svc.lookupWord(pre.Variant, pre.DictVersion, word)
}
// FileComplaint records a word-check complaint against the game's dictionary for
// later admin review, stamping the disputed lookup result.
func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UUID, word, note string) (Complaint, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Complaint{}, err
}
if _, ok := pre.seatOf(accountID); !ok {
return Complaint{}, ErrNotAPlayer
}
normalized := normalizeWord(word)
valid, err := svc.lookupWord(pre.Variant, pre.DictVersion, normalized)
if err != nil {
return Complaint{}, err
}
return svc.store.FileComplaint(ctx, Complaint{
ComplainantID: accountID,
GameID: gameID,
Variant: pre.Variant,
DictVersion: pre.DictVersion,
Word: normalized,
WasValid: valid,
Note: note,
})
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
// appropriate.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return HintResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return HintResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return HintResult{}, ErrFinished
}
if pre.ToMove != seat {
return HintResult{}, ErrNotYourTurn
}
if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return HintResult{}, err
}
used := pre.Seats[seat].HintsUsed
fromAllowance := used < pre.HintsPerPlayer
if !fromAllowance && acc.HintBalance <= 0 {
return HintResult{}, ErrNoHintsLeft
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return HintResult{}, err
}
move, ok := g.HintView()
if !ok {
return HintResult{}, ErrNoHintAvailable
}
walletAfter := acc.HintBalance
if fromAllowance {
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
} else {
spent, err := svc.accounts.SpendHint(ctx, accountID)
if err != nil {
return HintResult{}, err
}
if !spent {
return HintResult{}, ErrNoHintsLeft
}
walletAfter--
}
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
}
// GameState returns a seated player's view of the game: the shared summary plus
// their private rack, the bag size and their remaining hint budget.
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return StateView{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return StateView{}, ErrNotAPlayer
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return StateView{}, err
}
return StateView{
Game: pre,
Seat: seat,
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
}, nil
}
// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return HistoryView{}, err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return HistoryView{}, err
}
return HistoryView{Game: g, Moves: moves}, nil
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return "", err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return "", err
}
return writeGCG(g, svc.seatNames(ctx, g), moves), nil
}
// liveGame returns the live engine.Game for pre, rebuilding it from the journal
// on a cache miss. Callers must hold the per-game lock.
func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) {
if g, ok := svc.cache.get(pre.ID); ok {
return g, nil
}
g, err := svc.replay(ctx, pre)
if err != nil {
return nil, err
}
if !g.Over() {
svc.cache.put(pre.ID, g)
}
return g, nil
}
// replay reconstructs an engine.Game by dealing from the pinned seed and
// re-applying every journalled move in order. The deterministic bag makes the
// reconstruction exact.
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
seed, err := svc.store.GameSeed(ctx, pre.ID)
if err != nil {
return nil, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
})
if err != nil {
return nil, err
}
moves, err := svc.store.GetJournal(ctx, pre.ID)
if err != nil {
return nil, err
}
for _, mv := range moves {
if err := replayMove(g, mv); err != nil {
return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err)
}
}
return g, nil
}
// replayMove re-applies one journalled move to g through the decoded engine API.
func replayMove(g *engine.Game, mv HistoryMove) error {
switch mv.Action {
case "play":
dir := engine.Horizontal
if mv.Dir == "V" {
dir = engine.Vertical
}
_, err := g.SubmitPlay(dir, mv.Tiles)
return err
case "pass":
_, err := g.Pass()
return err
case "exchange":
_, err := g.SubmitExchange(mv.Exchanged)
return err
case "resign", "timeout":
_, err := g.Resign()
return err
default:
return fmt.Errorf("unknown action %q", mv.Action)
}
}
// buildStats derives each seat's statistics contribution from a finished game:
// win/loss/draw from the (resignation-aware) winner, the final score, and the
// best single-move score from the log.
func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result()
best := make(map[int]int)
for _, rec := range g.Log() {
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
best[rec.Player] = rec.Score
}
}
out := make([]statDelta, 0, len(seats))
for _, s := range seats {
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
switch {
case res.Winner < 0:
d.draws = 1
case res.Winner == s.Seat:
d.wins = 1
default:
d.losses = 1
}
out = append(out, d)
}
return out
}
// seatNames resolves each seat's display name for GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players)
for _, s := range g.Seats {
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
names[s.Seat] = acc.DisplayName
}
}
return names
}
// lookupWord checks word against a variant/version dictionary, treating an
// out-of-alphabet input as simply not a word (a real registry error still
// surfaces).
func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bool, error) {
present, err := svc.registry.Lookup(variant, version, normalizeWord(word))
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
return false, err
}
return false, nil
}
return present, nil
}
// hintsRemaining is a player's remaining hint budget: the unspent per-game
// allowance plus the profile wallet.
func hintsRemaining(allowance, used, wallet int) int {
return max(0, allowance-used) + wallet
}
// allowedTimeout reports whether d is one of the offered move clocks.
func allowedTimeout(d time.Duration) bool {
return slices.Contains(AllowedTurnTimeouts, d)
}
// normalizeWord lower-cases and trims a word-check input to the alphabet's form.
func normalizeWord(word string) string {
return strings.ToLower(strings.TrimSpace(word))
}
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
// system source fails.
func randomSeed() int64 {
var b [8]byte
if _, err := crand.Read(b[:]); err != nil {
return time.Now().UnixNano()
}
return int64(binary.LittleEndian.Uint64(b[:]))
}
+439
View File
@@ -0,0 +1,439 @@
package game
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Store is the Postgres-backed query surface for games, seats, the move journal,
// complaints and per-account statistics.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// gameInsert carries the immutable fields of a new game.
type gameInsert struct {
id uuid.UUID
variant string
dictVersion string
seed int64
players int
turnTimeoutSecs int
hintsAllowed bool
hintsPerPlayer int
}
// statDelta is one account's contribution to its statistics on a game finish.
type statDelta struct {
accountID uuid.UUID
wins int
losses int
draws int
gamePoints int
wordPoints int
}
// commit is everything a single committed transition persists: the journal row,
// the post-move game cursor and per-seat scores, and — when the move ended the
// game — the finish stamp and the statistics deltas.
type commit struct {
gameID uuid.UUID
seq int
seat int
action string
score int
runningTotal int
exchanged []string
rec engine.MoveRecord
rackBefore []string
toMove int
turnStartedAt time.Time
moveCount int
scores []int
now time.Time
finished bool
endReason string
finishedAt time.Time
winner int // -1 on a draw
stats []statDelta
}
// activeGame is the sweeper's view of an in-progress game's turn clock.
type activeGame struct {
gameID uuid.UUID
toMove int
turnStartedAt time.Time
turnTimeoutSecs int
}
// CreateGame inserts the games row and one game_players row per seat (seat 0
// first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
gi := table.Games.INSERT(
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
for seat, accountID := range seats {
pi := table.GamePlayers.INSERT(
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
).VALUES(ins.id, seat, accountID)
if _, err := pi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert seat %d: %w", seat, err)
}
}
return nil
})
}
// GetGame loads the games row joined with its seats (ordered by seat), or
// ErrNotFound.
func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
gstmt := postgres.SELECT(table.Games.AllColumns).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var grow model.Games
if err := gstmt.QueryContext(ctx, s.db, &grow); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Game{}, ErrNotFound
}
return Game{}, fmt.Errorf("game: get %s: %w", id, err)
}
sstmt := postgres.SELECT(table.GamePlayers.AllColumns).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GamePlayers.Seat.ASC())
var srows []model.GamePlayers
if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil {
return Game{}, fmt.Errorf("game: get seats %s: %w", id, err)
}
return projectGame(grow, srows)
}
// GetJournal loads the ordered, decoded move journal for a game.
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
stmt := postgres.SELECT(table.GameMoves.AllColumns).
FROM(table.GameMoves).
WHERE(table.GameMoves.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GameMoves.Seq.ASC())
var rows []model.GameMoves
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: get journal %s: %w", id, err)
}
out := make([]HistoryMove, 0, len(rows))
for _, r := range rows {
p, err := parsePayload(r.Payload)
if err != nil {
return nil, err
}
out = append(out, HistoryMove{
Seq: int(r.Seq),
Seat: int(r.Seat),
Action: r.Action,
Score: int(r.Score),
RunningTotal: int(r.RunningTotal),
Dir: p.Dir,
MainRow: p.MainRow,
MainCol: p.MainCol,
Tiles: p.tileRecords(),
Words: p.Words,
Exchanged: p.Exchanged,
Rack: p.Rack,
})
}
return out, nil
}
// CommitMove appends the move and applies the post-move game state — the turn
// cursor and per-seat scores, plus the finish stamp and statistics when the move
// ended the game — in one transaction.
func (s *Store) CommitMove(ctx context.Context, c commit) error {
payload, err := buildPayload(c.rec, c.rackBefore, c.exchanged).marshal()
if err != nil {
return err
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
mi := table.GameMoves.INSERT(
table.GameMoves.GameID, table.GameMoves.Seq, table.GameMoves.Seat, table.GameMoves.Action,
table.GameMoves.Score, table.GameMoves.RunningTotal, table.GameMoves.ExchangedCount, table.GameMoves.Payload,
).VALUES(c.gameID, c.seq, c.seat, c.action, c.score, c.runningTotal, len(c.exchanged), payload)
if _, err := mi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("append move: %w", err)
}
if c.finished {
gu := table.Games.UPDATE(
table.Games.Status, table.Games.ToMove, table.Games.MoveCount,
table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt,
).SET(
postgres.String(StatusFinished), postgres.Int(int64(c.toMove)), postgres.Int(int64(c.moveCount)),
postgres.String(c.endReason), postgres.TimestampzT(c.now), postgres.TimestampzT(c.finishedAt),
).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID)))
if _, err := gu.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("finish game: %w", err)
}
} else {
gu := table.Games.UPDATE(
table.Games.ToMove, table.Games.TurnStartedAt, table.Games.MoveCount, table.Games.UpdatedAt,
).SET(
postgres.Int(int64(c.toMove)), postgres.TimestampzT(c.turnStartedAt), postgres.Int(int64(c.moveCount)), postgres.TimestampzT(c.now),
).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID)))
if _, err := gu.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("advance game: %w", err)
}
}
for seat, score := range c.scores {
if err := updateSeatScore(ctx, tx, c.gameID, seat, score, c.finished, c.finished && seat == c.winner); err != nil {
return fmt.Errorf("update seat %d: %w", seat, err)
}
}
if c.finished {
for _, d := range c.stats {
if err := upsertStats(ctx, tx, d, c.now); err != nil {
return err
}
}
}
return nil
})
}
// updateSeatScore writes a seat's running score, also stamping is_winner when the
// game has finished.
func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error {
where := table.GamePlayers.GameID.EQ(postgres.UUID(gameID)).
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat))))
var stmt postgres.UpdateStatement
if finished {
stmt = table.GamePlayers.
UPDATE(table.GamePlayers.Score, table.GamePlayers.IsWinner).
SET(postgres.Int(int64(score)), postgres.Bool(isWinner)).
WHERE(where)
} else {
stmt = table.GamePlayers.
UPDATE(table.GamePlayers.Score).
SET(postgres.Int(int64(score))).
WHERE(where)
}
_, err := stmt.ExecContext(ctx, tx)
return err
}
// upsertStats folds one account's deltas into account_stats, locking the row for
// the read-modify-write so concurrent finishes accumulate correctly.
func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
ensure := table.AccountStats.
INSERT(table.AccountStats.AccountID).
VALUES(d.accountID).
ON_CONFLICT(table.AccountStats.AccountID).
DO_NOTHING()
if _, err := ensure.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("ensure stats %s: %w", d.accountID, err)
}
sel := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID))).
FOR(postgres.UPDATE())
var row model.AccountStats
if err := sel.QueryContext(ctx, tx, &row); err != nil {
return fmt.Errorf("lock stats %s: %w", d.accountID, err)
}
wins := row.Wins + int32(d.wins)
losses := row.Losses + int32(d.losses)
draws := row.Draws + int32(d.draws)
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
).SET(
postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("update stats %s: %w", d.accountID, err)
}
return nil
}
// SpendHintAllowance increments a seat's per-game hint counter by one.
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
stmt := table.GamePlayers.
UPDATE(table.GamePlayers.HintsUsed).
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
WHERE(
table.GamePlayers.GameID.EQ(postgres.UUID(gameID)).
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("game: spend hint allowance: %w", err)
}
return nil
}
// FileComplaint persists a word-check complaint in status open and returns the
// stored row.
func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, error) {
id, err := uuid.NewV7()
if err != nil {
return Complaint{}, fmt.Errorf("game: new complaint id: %w", err)
}
stmt := table.Complaints.INSERT(
table.Complaints.ComplaintID, table.Complaints.ComplainantID, table.Complaints.GameID,
table.Complaints.Variant, table.Complaints.DictVersion, table.Complaints.Word,
table.Complaints.WasValid, table.Complaints.Note,
).VALUES(
id, c.ComplainantID, c.GameID, c.Variant.String(), c.DictVersion, c.Word, c.WasValid, c.Note,
).RETURNING(table.Complaints.AllColumns)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Complaint{}, fmt.Errorf("game: file complaint: %w", err)
}
return projectComplaint(row)
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
stmt := postgres.SELECT(
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, table.Games.TurnTimeoutSecs,
).FROM(table.Games).
WHERE(table.Games.Status.EQ(postgres.String(StatusActive))).
ORDER_BY(table.Games.TurnStartedAt.ASC())
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list active: %w", err)
}
out := make([]activeGame, 0, len(rows))
for _, r := range rows {
out = append(out, activeGame{
gameID: r.GameID,
toMove: int(r.ToMove),
turnStartedAt: r.TurnStartedAt,
turnTimeoutSecs: int(r.TurnTimeoutSecs),
})
}
return out, nil
}
// GameSeed returns the bag seed a game was dealt from, used to replay it. The
// seed is server-only state and never travels in the public Game view.
func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
stmt := postgres.SELECT(table.Games.Seed).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("game: get seed %s: %w", id, err)
}
return row.Seed, nil
}
// projectGame builds a Game from a games row and its ordered seat rows.
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
variant, err := engine.ParseVariant(g.Variant)
if err != nil {
return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
}
out := Game{
ID: g.GameID,
Variant: variant,
DictVersion: g.DictVersion,
Status: g.Status,
Players: int(g.Players),
ToMove: int(g.ToMove),
TurnStartedAt: g.TurnStartedAt,
TurnTimeout: time.Duration(g.TurnTimeoutSecs) * time.Second,
HintsAllowed: g.HintsAllowed,
HintsPerPlayer: int(g.HintsPerPlayer),
MoveCount: int(g.MoveCount),
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
if g.EndReason != nil {
out.EndReason = *g.EndReason
}
if g.FinishedAt != nil {
t := *g.FinishedAt
out.FinishedAt = &t
}
out.Seats = make([]Seat, 0, len(seats))
for _, p := range seats {
out.Seats = append(out.Seats, Seat{
Seat: int(p.Seat),
AccountID: p.AccountID,
Score: int(p.Score),
HintsUsed: int(p.HintsUsed),
IsWinner: p.IsWinner,
})
}
return out, nil
}
// projectComplaint builds a Complaint from a stored row.
func projectComplaint(row model.Complaints) (Complaint, error) {
variant, err := engine.ParseVariant(row.Variant)
if err != nil {
return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
}
return Complaint{
ID: row.ComplaintID,
ComplainantID: row.ComplainantID,
GameID: row.GameID,
Variant: variant,
DictVersion: row.DictVersion,
Word: row.Word,
WasValid: row.WasValid,
Note: row.Note,
Status: row.Status,
CreatedAt: row.CreatedAt,
}, nil
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package game
import (
"context"
"time"
"go.uber.org/zap"
)
// effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline
// (turn start plus the per-game timeout) unless that instant falls inside the
// acting player's away window (a daily local-time interval in loc), in which case
// it is pushed to the end of that window — so a player is never timed out while
// asleep. awayStartMin and awayEndMin are minutes since local midnight; an empty
// window (start == end) disables the grace. The function is pure and total.
func effectiveDeadline(turnStartedAt time.Time, timeout time.Duration, loc *time.Location, awayStartMin, awayEndMin int) time.Time {
raw := turnStartedAt.Add(timeout)
if awayStartMin == awayEndMin {
return raw
}
local := raw.In(loc)
dlMin := local.Hour()*60 + local.Minute()
in, endToday := inAwayWindow(dlMin, awayStartMin, awayEndMin)
if !in {
return raw
}
y, m, d := local.Date()
end := time.Date(y, m, d, awayEndMin/60, awayEndMin%60, 0, 0, loc)
if !endToday {
end = end.AddDate(0, 0, 1)
}
return end
}
// inAwayWindow reports whether the minute-of-day dlMin lies inside the window
// [start, end) (which may wrap past midnight) and whether the window's end falls
// on the same local day as dlMin.
func inAwayWindow(dlMin, start, end int) (in, endToday bool) {
if start < end {
inside := dlMin >= start && dlMin < end
return inside, inside // a non-wrapping window always ends the same day
}
// Wraps past midnight: [start, 1440) on the evening side, [0, end) on the
// morning side.
switch {
case dlMin >= start:
return true, false // evening: the window ends the next local day
case dlMin < end:
return true, true // morning: the window ends later today
default:
return false, false
}
}
// minutesOfDay returns a time-of-day value's minutes since midnight.
func minutesOfDay(t time.Time) int {
return t.Hour()*60 + t.Minute()
}
// loadLocation resolves an IANA timezone name, falling back to UTC when it is
// empty or unknown (so a bad profile value never breaks the sweeper).
func loadLocation(name string) *time.Location {
if name == "" {
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
}
// SweepTimeouts auto-resigns every active game whose current turn has exceeded
// its effective deadline as of now. It cheaply filters games past the raw
// deadline, then defers to timeoutGame, which confirms the away-window-adjusted
// deadline under the per-game lock. It returns the number of games timed out; a
// per-game failure is logged and skipped so one bad game does not stall the
// sweep.
func (svc *Service) SweepTimeouts(ctx context.Context, now time.Time) (int, error) {
games, err := svc.store.ActiveGames(ctx)
if err != nil {
return 0, err
}
var timedOut int
for _, ag := range games {
if now.Before(ag.turnStartedAt.Add(time.Duration(ag.turnTimeoutSecs) * time.Second)) {
continue // not even past the raw deadline
}
did, err := svc.timeoutGame(ctx, ag.gameID, now)
if err != nil {
svc.log.Warn("timeout sweep", zap.String("game", ag.gameID.String()), zap.Error(err))
continue
}
if did {
timedOut++
}
}
return timedOut, nil
}
// RunSweeper drives SweepTimeouts and evicts idle games from the cache on each
// tick until ctx is cancelled. It is started once from main.
func (svc *Service) RunSweeper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := svc.SweepTimeouts(ctx, svc.clock()); err != nil {
svc.log.Warn("timeout sweep failed", zap.Error(err))
} else if n > 0 {
svc.log.Info("timed out games", zap.Int("count", n))
}
svc.cache.sweep()
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package game
import (
"errors"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// Status values persisted in the games.status column.
const (
StatusActive = "active"
StatusFinished = "finished"
)
// ComplaintStatus values; Stage 9 owns the resolution lifecycle, Stage 3 only
// ever writes StatusComplaintOpen.
const StatusComplaintOpen = "open"
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
// transitions, so callers may match either taxonomy with errors.Is.
var (
// ErrNotFound is returned when no game matches the lookup.
ErrNotFound = errors.New("game: not found")
// ErrNotYourTurn is returned when an account acts out of turn.
ErrNotYourTurn = errors.New("game: not the player's turn")
// ErrFinished is returned when a transition is attempted on a finished game.
ErrFinished = errors.New("game: game is finished")
// ErrNotAPlayer is returned when an account is not seated in the game.
ErrNotAPlayer = errors.New("game: account is not a player in this game")
// ErrInvalidConfig is returned when CreateParams are not acceptable.
ErrInvalidConfig = errors.New("game: invalid game configuration")
// ErrHintsDisabled is returned when hints are switched off for the game.
ErrHintsDisabled = errors.New("game: hints are disabled for this game")
// ErrNoHintsLeft is returned when the player's allowance and wallet are spent.
ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
)
// AllowedTurnTimeouts is the set of per-game move clocks a game may be created
// with, matching the values offered at creation. DefaultTurnTimeout is used when
// none is requested.
var AllowedTurnTimeouts = []time.Duration{
5 * time.Minute, 10 * time.Minute, 15 * time.Minute, 30 * time.Minute,
1 * time.Hour, 2 * time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour,
}
// DefaultTurnTimeout is the move clock applied when CreateParams.TurnTimeout is
// zero (the owner's default: a full day).
const DefaultTurnTimeout = 24 * time.Hour
// CreateParams describes a new game. Seats lists the seated accounts in turn
// order (seat 0 moves first); lobby/matchmaking assembles it in a later stage.
type CreateParams struct {
Variant engine.Variant
Seats []uuid.UUID
TurnTimeout time.Duration // one of AllowedTurnTimeouts; zero → DefaultTurnTimeout
HintsAllowed bool
HintsPerPlayer int // starting per-seat hint allowance
Seed int64 // zero → a random seed is chosen
}
// Game is the persisted state of a match: the games row joined with its seats.
type Game struct {
ID uuid.UUID
Variant engine.Variant
DictVersion string
Status string
Players int
ToMove int
TurnStartedAt time.Time
TurnTimeout time.Duration
HintsAllowed bool
HintsPerPlayer int
MoveCount int
EndReason string // "" while active
Seats []Seat
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
}
// Seat is one player's standing in a game.
type Seat struct {
Seat int
AccountID uuid.UUID
Score int
HintsUsed int
IsWinner bool
}
// seatOf returns the seat index of accountID and true, or (0, false) when the
// account is not seated.
func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
for _, s := range g.Seats {
if s.AccountID == accountID {
return s.Seat, true
}
}
return 0, false
}
// MoveResult is the outcome of a committed transition: the decoded move and the
// post-move game.
type MoveResult struct {
Move engine.MoveRecord
Game Game
}
// HintResult is a revealed hint and the requesting player's remaining hint
// budget (per-seat allowance plus profile wallet) after spending one.
type HintResult struct {
Move engine.MoveRecord
HintsRemaining int
}
// EvalResult previews a tentative play without committing it.
type EvalResult struct {
Valid bool
Score int
Words []string
}
// StateView is a player's view of a game: the shared game plus their private
// rack and remaining hint budget. The board for live rendering is reconstructed
// by the client from History; it is added to the gateway surface in a later
// stage.
type StateView struct {
Game Game
Seat int
Rack []string
BagLen int
HintsRemaining int
}
// HistoryMove is one decoded journal row, independent of any dictionary.
type HistoryMove struct {
Seq int
Seat int
Action string
Score int
RunningTotal int
Dir string // play only: "H"/"V"
MainRow int // play only: main word's first-letter row
MainCol int // play only: main word's first-letter column
Tiles []engine.TileRecord
Words []string
Exchanged []string // exchange only
Rack []string // the acting player's rack before the move
}
// HistoryView is a game's full, dictionary-independent move history.
type HistoryView struct {
Game Game
Moves []HistoryMove
}
// Complaint is a word-check complaint awaiting admin review (Stage 9).
type Complaint struct {
ID uuid.UUID
ComplainantID uuid.UUID
GameID uuid.UUID
Variant engine.Variant
DictVersion string
Word string
WasValid bool
Note string
Status string
CreatedAt time.Time
}