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:
@@ -0,0 +1,206 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/encoding"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// coord maps a line coordinate (fixed, axis) to a board (row, col) for direction dir.
|
||||
// For Horizontal the fixed coordinate is the row and the axis runs along columns; for
|
||||
// Vertical it is the reverse.
|
||||
func coord(dir Direction, fixed, axis int) (row, col int) {
|
||||
if dir == Horizontal {
|
||||
return fixed, axis
|
||||
}
|
||||
return axis, fixed
|
||||
}
|
||||
|
||||
// fixedAxis is the inverse of coord: it splits a (row, col) into the fixed and axis
|
||||
// coordinates for direction dir.
|
||||
func fixedAxis(dir Direction, row, col int) (fixed, axis int) {
|
||||
if dir == Horizontal {
|
||||
return row, col
|
||||
}
|
||||
return col, row
|
||||
}
|
||||
|
||||
func perpendicular(d Direction) Direction {
|
||||
if d == Horizontal {
|
||||
return Vertical
|
||||
}
|
||||
return Horizontal
|
||||
}
|
||||
|
||||
// Evaluate computes the words formed and the score for placing tiles on b in direction
|
||||
// dir under ruleset rs. It validates geometry — the tiles lie on one line, on empty
|
||||
// squares, and form a single contiguous run together with existing tiles — but does not
|
||||
// check the dictionary or board connectivity; ValidatePlay layers those on top. tiles
|
||||
// need not be sorted.
|
||||
func Evaluate(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placement) (Move, error) {
|
||||
if len(tiles) == 0 {
|
||||
return Move{}, errors.New("scrabble: empty play")
|
||||
}
|
||||
|
||||
ts := append([]Placement(nil), tiles...)
|
||||
sort.Slice(ts, func(i, j int) bool {
|
||||
_, ai := fixedAxis(dir, ts[i].Row, ts[i].Col)
|
||||
_, aj := fixedAxis(dir, ts[j].Row, ts[j].Col)
|
||||
return ai < aj
|
||||
})
|
||||
|
||||
fixed, _ := fixedAxis(dir, ts[0].Row, ts[0].Col)
|
||||
prevAxis := 0
|
||||
for i, t := range ts {
|
||||
f, a := fixedAxis(dir, t.Row, t.Col)
|
||||
if f != fixed {
|
||||
return Move{}, errors.New("scrabble: tiles are not on one line")
|
||||
}
|
||||
if !b.InBounds(t.Row, t.Col) {
|
||||
return Move{}, fmt.Errorf("scrabble: tile (%d,%d) off board", t.Row, t.Col)
|
||||
}
|
||||
if !b.Empty(t.Row, t.Col) {
|
||||
return Move{}, fmt.Errorf("scrabble: square (%d,%d) is occupied", t.Row, t.Col)
|
||||
}
|
||||
if i > 0 && a == prevAxis {
|
||||
return Move{}, errors.New("scrabble: two tiles on the same square")
|
||||
}
|
||||
prevAxis = a
|
||||
}
|
||||
|
||||
main, err := buildMainWord(b, rs, dir, fixed, ts)
|
||||
if err != nil {
|
||||
return Move{}, err
|
||||
}
|
||||
|
||||
move := Move{Dir: dir, Tiles: ts, Main: main, Score: main.Score}
|
||||
for _, t := range ts {
|
||||
if cw, ok := crossWord(b, rs, dir, t); ok {
|
||||
move.Cross = append(move.Cross, cw)
|
||||
move.Score += cw.Score
|
||||
}
|
||||
}
|
||||
if len(ts) == rs.RackSize {
|
||||
move.Bonus = rs.Bingo
|
||||
move.Score += rs.Bingo
|
||||
}
|
||||
return move, nil
|
||||
}
|
||||
|
||||
// buildMainWord assembles the word along dir through the (sorted) placements together
|
||||
// with the existing tiles that extend and bridge them, and scores it. New tiles apply
|
||||
// their squares' premiums; existing tiles score at face value.
|
||||
func buildMainWord(b *board.Board, rs *rules.Ruleset, dir Direction, fixed int, ts []Placement) (Word, error) {
|
||||
_, minA := fixedAxis(dir, ts[0].Row, ts[0].Col)
|
||||
_, maxA := fixedAxis(dir, ts[len(ts)-1].Row, ts[len(ts)-1].Col)
|
||||
|
||||
start := minA
|
||||
for {
|
||||
r, c := coord(dir, fixed, start-1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
start--
|
||||
}
|
||||
end := maxA
|
||||
for {
|
||||
r, c := coord(dir, fixed, end+1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
letters := make([]byte, 0, end-start+1)
|
||||
blanks := make([]bool, 0, end-start+1)
|
||||
letterSum, wordMult := 0, 1
|
||||
ti := 0
|
||||
for a := start; a <= end; a++ {
|
||||
r, c := coord(dir, fixed, a)
|
||||
if ti < len(ts) {
|
||||
if _, ta := fixedAxis(dir, ts[ti].Row, ts[ti].Col); ta == a {
|
||||
t := ts[ti]
|
||||
ti++
|
||||
prem := rs.Premium(r, c)
|
||||
if !t.Blank {
|
||||
letterSum += rs.Values[t.Letter] * prem.LetterMult()
|
||||
}
|
||||
wordMult *= prem.WordMult()
|
||||
letters = append(letters, t.Letter)
|
||||
blanks = append(blanks, t.Blank)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if b.Filled(r, c) {
|
||||
cell := b.At(r, c)
|
||||
l, bl := encoding.Letter(cell), encoding.IsBlank(cell)
|
||||
if !bl {
|
||||
letterSum += rs.Values[l]
|
||||
}
|
||||
letters = append(letters, l)
|
||||
blanks = append(blanks, bl)
|
||||
continue
|
||||
}
|
||||
return Word{}, fmt.Errorf("scrabble: gap in the play at line position %d", a)
|
||||
}
|
||||
|
||||
wr, wc := coord(dir, fixed, start)
|
||||
return Word{Row: wr, Col: wc, Dir: dir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, nil
|
||||
}
|
||||
|
||||
// crossWord builds the perpendicular word formed by a single new tile, if any. It
|
||||
// returns ok=false when the tile has no perpendicular neighbour.
|
||||
func crossWord(b *board.Board, rs *rules.Ruleset, dir Direction, t Placement) (Word, bool) {
|
||||
cdir := perpendicular(dir)
|
||||
fixed, axis := fixedAxis(cdir, t.Row, t.Col)
|
||||
|
||||
start := axis
|
||||
for {
|
||||
r, c := coord(cdir, fixed, start-1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
start--
|
||||
}
|
||||
end := axis
|
||||
for {
|
||||
r, c := coord(cdir, fixed, end+1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if start == end {
|
||||
return Word{}, false
|
||||
}
|
||||
|
||||
letters := make([]byte, 0, end-start+1)
|
||||
blanks := make([]bool, 0, end-start+1)
|
||||
letterSum, wordMult := 0, 1
|
||||
for a := start; a <= end; a++ {
|
||||
r, c := coord(cdir, fixed, a)
|
||||
if a == axis {
|
||||
prem := rs.Premium(r, c)
|
||||
if !t.Blank {
|
||||
letterSum += rs.Values[t.Letter] * prem.LetterMult()
|
||||
}
|
||||
wordMult *= prem.WordMult()
|
||||
letters = append(letters, t.Letter)
|
||||
blanks = append(blanks, t.Blank)
|
||||
} else {
|
||||
cell := b.At(r, c)
|
||||
l, bl := encoding.Letter(cell), encoding.IsBlank(cell)
|
||||
if !bl {
|
||||
letterSum += rs.Values[l]
|
||||
}
|
||||
letters = append(letters, l)
|
||||
blanks = append(blanks, bl)
|
||||
}
|
||||
}
|
||||
wr, wc := coord(cdir, fixed, start)
|
||||
return Word{Row: wr, Col: wc, Dir: cdir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, true
|
||||
}
|
||||
Reference in New Issue
Block a user