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