Implement Scrabble move generator (DAWG) with English and Russian rules
A Go library that returns every legal play ranked by score and scores or validates plays, using the Appel-Jacobson DAWG algorithm over github.com/iliadenisov/dafsa v1.1.0. - DAWG move generation (across / down / both), full tournament scoring with a per-tile breakdown; public Solver: GenerateMoves (ranked), ScorePlay, ValidatePlay. - Rulesets: English Scrabble, Russian Scrabble, Эрудит (parameterizable Ruleset). - cmd/builddict (build the DAWG from the dictionaries submodule), cmd/stress (self-play benchmark), selfplay engine; brute-force test oracle. - A GADDAG was implemented, benchmarked and removed (the DAWG was smaller and faster for a scoring solver); see RESULTS.md and ALGORITHM.md.
This commit is contained in:
+105
@@ -0,0 +1,105 @@
|
||||
// Package board holds the compact game board: a row-major grid of cell bytes encoded
|
||||
// per internal/encoding (0 = empty, letter+1, with 0x80 marking a blank). It is
|
||||
// otherwise alphabet-agnostic.
|
||||
package board
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode"
|
||||
|
||||
"github.com/iliadenisov/alphabet"
|
||||
|
||||
"scrabble-solver/internal/encoding"
|
||||
)
|
||||
|
||||
// Board is a row-major grid of encoded cells.
|
||||
type Board struct {
|
||||
rows, cols int
|
||||
cells []byte
|
||||
}
|
||||
|
||||
// New returns an empty rows×cols board.
|
||||
func New(rows, cols int) *Board {
|
||||
return &Board{rows: rows, cols: cols, cells: make([]byte, rows*cols)}
|
||||
}
|
||||
|
||||
// Rows returns the number of rows.
|
||||
func (b *Board) Rows() int { return b.rows }
|
||||
|
||||
// Cols returns the number of columns.
|
||||
func (b *Board) Cols() int { return b.cols }
|
||||
|
||||
// At returns the encoded cell at (r, c).
|
||||
func (b *Board) At(r, c int) byte { return b.cells[r*b.cols+c] }
|
||||
|
||||
// Set stores the encoded cell v at (r, c).
|
||||
func (b *Board) Set(r, c int, v byte) { b.cells[r*b.cols+c] = v }
|
||||
|
||||
// InBounds reports whether (r, c) is on the board.
|
||||
func (b *Board) InBounds(r, c int) bool {
|
||||
return r >= 0 && r < b.rows && c >= 0 && c < b.cols
|
||||
}
|
||||
|
||||
// Empty reports whether (r, c) is an empty square.
|
||||
func (b *Board) Empty(r, c int) bool { return encoding.IsEmpty(b.cells[r*b.cols+c]) }
|
||||
|
||||
// Filled reports whether (r, c) is on the board and occupied.
|
||||
func (b *Board) Filled(r, c int) bool {
|
||||
return b.InBounds(r, c) && !encoding.IsEmpty(b.cells[r*b.cols+c])
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the whole board is empty (used for the first move).
|
||||
func (b *Board) IsEmpty() bool {
|
||||
for _, c := range b.cells {
|
||||
if !encoding.IsEmpty(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the board.
|
||||
func (b *Board) Clone() *Board {
|
||||
cp := &Board{rows: b.rows, cols: b.cols, cells: make([]byte, len(b.cells))}
|
||||
copy(cp.cells, b.cells)
|
||||
return cp
|
||||
}
|
||||
|
||||
// Transpose returns a new board with rows and columns swapped, turning vertical lines
|
||||
// into horizontal ones. Down-play generation runs on the transpose.
|
||||
func (b *Board) Transpose() *Board {
|
||||
t := &Board{rows: b.cols, cols: b.rows, cells: make([]byte, len(b.cells))}
|
||||
for r := range b.rows {
|
||||
for c := range b.cols {
|
||||
t.cells[c*t.cols+r] = b.cells[r*b.cols+c]
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Parse builds a board from text rows: '.' (or space) is an empty square, a lowercase
|
||||
// letter is a normal tile, and an uppercase letter is a blank standing for that letter.
|
||||
// Letters are resolved through idx.
|
||||
func Parse(rows []string, idx alphabet.Indexer) (*Board, error) {
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("board: no rows")
|
||||
}
|
||||
cols := len([]rune(rows[0]))
|
||||
b := New(len(rows), cols)
|
||||
for r, line := range rows {
|
||||
runes := []rune(line)
|
||||
for c := 0; c < cols && c < len(runes); c++ {
|
||||
ch := runes[c]
|
||||
if ch == '.' || ch == ' ' {
|
||||
continue
|
||||
}
|
||||
blank := unicode.IsUpper(ch)
|
||||
li, err := idx.Index(string(unicode.ToLower(ch)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("board: row %d col %d %q: %w", r, c, string(ch), err)
|
||||
}
|
||||
b.Set(r, c, encoding.Cell(li, blank))
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package board_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/alphabet"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/encoding"
|
||||
)
|
||||
|
||||
func TestParseAndAccess(t *testing.T) {
|
||||
b, err := board.Parse([]string{
|
||||
"cat",
|
||||
"o..",
|
||||
"W..", // blank standing for 'w'
|
||||
}, alphabet.Latin())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Rows() != 3 || b.Cols() != 3 {
|
||||
t.Fatalf("size = %dx%d, want 3x3", b.Rows(), b.Cols())
|
||||
}
|
||||
if b.IsEmpty() {
|
||||
t.Error("IsEmpty = true for a non-empty board")
|
||||
}
|
||||
if !b.Empty(1, 1) {
|
||||
t.Error("(1,1) should be empty")
|
||||
}
|
||||
if !b.Filled(0, 0) {
|
||||
t.Error("(0,0) should be filled")
|
||||
}
|
||||
|
||||
// 'c' = index 2, normal tile.
|
||||
if got := b.At(0, 0); got != encoding.Cell(2, false) {
|
||||
t.Errorf("At(0,0) = %#x, want %#x", got, encoding.Cell(2, false))
|
||||
}
|
||||
if encoding.IsBlank(b.At(0, 0)) {
|
||||
t.Error("(0,0) wrongly marked blank")
|
||||
}
|
||||
// 'W' = blank for index 22.
|
||||
if got := b.At(2, 0); got != encoding.Cell(22, true) {
|
||||
t.Errorf("At(2,0) = %#x, want blank w", got)
|
||||
}
|
||||
if !encoding.IsBlank(b.At(2, 0)) {
|
||||
t.Error("(2,0) should be a blank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsEmpty(t *testing.T) {
|
||||
if !board.New(15, 15).IsEmpty() {
|
||||
t.Error("new board not empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranspose(t *testing.T) {
|
||||
b, _ := board.Parse([]string{
|
||||
"ab",
|
||||
"..",
|
||||
"cd",
|
||||
}, alphabet.Latin())
|
||||
tr := b.Transpose()
|
||||
if tr.Rows() != 2 || tr.Cols() != 3 {
|
||||
t.Fatalf("transpose size = %dx%d, want 2x3", tr.Rows(), tr.Cols())
|
||||
}
|
||||
if tr.At(0, 0) != b.At(0, 0) || tr.At(1, 0) != b.At(0, 1) || tr.At(0, 2) != b.At(2, 0) {
|
||||
t.Error("transpose did not swap coordinates")
|
||||
}
|
||||
|
||||
// Transposing twice restores the original.
|
||||
back := tr.Transpose()
|
||||
for r := range b.Rows() {
|
||||
for c := range b.Cols() {
|
||||
if back.At(r, c) != b.At(r, c) {
|
||||
t.Fatalf("double transpose differs at (%d,%d)", r, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClone(t *testing.T) {
|
||||
b := board.New(3, 3)
|
||||
b.Set(1, 1, encoding.Cell(0, false))
|
||||
cp := b.Clone()
|
||||
cp.Set(0, 0, encoding.Cell(1, false))
|
||||
if !b.Empty(0, 0) {
|
||||
t.Error("mutating clone changed the original")
|
||||
}
|
||||
if cp.Empty(1, 1) {
|
||||
t.Error("clone lost original content")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user