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