85baabe4ba
- internal/robot: durable kind='robot' account pool (migration 00004); every per-game and per-turn choice derived deterministically from the game seed (restart-stable FNV mix); a background move driver; margin targeting (band 1-30, closest-to-band); right-skewed [2,90]min delays (median ~10m); opponent-anchored sleep with +/-3h drift; daytime nudge reply + proactive 12h nudge; friend/chat blocked via profile toggles. - engine.Candidates (decoded ranked plays); game.Candidates + RobotTurns; social.LastNudgeAt. - matchmaker: 10s wait then robot substitution (reaper) + Poll delivery seam. - config (BACKEND_ROBOT_DRIVE_INTERVAL, BACKEND_LOBBY_ROBOT_WAIT, BACKEND_LOBBY_REAPER_INTERVAL); main wiring + boot-time pool provisioning. - metrics: robot account_stats (authoritative balance) + robot_games_finished_total OTel counter + per-finish log. - docs: PLAN, ARCHITECTURE, FUNCTIONAL(+ru), TESTING, README; account.go comment. - tests: robot strategy units, matchmaker reaper/Poll, engine.Candidates; inttest robot full-game / substitution / proactive-nudge.
212 lines
6.4 KiB
Go
212 lines
6.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())
|
|
}
|
|
}
|
|
|
|
// TestCandidatesRankedAndMatchesHint checks that Candidates decodes every
|
|
// generated move, ranks them by descending score, and leads with the same move
|
|
// HintView reveals.
|
|
func TestCandidatesRankedAndMatchesHint(t *testing.T) {
|
|
g := openingGame(t)
|
|
cands := g.Candidates()
|
|
if len(cands) == 0 {
|
|
t.Fatal("opening game has no candidates")
|
|
}
|
|
if got, want := len(cands), len(g.GenerateMoves()); got != want {
|
|
t.Errorf("candidate count = %d, want %d (one per generated move)", got, want)
|
|
}
|
|
for i := 1; i < len(cands); i++ {
|
|
if cands[i-1].Score < cands[i].Score {
|
|
t.Errorf("candidates not ranked: [%d].Score=%d < [%d].Score=%d", i-1, cands[i-1].Score, i, cands[i].Score)
|
|
}
|
|
}
|
|
hint, ok := g.HintView()
|
|
if !ok {
|
|
t.Fatal("opening game has no hint")
|
|
}
|
|
if cands[0].Score != hint.Score {
|
|
t.Errorf("top candidate score = %d, want hint score %d", cands[0].Score, hint.Score)
|
|
}
|
|
for _, c := range cands {
|
|
if c.Action != ActionPlay {
|
|
t.Errorf("candidate action = %v, want play", c.Action)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|