Files
scrabble-solver/README.md
T
Ilia Denisov a8a559993d scrabble: add single-word-per-turn rule via PlayOptions
Add an optional "single word per turn" rule: only the main word along the
play direction is validated and scored; perpendicular cross-words are ignored
— not formed, validated, scored, or used to constrain move generation. The
zero PlayOptions stays standard Scrabble, so existing callers are unchanged.

- PlayOptions{IgnoreCrossWords} threaded through new EvaluateOpts,
  Solver.{ScorePlay,ValidatePlay,GenerateMoves}Opts and the DAWG generator
  (relaxed cross-sets via fullSet, main-word-only scoring).
- connected() tests perpendicular adjacency directly instead of via
  Move.Cross, so an all-new main word touching the board only sideways still
  connects when cross-words are suppressed (behaviour-preserving for standard
  play).
- Tests: focused corner-case suite (solver_opts_test.go) and a single-word
  GCG fixture; the 17 real-game GCG fixtures stay green as the standard-rules
  regression guard.
2026-06-12 01:37:39 +02:00

91 lines
3.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# scrabble-solver
A Go library that, given a dictionary, a board position and a rack, returns **every
legal play ranked by score**, and also **scores** or **validates** arbitrary plays. The
move generator is the DAWG algorithm of Appel & Jacobson, *The World's Fastest Scrabble
Program*. It operates on compact byte-indexed inputs/outputs and is dictionary-driven via
[`github.com/iliadenisov/dafsa`](https://github.com/iliadenisov/dafsa).
See [`ALGORITHM.md`](ALGORITHM.md) for the algorithm (the single source of truth) and
[`RESULTS.md`](RESULTS.md) for the DAWG-vs-GADDAG benchmark that settled the design.
## Status
- DAWG move generation (across / down / both orientations), with full tournament scoring
(cross-words, premiums, all-tiles bonus) and a per-tile breakdown.
- Public `Solver`: `GenerateMoves` (ranked), `ScorePlay`, `ValidatePlay`, each with an
`*Opts` variant taking `PlayOptions` for optional rule variations.
- Optional **single word per turn** rule (`PlayOptions{IgnoreCrossWords: true}`): only the
main word is validated and scored — perpendicular cross-words are ignored, including in
move generation. The zero `PlayOptions` is standard Scrabble.
- Rulesets: **English** Scrabble, **Russian** Scrabble, **Эрудит**; `rules.Ruleset` is
fully parameterizable (board, premiums, tile values/counts, blanks, rack, bonus).
- A GADDAG (Gordon) was implemented, benchmarked and then **removed** — for a scoring
solver it was ~7× larger and no faster.
## Layout
```
scrabble/ public API: Solver, Move/Play types, DAWG generator, scoring, validation
board/ rack/ rules/ board grid (+transpose), rack, rulesets (English/Russian/Эрудит)
wordlist/ dictdawg/ public word-list parsing and DAWG build/load helpers
internal/ encoding (byte conventions), dict (committed-DAWG loader), graph
cmd/stress/ greedy self-play benchmark of the generator
selfplay/ bag + greedy player + game loop
```
## Setup
The committed dictionary DAWGs under `dawg/` (`en_sowpods`, `ru_scrabble`, `ru_erudit`)
are used directly — no build step. The word-list parsing and DAWG build pipeline lives in
the separate [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
repository, which publishes the DAWG set as a release artifact.
## Usage
```go
rs := rules.English()
finder, _ := dict.EnglishDAWG() // loads dawg/en_sowpods.dawg
s := scrabble.NewSolver(rs, finder)
b := board.New(rs.Rows, rs.Cols) // empty board (first move)
r := rack.New(rs.Size()) // rack "friends"
tiles, _ := rs.Alphabet.Encode("friends")
for _, t := range tiles {
r.Add(t)
}
moves := s.GenerateMoves(b, r, scrabble.Both) // ranked, highest score first
best := moves[0]
// best.Main / best.Cross hold the words (alphabet indexes; decode via rs.Alphabet),
// best.Tiles the placed tiles (with blank flags), best.Score the total.
// Score or validate an arbitrary play (placed tiles + direction):
m, err := s.ValidatePlay(b, scrabble.Horizontal, best.Tiles)
_ = m
_ = err
```
Words and tiles are alphabet **indexes** throughout (no string wrapper); convert with the
ruleset's `alphabet.Indexer` (`Encode`/`Decode`) when you need text.
## Rulesets
`rules.English()`, `rules.RussianScrabble()`, `rules.Erudit()`, or build your own with
`rules.FromTemplate(...)`. For Эрудит, fold Ё→Е while preparing the dictionary with
`wordlist.FoldYo` (the engine treats them as one letter; it is a dictionary-prep step).
## Benchmark
```sh
go run ./cmd/stress -games 100 # greedy AI-vs-AI self-play; reports speed and memory
```
## Tests
```sh
go test ./... # unit tests + a brute-force move-generation oracle
go test ./... -short # skips the full-dictionary game test
```