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

internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 17:33:49 +02:00
parent f36f3df748
commit 751e74b14f
45 changed files with 4220 additions and 103 deletions
+37 -22
View File
@@ -72,6 +72,7 @@ type Game struct {
scorelessRun int
over bool
reason EndReason
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
log []MoveRecord
}
@@ -98,14 +99,15 @@ func New(reg *Registry, opts Options) (*Game, error) {
rs := solver.Rules()
g := &Game{
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resignedSeat: -1,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -193,14 +195,19 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
return rec, nil
}
// Resign ends the game on the current player's turn (EndReason EndResign). In a
// two-player match this is the only resignation case; richer multi-player
// handling belongs to the game domain in a later stage.
// Resign ends the game on the current player's turn (EndReason EndResign). The
// resigner always forfeits the win and keeps their accumulated score (it is
// neither zeroed nor docked a rack adjustment); the win goes to the highest
// score among the remaining seats — in a two-player match, unconditionally to
// the other player. A missed-turn timeout reuses Resign in the game domain, so
// it inherits this win/loss. Richer multi-player drop-out handling belongs to
// the game domain in a later stage.
func (g *Game) Resign() (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resignedSeat = player
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
g.log = append(g.log, rec)
g.finish(EndResign)
@@ -288,10 +295,13 @@ func (g *Game) finish(reason EndReason) {
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
// empty, rack empty) they gain the sum of every opponent's rack value and each
// opponent loses their own; otherwise (scoreless stalemate or resignation) each
// player simply forfeits their own rack value.
// opponent loses their own. A scoreless stalemate forfeits each player's own
// rack value. A resignation freezes the scores: the win is decided by winner
// (which excludes the resigner), so no rack adjustment is applied and the
// resigner keeps their accumulated score.
func (g *Game) applyEndAdjustment(reason EndReason) {
if reason == EndOutOfTiles {
switch reason {
case EndOutOfTiles:
out := g.toMove
var bonus int
for i := range g.hands {
@@ -303,10 +313,10 @@ func (g *Game) applyEndAdjustment(reason EndReason) {
bonus += v
}
g.scores[out] += bonus
return
}
for i := range g.hands {
g.scores[i] -= g.rackValue(i)
case EndScoreless:
for i := range g.hands {
g.scores[i] -= g.rackValue(i)
}
}
}
@@ -324,15 +334,20 @@ func (g *Game) endTurnAfterScoreless() {
func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) }
// winner returns the index of the single highest-scoring player, or -1 on a tie
// for the lead or while the game is unfinished.
// for the lead or while the game is unfinished. After a resignation the resigner
// is excluded, so a two-player game returns the remaining player even when the
// resigner led on score.
func (g *Game) winner() int {
if !g.over {
return -1
}
best, tie := 0, false
for i := 1; i < len(g.scores); i++ {
best, tie := -1, false
for i := range g.scores {
if g.reason == EndResign && i == g.resignedSeat {
continue
}
switch {
case g.scores[i] > g.scores[best]:
case best == -1 || g.scores[i] > g.scores[best]:
best, tie = i, false
case g.scores[i] == g.scores[best]:
tie = true