// Command movegen emits golden conformance fixtures for the client-side move // generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is // committed so the TypeScript parity tests run without a Go toolchain. // // For each small sample dictionary (English and Russian — the latter reaches // alphabet index 32, exercising the 33-letter cross-set boundary) it writes: // // - sample_.dawg the serialized dictionary (the reader/cursor fixture) // - sample_.words.json the stored words + their alphabet indexes // - sample_.gen.json ranked move-generation results from the real solver, // for a handful of positions, plus the ruleset the TS // side rebuilds to score identically // // Positions are built with only the solver's public API: an empty board, and // two-ply positions reached by applying the solver's own top move (so no internal // encoding is needed). Regenerate with: // // go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata package main import ( "encoding/json" "flag" "log" "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" dawg "github.com/iliadenisov/dafsa" ) // sampleWordsEN is the English sample dictionary, in strictly increasing // alphabet-index order (the builder requires it). Shared prefixes (car/care/cars), // shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word. var sampleWordsEN = []string{ "a", "an", "and", "ant", "car", "care", "cared", "cares", "cars", "cat", "cats", "do", "doe", "does", "dog", "dogs", "done", "dot", } // sampleWordsRU is the Russian sample dictionary, in strictly increasing index // order. It deliberately includes words starting with я (index 32) so the ported // cross-set handles alphabet indexes past JS's 31-bit shift boundary. var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"} // sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test // knows the exact word set (as alphabet indexes) to expect from enumeration. type sampleFixture struct { Alphabet string `json:"alphabet"` NumAdded int `json:"numAdded"` Words []string `json:"words"` Indexes [][]int `json:"indexes"` } // genFixture is the move-generation golden set for one sample dictionary. type genFixture struct { Ruleset genRuleset `json:"ruleset"` Cases []genCase `json:"cases"` } // genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go // solver: letter values, premium multipliers per square, the centre, rack size and bonus. type genRuleset struct { Size int `json:"size"` Cols int `json:"cols"` Center int `json:"center"` RackSize int `json:"rackSize"` Bingo int `json:"bingo"` Values []int `json:"values"` LetterMult [][]int `json:"letterMult"` WordMult [][]int `json:"wordMult"` } // genTile is one placed tile (a board tile or a move placement). type genTile struct { Row int `json:"row"` Col int `json:"col"` Letter int `json:"letter"` Blank bool `json:"blank"` } // genRack is a rack as a multiset of letter indexes plus a blank count. type genRack struct { Letters []int `json:"letters"` Blanks int `json:"blanks"` } // genMove is one ranked generated play: its orientation, placed tiles and total score. type genMove struct { Dir int `json:"dir"` Tiles []genTile `json:"tiles"` Score int `json:"score"` } // genCase is one generation position: the tiles already on the board (empty when // none), the rack, the mode/rule and the ranked moves the solver returns. type genCase struct { Name string `json:"name"` Placed []genTile `json:"placed"` Rack genRack `json:"rack"` Mode int `json:"mode"` IgnoreCrossWords bool `json:"ignoreCrossWords"` Moves []genMove `json:"moves"` } func main() { out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures") flag.Parse() if err := os.MkdirAll(*out, 0o755); err != nil { log.Fatalf("movegen: mkdir %s: %v", *out, err) } buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{ emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false), emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false), emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false), emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true), }) buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{ emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false), }) } // buildSample writes the dawg, the word fixture and the generation golden set for // one sample dictionary. Two-ply cases are appended: the solver's own top move from // the first non-empty result is applied, then generation runs again on the new rack. func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) { idx := rs.Alphabet b := dawg.New(idx) indexes := make([][]int, 0, len(words)) for _, w := range words { if err := b.Add(w); err != nil { log.Fatalf("movegen[%s]: add %q: %v", tag, w, err) } enc, err := idx.Encode(w) if err != nil { log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err) } ints := make([]int, len(enc)) for i, x := range enc { ints[i] = int(x) } indexes = append(indexes, ints) } finder := b.Finish() writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{ Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes, }) dawgPath := filepath.Join(out, "sample_"+tag+".dawg") if _, err := finder.Save(dawgPath); err != nil { log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err) } s := scrabble.NewSolver(rs, finder) for i := range cases { runCase(s, rs, &cases[i], nil) } // A two-ply position from the first standard case that produced a move. if two := twoPly(s, rs, cases); two != nil { cases = append(cases, *two) } writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{ Ruleset: rulesetOf(rs), Cases: cases, }) log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases)) } // runCase fills a case's Moves by generating on a board holding the given placed // tiles (nil = empty board). func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) { bd := board.New(rs.Rows, rs.Cols) for _, t := range placed { bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank)) } c.Placed = placed rk := toRack(rs.Size(), c.Rack) moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords}) c.Moves = movesOf(moves) } // twoPly reaches a mid-game position by applying the top move of the first standard // case that generated one, then generates again with a fresh rack of the same tiles. func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase { for _, c := range cases { if c.IgnoreCrossWords || len(c.Moves) == 0 { continue } placed := c.Moves[0].Tiles next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)} runCase(s, rs, &next, placed) return &next } return nil } // cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold // letter+1, bit 7 marks a blank). Duplicated here because that package is internal // to the solver module and cannot be imported. func cellByte(letter int, blank bool) byte { v := byte(letter+1) & 0x3f if blank { v |= 0x80 } return v } func toRack(size int, r genRack) rack.Rack { rk := rack.New(size) for _, l := range r.Letters { rk.Add(byte(l)) } for i := 0; i < r.Blanks; i++ { rk.AddBlank() } return rk } func rulesetOf(rs *rules.Ruleset) genRuleset { lm := make([][]int, rs.Rows) wm := make([][]int, rs.Rows) for r := 0; r < rs.Rows; r++ { lm[r] = make([]int, rs.Cols) wm[r] = make([]int, rs.Cols) for c := 0; c < rs.Cols; c++ { p := rs.Premium(r, c) lm[r][c] = p.LetterMult() wm[r][c] = p.WordMult() } } return genRuleset{ Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm, } } func movesOf(ms []scrabble.Move) []genMove { out := make([]genMove, len(ms)) for i, m := range ms { out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score} } return out } func tilesOf(ps []scrabble.Placement) []genTile { out := make([]genTile, len(ps)) for i, p := range ps { out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank} } return out } // emptyCase builds an empty-board case (Moves filled later by runCase). func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase { return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross} } // englishRack builds a rack from lowercase a-z letters (index = letter-'a'). func englishRack(letters string, blanks int) genRack { idx := make([]int, 0, len(letters)) for _, ch := range letters { idx = append(idx, int(ch-'a')) } return genRack{Letters: idx, Blanks: blanks} } // russianRack builds a rack from the Russian sample letters used above. func russianRack(letters string, blanks int) genRack { m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32} idx := make([]int, 0, len([]rune(letters))) for _, ch := range letters { i, ok := m[ch] if !ok { log.Fatalf("movegen: russianRack: no index for %q", string(ch)) } idx = append(idx, i) } return genRack{Letters: idx, Blanks: blanks} } func writeJSON(path string, v any) { data, err := json.MarshalIndent(v, "", " ") if err != nil { log.Fatalf("movegen: marshal %s: %v", path, err) } if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { log.Fatalf("movegen: write %s: %v", path, err) } }