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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user