// Command validategen produces golden conformance fixtures for the TypeScript // move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays // greedy games with the authoritative scrabble-solver engine to build realistic // board positions, then records a battery of candidate plays — the engine's own // top move, letter-mutated variants, random scatters and (on the empty board) an // off-centre translation — each paired with the ground-truth result of // ValidatePlayOpts (legal, score, the words formed). The TS conformance test // replays these and must agree exactly. // // It is a development tool (not built into any service), analogous to // cmd/dictgen. Run it from the repository root: // // go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out package main import ( "bytes" "encoding/json" "flag" "fmt" "math/rand" "os" "path/filepath" "gitea.iliadenisov.ru/developer/scrabble-solver/board" "gitea.iliadenisov.ru/developer/scrabble-solver/rack" "gitea.iliadenisov.ru/developer/scrabble-solver/rules" "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" "gitea.iliadenisov.ru/developer/scrabble-solver/selfplay" dawg "github.com/iliadenisov/dafsa" ) // blankTile marks a blank tile in a drawn hand (matches selfplay). const blankTile byte = 0xff // variantSpec pairs a variant label with its ruleset and dawg file. type variantSpec struct { name string rules *rules.Ruleset dawg string } // cell is an occupied board square or a placement (alphabet-index letter). type cell struct { R, C, Letter int Blank bool } // word mirrors scrabble.Word in index space. type word struct { Row, Col, Dir int Letters []int Blanks []bool Score int } // fixture is one candidate play with the engine's ground-truth verdict. type fixture struct { Board int `json:"board"` // index into the boards list Dir int `json:"dir"` IgnoreCrossWords bool `json:"ignoreCrossWords"` Tiles []cell `json:"tiles"` Legal bool `json:"legal"` Score int `json:"score"` Bonus int `json:"bonus"` Main *word `json:"main,omitempty"` Cross []word `json:"cross,omitempty"` } // variantFile is the whole conformance payload for one variant. type variantFile struct { Variant string `json:"variant"` Rows int `json:"rows"` Cols int `json:"cols"` Center int `json:"center"` RackSize int `json:"rackSize"` Bingo int `json:"bingo"` Values []int `json:"values"` Premiums []int `json:"premiums"` // row-major rules.Premium codes Boards [][]cell `json:"boards"` Fixtures []fixture `json:"fixtures"` } func main() { dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files") outDir := flag.String("out", "", "output directory for the fixture files (required)") games := flag.Int("games", 6, "self-play games per (variant, rule)") plies := flag.Int("plies", 40, "maximum plies captured per game") flag.Parse() if *outDir == "" { fail("-out is required") } if err := os.MkdirAll(*outDir, 0o755); err != nil { fail("mkdir out: %v", err) } specs := []variantSpec{ {"scrabble_en", rules.English(), "en_sowpods.dawg"}, {"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"}, {"erudit_ru", rules.Erudit(), "ru_erudit.dawg"}, } for _, sp := range specs { if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil { fail("%s: %v", sp.name, err) } } } func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error { data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg)) if err != nil { return err } finder, err := dawg.Read(bytes.NewReader(data), 0) if err != nil { return fmt.Errorf("read dawg: %w", err) } defer finder.Close() rs := sp.rules solver := scrabble.NewSolver(rs, finder) out := variantFile{ Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values, Premiums: premiumCodes(rs), } // Capture under both the standard rule and the single-word rule, building the // board with the same rule so positions are reachable under it. for _, ignore := range []bool{false, true} { opts := scrabble.PlayOptions{IgnoreCrossWords: ignore} for g := range games { seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name) playAndCapture(&out, rs, solver, opts, seed, plies) } } b, err := json.Marshal(&out) if err != nil { return err } if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil { return err } fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures)) return nil } // playAndCapture greedily self-plays one game, recording candidate plays against // each board position along the way. func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) { rng := rand.New(rand.NewSource(seed)) bag := selfplay.NewBag(rs, seed) b := board.New(rs.Rows, rs.Cols) hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)} passes := 0 for turn := range plies { p := turn % 2 rk := rackOf(hands[p], rs.Size()) moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts) if len(moves) == 0 { if passes++; passes >= 4 { break } continue } passes = 0 top := moves[0] boardIdx := len(out.Boards) out.Boards = append(out.Boards, boardCells(b)) captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng) scrabble.Apply(b, top) hands[p] = removeUsed(hands[p], top) if need := rs.RackSize - len(hands[p]); need > 0 { hands[p] = append(hands[p], bag.Draw(need)...) } if len(hands[p]) == 0 && bag.Len() == 0 { break } } } // captureCandidates records the engine's top move plus derived candidates for one // board, each with its ValidatePlayOpts verdict. func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) { size := rs.Size() record := func(tiles []scrabble.Placement) { if len(tiles) == 0 { return } out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles)) } // The engine's own top move (legal). record(top.Tiles) // Letter-mutated variants: usually reject on the dictionary, occasionally form // a different legal word. for range 3 { mut := clonePlacements(top.Tiles) i := rng.Intn(len(mut)) mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size) record(mut) } // Random scatters: exercise geometry, dictionary and connectivity paths. for range 3 { record(randomScatter(b, size, 2+rng.Intn(4), rng)) } // Single tiles abutting the board exercise the direction inference — a single // tile is ambiguous, its orientation resolved from which axis it extends. for range 3 { if t, ok := randomAdjacentSingle(b, size, rng); ok { record([]scrabble.Placement{t}) } } // On the empty board, an off-centre translation of the first move exercises the // first-move centre rule. if b.IsEmpty() { shifted := clonePlacements(top.Tiles) ok := true for i := range shifted { shifted[i].Row++ shifted[i].Col++ if !b.InBounds(shifted[i].Row, shifted[i].Col) { ok = false break } } if ok { record(shifted) } } } // makeFixture validates a candidate against board b and serializes it with its // ground truth. Word breakdown is recorded only for legal plays (the TS test // checks words only then); an illegal play records legal=false alone. func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture { // Infer the orientation exactly as the backend evaluate does (dir-less), so the // fixture matches the real eval path and pins the client's ported inference. dir := playDirectionMirror(solver, b, tiles, opts) fx := fixture{ Board: boardIdx, Dir: int(dir), IgnoreCrossWords: opts.IgnoreCrossWords, Tiles: placementCells(tiles), } m, err := solver.ValidatePlayOpts(b, dir, tiles, opts) if err == nil { fx.Legal = true fx.Score = m.Score fx.Bonus = m.Bonus fx.Main = toWord(m.Main) for _, cw := range m.Cross { fx.Cross = append(fx.Cross, *toWord(cw)) } } return fx } func placementCells(ts []scrabble.Placement) []cell { cs := make([]cell, len(ts)) for i, t := range ts { cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank} } return cs } func toWord(w scrabble.Word) *word { letters := make([]int, len(w.Letters)) for i, l := range w.Letters { letters[i] = int(l) } return &word{ Row: w.Row, Col: w.Col, Dir: int(w.Dir), Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score, } } func premiumCodes(rs *rules.Ruleset) []int { codes := make([]int, rs.Rows*rs.Cols) for i := range codes { codes[i] = int(rs.PremiumAt(i)) } return codes } func boardCells(b *board.Board) []cell { var cs []cell for r := 0; r < b.Rows(); r++ { for c := 0; c < b.Cols(); c++ { if b.Filled(r, c) { v := b.At(r, c) cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0}) } } } return cs } func clonePlacements(ts []scrabble.Placement) []scrabble.Placement { return append([]scrabble.Placement(nil), ts...) } // randomScatter picks n distinct empty in-bounds squares with random letters. func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement { seen := map[[2]int]bool{} var ts []scrabble.Placement for tries := 0; tries < n*20 && len(ts) < n; tries++ { r := rng.Intn(b.Rows()) c := rng.Intn(b.Cols()) if seen[[2]int{r, c}] || b.Filled(r, c) { continue } seen[[2]int{r, c}] = true ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}) } return ts } // randomAdjacentSingle picks a random empty in-bounds square abutting at least one // filled square, with a random letter — a single-tile play whose orientation the // inference must resolve. It returns ok=false on an empty board. func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) { var cands [][2]int for r := 0; r < b.Rows(); r++ { for c := 0; c < b.Cols(); c++ { if b.Filled(r, c) { continue } if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) { cands = append(cands, [2]int{r, c}) } } } if len(cands) == 0 { return scrabble.Placement{}, false } rc := cands[rng.Intn(len(cands))] return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true } // playDirectionMirror mirrors engine (*Game).playDirection: the geometric // resolution, except a single tile under the single-word rule tries both // orientations through the solver and keeps the higher-scoring legal one (H wins // ties). It reproduces the orientation the backend evaluate infers. func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction { geo := resolveDirectionMirror(b, placements) if len(placements) != 1 || !opts.IgnoreCrossWords { return geo } best, found, bestScore := geo, false, 0 for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} { m, err := solver.ValidatePlayOpts(b, dir, placements, opts) if err != nil { continue } if !found || m.Score > bestScore { best, found, bestScore = dir, true, m.Score } } return best } // resolveDirectionMirror mirrors engine.resolveDirection. func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction { if len(placements) >= 2 { row := placements[0].Row for _, p := range placements[1:] { if p.Row != row { return scrabble.Vertical } } return scrabble.Horizontal } if len(placements) == 1 { p := placements[0] h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal) v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical) if v >= 2 && v > h { return scrabble.Vertical } if h >= 2 { return scrabble.Horizontal } if v >= 2 { return scrabble.Vertical } } return scrabble.Horizontal } // runLengthMirror mirrors engine.runLength. func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int { dr, dc := 0, 1 if dir == scrabble.Vertical { dr, dc = 1, 0 } n := 1 for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc { n++ } for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc { n++ } return n } // rackOf builds a generation rack from a hand of tiles (reimplemented from the // unexported selfplay helper). func rackOf(tiles []byte, size int) rack.Rack { r := rack.New(size) for _, t := range tiles { if t == blankTile { r.AddBlank() } else { r.Add(t) } } return r } // removeUsed returns the hand with the tiles consumed by m removed. func removeUsed(tiles []byte, m scrabble.Move) []byte { out := append([]byte(nil), tiles...) for _, p := range m.Tiles { want := p.Letter if p.Blank { want = blankTile } for i, t := range out { if t == want { out = append(out[:i], out[i+1:]...) break } } } return out } func boolseed(b bool) int64 { if b { return 500000 } return 0 } func variantSeed(name string) int64 { var s int64 for _, r := range name { s = s*131 + int64(r) } return s } func fail(format string, args ...any) { fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...) os.Exit(1) }