d24a127406
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Score and validate a tentative move on-device instead of a per-arrangement
network round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a new `conformance` CI job. The server stays
authoritative — submit_play re-validates — so the local result is an advisory
accelerator only; any cache miss, storage eviction or a bad-connection breaker
falls back to the network evaluate.
- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
(immutable) streaming the pinned per-game dawg.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: an IndexedDB blob cache (best-effort, storage.persist()) + a loader
(memory -> IndexedDB -> network, session-scoped bad-connection breaker) + a lobby
prefetch (your-turn first); an adapter over the existing premiums/alphabet; a
DictWarmup overlay while a cold dictionary loads (120ms flash-guard, 5s cap ->
network); a ?nolocal flag; the DebugPanel reset also clears the dict cache.
- parity: generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run
in CI against the release dictionaries.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
467 lines
14 KiB
Go
467 lines
14 KiB
Go
// 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 <dir>
|
|
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)
|
|
}
|