Files
scrabble-solver/scrabble/gcg_test.go
T
Ilia Denisov 256999b42c Publish as versioned Gitea module; move dictionary pipeline out
- Rename module to gitea.iliadenisov.ru/developer/scrabble-solver so it can be
  consumed as a versioned dependency (no go.work replace / CI clone).
- De-internalize wordlist and dictdawg as public packages.
- Remove cmd/builddict, dictprep/, the dictionaries submodule and the dawg
  Makefile: the word-list parsing and DAWG build now live in the separate
  scrabble-dictionary repository, which publishes the DAWG set as a release artifact.
- internal/dict loads the committed dawg/en_sowpods.dawg fixture for cmd/stress.
- Update README/CLAUDE docs accordingly.
2026-06-04 19:11:46 +02:00

188 lines
5.5 KiB
Go

package scrabble
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/dictdawg"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// TestScoreRealGames replays real tournament games recorded in GCG format and checks that
// our scoring reproduces, move for move, the score and running total written in the
// protocol. This validates the scoring engine against canonical play, not invented cases.
//
// The games come from cross-tables.com (annotated self-play) and are stored under
// testdata/. They use the standard English board and SOWPODS, so the test loads the
// committed dawg/en_sowpods.dawg.
func TestScoreRealGames(t *testing.T) {
finder, err := dictdawg.Load("../dawg/en_sowpods.dawg")
if err != nil {
t.Skipf("need dawg/en_sowpods.dawg: %v", err)
}
s := NewSolver(rules.English(), finder)
games, _ := filepath.Glob("testdata/*.gcg")
if len(games) == 0 {
t.Fatal("no GCG games in testdata/")
}
for _, g := range games {
t.Run(filepath.Base(g), func(t *testing.T) { replayGCG(t, s, g) })
}
}
// parsePos decodes a GCG coordinate into a 0-based square and orientation. A leading digit
// means an across (horizontal) play ("11J"), a leading letter means a down (vertical) one
// ("H7"); rows are 1..15 and columns A..O.
func parsePos(p string) (row, col int, dir Direction, ok bool) {
if len(p) < 2 {
return 0, 0, 0, false
}
if p[0] >= '1' && p[0] <= '9' { // number first -> horizontal (across)
i := 0
for i < len(p) && p[i] >= '0' && p[i] <= '9' {
i++
}
if i+1 != len(p) || p[i] < 'A' || p[i] > 'O' {
return 0, 0, 0, false
}
n, _ := strconv.Atoi(p[:i])
return n - 1, int(p[i] - 'A'), Horizontal, true
}
if p[0] >= 'A' && p[0] <= 'O' { // letter first -> vertical (down)
for i := 1; i < len(p); i++ {
if p[i] < '0' || p[i] > '9' {
return 0, 0, 0, false
}
}
n, _ := strconv.Atoi(p[1:])
return n - 1, int(p[0] - 'A'), Vertical, true
}
return 0, 0, 0, false
}
// parseRack splits a GCG rack ("ACEILRT", "?GOORRS") into lowercase letters and a blank
// count, ready for makeRack.
func parseRack(s string) (string, int) {
var letters []rune
blanks := 0
for _, ch := range s {
switch {
case ch == '?':
blanks++
case ch >= 'A' && ch <= 'Z':
letters = append(letters, ch+('a'-'A'))
case ch >= 'a' && ch <= 'z':
letters = append(letters, ch)
}
}
return string(letters), blanks
}
// parseWord turns a GCG word into the newly-placed tiles starting at (row,col) along dir.
// "." marks an existing played-through tile (skipped); a lowercase letter is a blank.
func parseWord(word string, row, col int, dir Direction) []Placement {
var ts []Placement
for _, ch := range word {
if ch != '.' {
switch {
case ch >= 'A' && ch <= 'Z':
ts = append(ts, Placement{Row: row, Col: col, Letter: byte(ch - 'A')})
case ch >= 'a' && ch <= 'z':
ts = append(ts, Placement{Row: row, Col: col, Letter: byte(ch - 'a'), Blank: true})
}
}
if dir == Horizontal {
col++
} else {
row++
}
}
return ts
}
func replayGCG(t *testing.T, s *Solver, path string) {
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
b := board.New(s.rules.Rows, s.rules.Cols)
total := map[string]int{}
var last Move // the last applied play, undone by a phony withdrawal ("--")
plays := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, ">") {
continue // pragma or note
}
colon := strings.Index(line, ":")
player := line[1:colon]
toks := strings.Fields(line[colon+1:])
if len(toks) < 2 {
continue
}
// score = the +N/-N token; cumulative = the last token.
want, _ := strconv.Atoi(strings.TrimPrefix(toks[len(toks)-2], "+"))
cumul, _ := strconv.Atoi(toks[len(toks)-1])
switch row, col, dir, ok := parsePos(toks[1]); {
case ok: // a regular play: RACK POS WORD +SCORE CUMUL
ts := parseWord(toks[2], row, col, dir)
m, err := s.ScorePlay(b, dir, ts)
if err != nil {
t.Fatalf("%s: ScorePlay %q at %s: %v", path, toks[2], toks[1], err)
}
if m.Score != want {
t.Errorf("%s: %q at %s scored %d, want %d", path, toks[2], toks[1], m.Score, want)
}
// A dictionary-valid play must also be produced by the generator from the
// player's rack; phonies (not in SOWPODS) are correctly never generated.
if _, verr := s.ValidatePlay(b, dir, ts); verr == nil {
key, found := moveKey(dir, ts), false
for _, mv := range s.GenerateMoves(b, makeRack(parseRack(toks[0])), Both) {
if mv.Key() == key {
found = true
if mv.Score != want {
t.Errorf("%s: generated %q at %s scored %d, want %d", path, toks[2], toks[1], mv.Score, want)
}
break
}
}
if !found {
t.Errorf("%s: generator did not produce %q at %s from rack %s", path, toks[2], toks[1], toks[0])
}
}
Apply(b, m)
last = m
total[player] += m.Score
plays++
case toks[1] == "--": // a challenged-off phony: undo the previous play
for _, p := range last.Tiles {
b.Set(p.Row, p.Col, 0)
}
last = Move{}
total[player] += want
default: // pass, exchange, challenge bonus, time penalty, end-game rack adjustment
total[player] += want
}
if total[player] != cumul {
t.Errorf("%s: %s running total %d, want %d (after %q)", path, player, total[player], cumul, line)
}
}
if err := sc.Err(); err != nil {
t.Fatal(err)
}
if plays == 0 {
t.Fatalf("%s: no plays parsed", path)
}
t.Logf("%s: %d scored plays, final totals %v", path, plays, total)
}