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) }