256999b42c
- 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.
188 lines
5.5 KiB
Go
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)
|
|
}
|