751e74b14f
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.
181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// TestDirectionString covers the H/V rendering used by the journal and GCG.
|
|
func TestDirectionString(t *testing.T) {
|
|
if Horizontal.String() != "H" {
|
|
t.Errorf("Horizontal = %q, want H", Horizontal.String())
|
|
}
|
|
if Vertical.String() != "V" {
|
|
t.Errorf("Vertical = %q, want V", Vertical.String())
|
|
}
|
|
}
|
|
|
|
// TestSubmitPlayMatchesHint plays the decoded top-1 move through SubmitPlay and
|
|
// checks it scores and advances exactly like the underlying solver move, proving
|
|
// the decode→encode round trip.
|
|
func TestSubmitPlayMatchesHint(t *testing.T) {
|
|
g := openingGame(t)
|
|
hint, ok := g.HintView()
|
|
if !ok {
|
|
t.Fatal("opening game has no hint")
|
|
}
|
|
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
|
if err != nil {
|
|
t.Fatalf("submit play: %v", err)
|
|
}
|
|
if rec.Score != hint.Score {
|
|
t.Errorf("played score = %d, want hint score %d", rec.Score, hint.Score)
|
|
}
|
|
if rec.Action != ActionPlay {
|
|
t.Errorf("action = %v, want play", rec.Action)
|
|
}
|
|
if g.Score(0) != hint.Score {
|
|
t.Errorf("player 0 score = %d, want %d", g.Score(0), hint.Score)
|
|
}
|
|
if g.ToMove() != 1 {
|
|
t.Errorf("to move = %d, want 1 after a play", g.ToMove())
|
|
}
|
|
}
|
|
|
|
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
|
|
// leaves the board, scores, turn and bag untouched.
|
|
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
|
g := openingGame(t)
|
|
hint, ok := g.HintView()
|
|
if !ok {
|
|
t.Fatal("opening game has no hint")
|
|
}
|
|
boardBefore := g.BoardClone()
|
|
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
|
|
|
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
|
|
if err != nil {
|
|
t.Fatalf("evaluate play: %v", err)
|
|
}
|
|
if rec.Score != hint.Score {
|
|
t.Errorf("evaluated score = %d, want %d", rec.Score, hint.Score)
|
|
}
|
|
if !boardsEqual(boardBefore, g.BoardClone()) {
|
|
t.Error("evaluate must not mutate the board")
|
|
}
|
|
if g.Score(0) != scoreBefore || g.ToMove() != toMoveBefore || g.BagLen() != bagBefore {
|
|
t.Errorf("evaluate mutated state: score %d->%d, toMove %d->%d, bag %d->%d",
|
|
scoreBefore, g.Score(0), toMoveBefore, g.ToMove(), bagBefore, g.BagLen())
|
|
}
|
|
}
|
|
|
|
// TestEvaluatePlayRejectsIllegal reports ErrIllegalPlay for a play the solver
|
|
// rejects (a single off-centre opening tile) without committing.
|
|
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
|
g := newEnglishGame(t, 1)
|
|
letter := g.Hand(0)[0]
|
|
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
|
|
if !errors.Is(err, ErrIllegalPlay) {
|
|
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
|
}
|
|
}
|
|
|
|
// TestSubmitExchangeWithBlank exchanges a full rack that includes a blank,
|
|
// exercising the "?" encoding path, and checks the turn advances.
|
|
func TestSubmitExchangeWithBlank(t *testing.T) {
|
|
g := gameWithBlankInHand(t)
|
|
hand := g.Hand(0)
|
|
if !slices.Contains(hand, blankLetter) {
|
|
t.Fatalf("hand %v has no blank", hand)
|
|
}
|
|
rec, err := g.SubmitExchange(hand)
|
|
if err != nil {
|
|
t.Fatalf("submit exchange: %v", err)
|
|
}
|
|
if rec.Action != ActionExchange || rec.Count != len(hand) {
|
|
t.Errorf("exchange record = %+v, want action exchange count %d", rec, len(hand))
|
|
}
|
|
if g.ToMove() != 1 {
|
|
t.Errorf("to move = %d, want 1 after an exchange", g.ToMove())
|
|
}
|
|
}
|
|
|
|
// TestHandDecodesBlank checks Hand returns concrete letters and "?" for a blank,
|
|
// agreeing with the internal hand.
|
|
func TestHandDecodesBlank(t *testing.T) {
|
|
g := gameWithBlankInHand(t)
|
|
hand := g.Hand(0)
|
|
if len(hand) != g.rules.RackSize {
|
|
t.Fatalf("hand size = %d, want %d", len(hand), g.rules.RackSize)
|
|
}
|
|
var blanks int
|
|
for _, s := range hand {
|
|
if s == "" {
|
|
t.Errorf("hand %v has an empty letter", hand)
|
|
}
|
|
if s == blankLetter {
|
|
blanks++
|
|
}
|
|
}
|
|
var want int
|
|
for _, t := range g.hands[0] {
|
|
if t == blankTile {
|
|
want++
|
|
}
|
|
}
|
|
if blanks != want {
|
|
t.Errorf("decoded blanks = %d, want %d", blanks, want)
|
|
}
|
|
}
|
|
|
|
// TestRegistryLookup covers word-check membership and its error taxonomy.
|
|
func TestRegistryLookup(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
variant Variant
|
|
word string
|
|
want bool
|
|
}{
|
|
{"english hit", VariantEnglish, "cat", true},
|
|
{"english miss", VariantEnglish, "zzzz", false},
|
|
{"russian hit", VariantRussianScrabble, "кот", true},
|
|
{"erudit hit", VariantErudit, "кот", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := testReg.Lookup(tc.variant, testVersion, tc.word)
|
|
if err != nil {
|
|
t.Fatalf("lookup: %v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("lookup %q = %v, want %v", tc.word, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
if _, err := testReg.Lookup(VariantEnglish, "missing", "cat"); !errors.Is(err, ErrUnknownVersion) {
|
|
t.Errorf("unknown version = %v, want ErrUnknownVersion", err)
|
|
}
|
|
if _, err := NewRegistry().Lookup(VariantEnglish, testVersion, "cat"); !errors.Is(err, ErrUnknownVariant) {
|
|
t.Errorf("empty registry = %v, want ErrUnknownVariant", err)
|
|
}
|
|
if _, err := testReg.Lookup(VariantEnglish, testVersion, "кот"); err == nil {
|
|
t.Error("out-of-alphabet lookup must error")
|
|
}
|
|
}
|
|
|
|
// gameWithBlankInHand returns a two-player English game whose player 0 holds at
|
|
// least one blank, searching a deterministic range of seeds.
|
|
func gameWithBlankInHand(t *testing.T) *Game {
|
|
t.Helper()
|
|
for seed := int64(1); seed <= 200; seed++ {
|
|
g := newEnglishGame(t, seed)
|
|
if slices.Contains(g.Hand(0), blankLetter) {
|
|
return g
|
|
}
|
|
}
|
|
t.Fatal("no opening rack with a blank found in seeds 1..200")
|
|
return nil
|
|
}
|