// 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 ( "bytes" "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") dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples") flag.Parse() if err := os.MkdirAll(*out, 0o755); err != nil { log.Fatalf("movegen: mkdir %s: %v", *out, err) } if *dawgDir != "" { buildReal(*dawgDir, *out) return } emitRulesets() 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)) } // realVariant maps a shipped dictionary file to the ruleset that scores it and the racks // the conformance positions use. smallRack/blankRack keep the first-move (empty board) // lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position, // kept small by the anchors around the already-placed word. type realVariant struct { file, variant, smallRack, blankRack, fullRack string rs *rules.Ruleset } // buildReal emits move-generation golden from the real shipped dictionaries in dawgDir — // the full alphabets and deep graphs the tiny samples cannot reach — one // .movegen.json per variant. Like the dictgen/validategen vectors it is // regenerated in CI and never committed, so it pins no dictionary version into the repo. func buildReal(dawgDir, out string) { reals := []realVariant{ {"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()}, {"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()}, {"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()}, } for _, v := range reals { data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg")) if err != nil { log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err) } finder, err := dawg.Read(bytes.NewReader(data), 0) if err != nil { log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err) } s := scrabble.NewSolver(v.rs, finder) cases := []genCase{ emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false), emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false), } for i := range cases { runCase(s, v.rs, &cases[i], nil) } // A deep 7-tile mid-game: place the top first move, then generate again. The // anchors around the placed word bound the list while still exercising a full rack, // deep left/right extension and wide cross-sets over the real graph. full := encRack(v.rs, v.fullRack, 0) b := board.New(v.rs.Rows, v.rs.Cols) if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 { mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)} runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles)) cases = append(cases, mid) } writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases}) total := 0 for _, c := range cases { total += len(c.Moves) } _ = finder.Close() log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total) } } // encRack encodes a rack given as the variant's letters (plus a blank count) into the // index-based genRack the fixtures carry. func encRack(rs *rules.Ruleset, letters string, blanks int) genRack { enc, err := rs.Alphabet.Encode(letters) if err != nil { log.Fatalf("movegen: encode rack %q: %v", letters, err) } idx := make([]int, len(enc)) for i, b := range enc { idx[i] = int(b) } return genRack{Letters: idx, Blanks: blanks} } // 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} } // emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks, // bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS // parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules). func emitRulesets() { type rsFix struct { Size int `json:"size"` RackSize int `json:"rackSize"` Bingo int `json:"bingo"` Blanks int `json:"blanks"` Values []int `json:"values"` Counts []int `json:"counts"` } out := map[string]rsFix{} for _, v := range []struct { name string rs *rules.Ruleset }{ {"scrabble_en", rules.English()}, {"scrabble_ru", rules.RussianScrabble()}, {"erudit_ru", rules.Erudit()}, } { out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts} } dir := filepath.Join("ui", "src", "lib", "localgame", "testdata") if err := os.MkdirAll(dir, 0o755); err != nil { log.Fatalf("movegen: mkdir %s: %v", dir, err) } writeJSON(filepath.Join(dir, "rulesets.json"), out) log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json")) } 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) } }