feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
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 `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.
- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
(immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
IndexedDB (best-effort, self-healing on a rejected blob) and reused across
sessions; a warm-up overlay covers a cold load, then the network preview is the
fallback; a bad-connection breaker stops warming after repeated misses; the move
preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
CI against the release dictionaries. A hidden debug readout lists the cached
dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// Command dictgen dumps golden parity vectors from the committed dawg
|
||||
// dictionaries so the TypeScript dawg reader can be checked byte-for-byte
|
||||
// against the authoritative Go dafsa reader.
|
||||
//
|
||||
// For each *.dawg file it writes, into the output directory:
|
||||
//
|
||||
// - <name>.words.bin — every stored word as alphabet-index bytes, in index
|
||||
// order, framed as [1-byte length][length index bytes]. The word at stream
|
||||
// position k has IndexOfB == k.
|
||||
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
|
||||
// framing, to exercise the not-found path at varying depths.
|
||||
// - <name>.meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for
|
||||
// a header-parse sanity cross-check on the TS side.
|
||||
//
|
||||
// It is a development tool (not built into any service), analogous to
|
||||
// cmd/jetgen. Run it from the repository root:
|
||||
//
|
||||
// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out <dir>
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
)
|
||||
|
||||
// meta is the per-dictionary sanity payload cross-checked by the TS reader.
|
||||
type meta struct {
|
||||
NumAdded int `json:"numAdded"`
|
||||
NumNodes int `json:"numNodes"`
|
||||
NumEdges int `json:"numEdges"`
|
||||
Alphabet int `json:"alphabet"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
|
||||
outDir := flag.String("out", "", "output directory for the golden files (required)")
|
||||
negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary")
|
||||
flag.Parse()
|
||||
|
||||
if *outDir == "" {
|
||||
fail("-out is required")
|
||||
}
|
||||
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||
fail("mkdir out: %v", err)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg"))
|
||||
if err != nil {
|
||||
fail("glob: %v", err)
|
||||
}
|
||||
sort.Strings(files)
|
||||
if len(files) == 0 {
|
||||
fail("no .dawg files in %s", *dawgDir)
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if err := process(f, *outDir, *negCount); err != nil {
|
||||
fail("%s: %v", filepath.Base(f), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process emits the golden files for a single dawg dictionary.
|
||||
func process(path, outDir string, negCount int) error {
|
||||
name := strings.TrimSuffix(filepath.Base(path), ".dawg")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
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()
|
||||
|
||||
// Stream every stored word in index order; keep a decimated sample and the
|
||||
// maximum alphabet index for negative generation.
|
||||
wf, err := os.Create(filepath.Join(outDir, name+".words.bin"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bw := bufio.NewWriter(wf)
|
||||
|
||||
var (
|
||||
count int
|
||||
maxIx byte
|
||||
sample [][]byte
|
||||
)
|
||||
finder.EnumerateB(func(index int, word []byte, final bool) int {
|
||||
if !final {
|
||||
return 0 // Continue
|
||||
}
|
||||
if index != count {
|
||||
panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count))
|
||||
}
|
||||
writeWord(bw, word)
|
||||
for _, b := range word {
|
||||
if b > maxIx {
|
||||
maxIx = b
|
||||
}
|
||||
}
|
||||
if count%4 == 0 && len(sample) < 60000 {
|
||||
sample = append(sample, append([]byte(nil), word...))
|
||||
}
|
||||
count++
|
||||
return 0 // Continue
|
||||
})
|
||||
if err := bw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wf.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != finder.NumAdded() {
|
||||
return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded())
|
||||
}
|
||||
|
||||
alphabet := int(maxIx) + 1
|
||||
|
||||
// Negatives: mutate sampled real words and keep the ones the reader rejects.
|
||||
nf, err := os.Create(filepath.Join(outDir, name+".neg.bin"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nbw := bufio.NewWriter(nf)
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
neg := 0
|
||||
for neg < negCount && len(sample) > 0 {
|
||||
base := sample[rng.Intn(len(sample))]
|
||||
cand := append([]byte(nil), base...)
|
||||
switch rng.Intn(3) {
|
||||
case 0: // extend by one index
|
||||
cand = append(cand, byte(rng.Intn(alphabet)))
|
||||
case 1: // flip one index
|
||||
if len(cand) > 0 {
|
||||
cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet))
|
||||
}
|
||||
case 2: // drop the tail and flip the new last index
|
||||
if len(cand) > 1 {
|
||||
cand = cand[:len(cand)-1]
|
||||
cand[len(cand)-1] = byte(rng.Intn(alphabet))
|
||||
}
|
||||
}
|
||||
if finder.IndexOfB(cand) == -1 {
|
||||
writeWord(nbw, cand)
|
||||
neg++
|
||||
}
|
||||
}
|
||||
if err := nbw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := nf.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet}
|
||||
mb, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n",
|
||||
name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges())
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeWord frames one index-byte word as [length][bytes].
|
||||
func writeWord(w *bufio.Writer, word []byte) {
|
||||
if len(word) > 255 {
|
||||
panic(fmt.Sprintf("word too long to frame: %d", len(word)))
|
||||
}
|
||||
w.WriteByte(byte(len(word)))
|
||||
w.Write(word)
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
|
||||
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
|
||||
// cross-test can drive the letter-space client path exactly as production does.
|
||||
type alphaEntry struct {
|
||||
Index int `json:"index"`
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// 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
|
||||
Alphabet []alphaEntry `json:"alphabet"`
|
||||
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), Alphabet: alphabetOf(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 alphabetOf(rs *rules.Ruleset) []alphaEntry {
|
||||
n := rs.Alphabet.Size()
|
||||
out := make([]alphaEntry, n)
|
||||
for i := range n {
|
||||
ch, _ := rs.Alphabet.Character(byte(i))
|
||||
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{
|
||||
VariantErudit: "ru_erudit.dawg",
|
||||
}
|
||||
|
||||
// entry is one resident dictionary: the loaded finder and the solver built over
|
||||
// it. The finder is retained so Close can release it.
|
||||
// entry is one resident dictionary: the loaded finder, the solver built over it
|
||||
// and the file it was loaded from. The finder is retained so Close can release
|
||||
// it; path is retained so the raw bytes can be re-read for the client download.
|
||||
type entry struct {
|
||||
finder dawg.Finder
|
||||
solver *scrabble.Solver
|
||||
path string
|
||||
}
|
||||
|
||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
|
||||
if old, ok := r.entries[v][version]; ok {
|
||||
_ = old.finder.Close()
|
||||
}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
|
||||
r.latest[v] = version
|
||||
return nil
|
||||
}
|
||||
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
|
||||
return versions
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
|
||||
// re-read from the file it was loaded from — the same immutable bytes the solver
|
||||
// holds. It backs the client-side dictionary download for the local move
|
||||
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
|
||||
// is not resident, and wraps any read error. The file is read outside the lock.
|
||||
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
|
||||
r.mu.RLock()
|
||||
versions, ok := r.entries[v]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||
}
|
||||
e, ok := versions[version]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||
}
|
||||
data, err := os.ReadFile(e.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Lookup reports whether word is present in the (variant, version) dictionary,
|
||||
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
||||
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
||||
|
||||
@@ -1649,6 +1649,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
|
||||
return present, nil
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
|
||||
// from the registry, backing the client-side dictionary download used by the
|
||||
// local move preview. It surfaces engine.ErrUnknownVariant /
|
||||
// engine.ErrUnknownVersion when that dictionary is not resident.
|
||||
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
|
||||
return svc.registry.DictBytes(variant, version)
|
||||
}
|
||||
|
||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||||
// allowance plus the profile wallet.
|
||||
func hintsRemaining(allowance, used, wallet int) int {
|
||||
|
||||
@@ -90,6 +90,9 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
u.POST("/games/:id/hide", s.handleHideGame)
|
||||
// Raw dictionary download for the client-side local move preview, keyed by
|
||||
// the game's pinned (variant, version); immutable, so cached hard.
|
||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||
}
|
||||
if s.feedback != nil {
|
||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// handleDictBytes streams the raw serialized dictionary for a (variant, version)
|
||||
// pair so the client can validate and score moves locally — the local move
|
||||
// preview — instead of a network round trip per tile arrangement. It is reached
|
||||
// only through the gateway's session-gated /dict route (which resolves the
|
||||
// X-User-ID this group requires), and served with a long immutable cache lifetime
|
||||
// because a published dictionary version never changes. An unknown variant or a
|
||||
// version that is not resident is a 404.
|
||||
func (s *Server) handleDictBytes(c *gin.Context) {
|
||||
variant, err := engine.ParseVariant(c.Param("variant"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unknown variant"})
|
||||
return
|
||||
}
|
||||
data, err := s.games.DictBytes(variant, c.Param("version"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "dictionary not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
Reference in New Issue
Block a user