Files
scrabble-solver/scrabble/score.go
T
Ilia Denisov 256999b42c Publish as versioned Gitea module; move dictionary pipeline out
- 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.
2026-06-04 19:11:46 +02:00

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
}