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.
207 lines
5.5 KiB
Go
207 lines
5.5 KiB
Go
package scrabble
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/internal/encoding"
|
|
"gitea.iliadenisov.ru/developer/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
|
|
}
|