diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 265cfaf..97059c7 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -207,11 +207,66 @@ jobs: run: pnpm run test:e2e timeout-minutes: 5 + # conformance proves the client's local move preview (the ported dawg reader + + # validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine: + # a Go step generates golden parity vectors from the release dictionaries, then the + # gated Vitest suite replays them. It spans both toolchains, so it runs whenever the + # Go engine side or the UI side changed. + conformance: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + env: + GOPRIVATE: gitea.iliadenisov.ru/* + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch dictionary DAWGs + run: | + mkdir -p "${GITHUB_WORKSPACE}/dawg" + curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" + tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + cache: true + + - name: Generate golden parity vectors + run: | + go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold + go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm@11.0.9 + + - name: Install deps + working-directory: ui + run: pnpm install --frozen-lockfile + + - name: Local-eval conformance (reader + validator vs the Go engine) + working-directory: ui + env: + DICT_DAWG_DIR: ${{ github.workspace }}/dawg + DICT_GOLD_DIR: /tmp/dictgold + DICT_VALID_DIR: /tmp/validgold + run: pnpm exec vitest run src/lib/dict/ + # gate is the single branch-protection required check. It always runs and passes # only when each upstream job succeeded or was skipped (a path-filtered no-op), # failing the merge if any actually failed or was cancelled. gate: - needs: [unit, integration, ui] + needs: [unit, integration, ui, conformance] if: always() runs-on: ubuntu-latest defaults: @@ -221,7 +276,7 @@ jobs: - name: Aggregate required checks run: | fail= - for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do + for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do name="${r%%:*}"; res="${r#*:}" echo "$name = $res" case "$res" in @@ -340,6 +395,23 @@ jobs: docker logs --tail 50 scrabble-backend || true exit 1 + - name: Probe the /dict edge route reaches the gateway + run: | + set -u + # The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version} + # for the local move preview. If caddy does not route /dict to the gateway the request + # falls to the static landing and the client silently gets a non-dawg blob. Probed + # unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a + # 404/200 from the landing catch-all. + out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)" + echo "$out" | grep -E "HTTP/" || true + if echo "$out" | grep -q " 401"; then + echo "ok: /dict reaches the gateway (401 unauthenticated)" + else + echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?" + exit 1 + fi + - name: Probe the Telegram validator and bot liveness run: | set -u diff --git a/backend/cmd/dictgen/main.go b/backend/cmd/dictgen/main.go new file mode 100644 index 0000000..d76c538 --- /dev/null +++ b/backend/cmd/dictgen/main.go @@ -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: +// +// - .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. +// - .neg.bin — negative lookups (sequences whose IndexOfB is -1), same +// framing, to exercise the not-found path at varying depths. +// - .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 +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) +} diff --git a/backend/cmd/validategen/main.go b/backend/cmd/validategen/main.go new file mode 100644 index 0000000..c47bcca --- /dev/null +++ b/backend/cmd/validategen/main.go @@ -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 +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) +} diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go index 84a525e..8c41f94 100644 --- a/backend/internal/engine/registry.go +++ b/backend/internal/engine/registry.go @@ -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 diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 3ed494a..312f93d 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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 { diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 2451514..d15fb59 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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) diff --git a/backend/internal/server/handlers_dict.go b/backend/internal/server/handlers_dict.go new file mode 100644 index 0000000..8ba4e2c --- /dev/null +++ b/backend/internal/server/handlers_dict.go @@ -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) +} diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 50df064..c335674 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -53,7 +53,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8bbf8c6..eb0d3a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -352,6 +352,17 @@ Key points: - History is dictionary-independent (§9.1): the engine emits decoded `MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard` (alphabet only, no dictionary). +- The **client mirrors this validation path for an instant move preview** (the + local eval, `ui/src/lib/dict`): the dawg reader and the + validate/score/direction slice are ported to TypeScript, byte-for-byte against + this engine and pinned by the `conformance` CI job. Composing a move is scored + on-device with no round trip. It is an **advisory accelerator only** — + `submit_play` re-validates on the server, so a wrong or spoofed local result + cannot corrupt a game. The pinned per-game dictionary blob is fetched once + through a **session-gated `GET /dict/{variant}/{version}`** edge route + (immutable; cached in IndexedDB best-effort) and reused across sessions; any + miss, storage eviction or a bad-connection breaker falls back to the network + `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. ## 6. Game rules diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 39c24b0..e05d9d1 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -163,7 +163,10 @@ tile that extends an existing word (down a column or across a row) is accepted. A play is validated against the game's dictionary at submit time and scored; an unlimited preview reports the word(s) a tentative move would form and its score, or that it is not -legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is +legal, and the move is offered for submission only once it is confirmed legal. The preview is +computed **on-device** for an instant response once the game's dictionary has loaded — a +brief warm-up shows on the first open otherwise — and falls back to the server whenever the +local dictionary is unavailable. The dictionary check tool is unlimited and offers a complaint on any result; for a word it finds, it also links out to an external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for English) to look it up. Hints are governed per game — diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f94746d..f0d1aca 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -171,7 +171,9 @@ _Вход сейчас только через провайдера, поэто слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно -отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и +отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве** +и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается +короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), чтобы его посмотреть. Подсказки управляются настройками diff --git a/docs/TESTING.md b/docs/TESTING.md index 8d08b35..18d0725 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -21,6 +21,15 @@ tests or touching CI. mock for the friends screen (code issue/redeem, accept a request), the lobby invitations section, the stats screen, profile editing, and the GCG export's finished-only visibility. +- **Local-eval conformance** — the client's on-device move preview (the ported + dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the + authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit + golden vectors from the release dictionaries — every stored word plus a battery of + plays across both cross-word rules and all variants, each with the engine's + legality, score, words and inferred direction. The gated Vitest suites + (`ui/src/lib/dict/*.parity.test.ts`, skipped without their `DICT_*` env) replay + them and must agree exactly; the `conformance` CI job runs the whole loop, so a + drift in the port fails the merge. - **Engine** — correctness of scoring and move generation is owned by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on top of the embedded solver: per-variant smoke tests (load all three committed diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 00404da..c6bc65d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -427,6 +427,20 @@ enabled on the first, uncached load) and flip in place when an event refreshes t raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the Info tab. +## Dictionary warm-up overlay (`components/DictWarmup.svelte`) + +Opening a game whose local move-preview dictionary is not yet cached shows a brief, +non-dismissable warm-up overlay while it downloads (a returning player's cached +dictionary loads from IndexedDB in a few ms, so the flash-guard suppresses it then). +A darker-than-onboarding scrim covers +the board; centred on it, a large emoji cycles through a fixed playful sequence — one +per 500 ms tick (300 ms still, then a 200 ms clockwise spin with the current glyph +fading out and the next fading in), looping — under a constant "Loading…" caption. +Reduce-motion drops the spin to a plain cross-fade. A ~120 ms flash-guard suppresses +the overlay for a disk-cached dictionary that loads in a few ms; a cold (network) load +shows it until the dictionary is ready or a 5 s cap elapses, after which the preview +falls back to the network. See docs/ARCHITECTURE.md §5 (the local eval). + ## Caveat Emoji are rendered by the platform's system emoji font, so their exact look varies across diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index b108877..52428f1 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -194,6 +194,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { edge := connectsrv.NewServer(connectsrv.Deps{ Registry: registry, Sessions: sessions, + Backend: backend, Limiter: limiter, Tracker: tracker, Banlist: banlist, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 84ea8cd..9ee4bd0 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -483,6 +483,13 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []Pl return out, err } +// DictBytes fetches the raw serialized dictionary for a (variant, version) pair, +// backing the client-side local move preview. It returns the blob and the +// backend's Cache-Control header, forwarded so the immutable blob is cached hard. +func (c *Client) DictBytes(ctx context.Context, userID, variant, version string) ([]byte, string, error) { + return c.getRaw(ctx, "/api/v1/user/dict/"+url.PathEscape(variant)+"/"+url.PathEscape(version), userID, "Cache-Control") +} + // CheckWord looks a word up in the game's pinned dictionary. The word is carried as // repeated ?idx= alphabet indices; the backend echoes the decoded concrete word. func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) { diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index 50ccbb7..ce2a93e 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -125,6 +125,34 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, return nil } +// getRaw performs one REST GET, returning the raw (un-decoded) response body and +// the value of respHeader (e.g. Cache-Control). userID, when non-empty, is +// forwarded as X-User-ID. A non-2xx response is returned as an *APIError. Unlike +// do it does not JSON-decode, so it carries a binary payload such as a dictionary +// blob. +func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, "", fmt.Errorf("backendclient: new request: %w", err) + } + if userID != "" { + req.Header.Set("X-User-ID", userID) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("backendclient: read response: %w", err) + } + if resp.StatusCode >= http.StatusMultipleChoices { + return nil, "", parseAPIError(resp.StatusCode, data) + } + return data, resp.Header.Get(respHeader), nil +} + // parseAPIError extracts the backend's {error:{code,message}} envelope. func parseAPIError(status int, data []byte) *APIError { var env struct { diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 1b38693..c3d5588 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -68,6 +68,7 @@ const ( type Server struct { registry *transcode.Registry sessions *session.Cache + backend *backendclient.Client limiter *ratelimit.Limiter tracker *ratelimit.Tracker banlist *ratelimit.Banlist @@ -91,7 +92,10 @@ type Server struct { type Deps struct { Registry *transcode.Registry Sessions *session.Cache - Limiter *ratelimit.Limiter + // Backend is the REST client backing the session-gated /dict blob route; a nil + // value disables that route (it 404s). + Backend *backendclient.Client + Limiter *ratelimit.Limiter // Tracker accumulates limiter rejections for the periodic report; nil // selects a private tracker (rejections are then only counted, never // reported). @@ -142,6 +146,7 @@ func NewServer(d Deps) *Server { return &Server{ registry: d.Registry, sessions: d.Sessions, + backend: d.Backend, limiter: limiter, tracker: tracker, banlist: banlist, @@ -183,6 +188,9 @@ func (s *Server) HTTPHandler() http.Handler { // does not serve the app shell at the operator path. mux.Handle("/_gm/", http.NotFoundHandler()) } + // The client-side local move preview pulls each game's pinned dictionary blob + // through this session-gated route (not public); see dictBytesHandler. + mux.Handle("/dict/", s.dictBytesHandler()) // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps @@ -397,6 +405,75 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler { }) } +// dictBytesHandler serves the raw dictionary blob for a (variant, version) pair to +// a signed-in client (the local move preview), proxying the backend's authed +// endpoint. It is session-gated — not public — bounds the download with the +// user-class limiter, and forwards the backend's immutable Cache-Control so the +// browser caches the blob hard. Only GET is allowed; the path is +// /dict/{variant}/{version}. +func (s *Server) dictBytesHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("user:"+ip, s.userPolicy) { + s.noteRateLimited(r.Context(), classUser, ip, "dict") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + uid, _, err := s.resolve(r.Context(), r.Header, ip) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + variant, version, ok := parseDictPath(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + data, cacheControl, err := s.backend.DictBytes(r.Context(), uid, variant, version) + if err != nil { + var apiErr *backendclient.APIError + if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError { + http.NotFound(w, r) // unknown variant or version + return + } + s.log.Warn("dict fetch failed", zap.Error(err)) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + if cacheControl != "" { + w.Header().Set("Cache-Control", cacheControl) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(data) + }) +} + +// parseDictPath splits /dict/{variant}/{version}; both segments must be present +// and non-empty, and the version must not contain a further slash. +func parseDictPath(p string) (variant, version string, ok bool) { + rest, found := strings.CutPrefix(p, "/dict/") + if !found { + return "", "", false + } + i := strings.IndexByte(rest, '/') + if i <= 0 || i == len(rest)-1 { + return "", "", false + } + variant, version = rest[:i], rest[i+1:] + if strings.Contains(version, "/") { + return "", "", false + } + return variant, version, true +} + // resolve extracts and resolves the Authorization bearer token to an account id // and its guest flag, returning a Connect Unauthenticated error when it is missing // or unknown. diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 76046ef..61c0519 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -19,8 +19,10 @@ import { join } from 'node:path'; const DIST = 'dist'; -// Per-chunk gzip budgets in KB. -const BUDGET = { app: 100, shared: 30, landing: 5 }; +// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local +// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this +// covers the small in-entry game/debug wiring it needs). +const BUDGET = { app: 110, shared: 30, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/components/DebugPanel.svelte b/ui/src/components/DebugPanel.svelte index 8bdf6b0..6960589 100644 --- a/ui/src/components/DebugPanel.svelte +++ b/ui/src/components/DebugPanel.svelte @@ -4,18 +4,39 @@ // snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a // clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the // Telegram Android presentation quirks. Drawn from the top, just under the app header. + import { onMount } from 'svelte'; import { app, closeDebug, resetOnboarding } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { shareText } from '../lib/share'; import { telegramChromeDiag } from '../lib/telegram'; - const report = [ - `app: ${__APP_VERSION__}`, - `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, - `online: ${connection.online} streamAlive: ${app.streamAlive}`, - `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, - telegramChromeDiag(), - ].join('\n'); + // The local move-preview snapshot — the feature flag, the bad-connection breaker and the + // dictionaries cached in IndexedDB with their sizes — loads async so a report about the + // preview is precise. Dynamically imported so the debug panel keeps the dict code lazy. + let dictInfo = $state('local eval: …'); + onMount(() => { + void (async () => { + try { + const m = await import('../lib/dict'); + const list = await m.listCachedDicts(); + const cached = list.length ? list.map((d) => `${d.key}=${(d.bytes / 1024).toFixed(0)}KB`).join(' ') : 'none'; + dictInfo = `dict breaker: ${m.dictLoadingDisabled() ? 'TRIPPED' : 'ok'}\ndicts cached: ${cached}`; + } catch { + dictInfo = 'local eval: n/a'; + } + })(); + }); + + const report = $derived( + [ + `app: ${__APP_VERSION__}`, + `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, + `online: ${connection.online} streamAlive: ${app.streamAlive}`, + `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, + telegramChromeDiag(), + dictInfo, + ].join('\n'), + ); let label = $state('Share'); async function share(e: MouseEvent): Promise { @@ -33,6 +54,13 @@ function resetVisited(e: MouseEvent): void { e.stopPropagation(); resetOnboarding(); + // Also clear the local move-preview dictionary cache (safe — it re-downloads). + // Dynamically imported so the debug panel keeps the dict code out of the app bundle. + void import('../lib/dict').then((m) => { + m.clearDictCache(); + m.clearDictInstances(); + m.bustDictHttpCache(); // also bypass the browser HTTP cache on the next load (a true cold test) + }); resetLabel = 'Cleared — relaunch'; setTimeout(() => (resetLabel = 'Reset visited'), 1800); } diff --git a/ui/src/components/DictWarmup.svelte b/ui/src/components/DictWarmup.svelte new file mode 100644 index 0000000..0806362 --- /dev/null +++ b/ui/src/components/DictWarmup.svelte @@ -0,0 +1,81 @@ + + +
+
+
+ {#key index} + {EMOJIS[index]} + {/key} +
+ {t('dict.loading')} +
+
+ + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 4b03091..2ebbd08 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -4,6 +4,7 @@ import TabBar from '../components/TabBar.svelte'; import TapConfirm from '../components/TapConfirm.svelte'; import Modal from '../components/Modal.svelte'; + import DictWarmup from '../components/DictWarmup.svelte'; import Board from './Board.svelte'; import Rack from './Rack.svelte'; import { gateway } from '../lib/gateway'; @@ -50,6 +51,15 @@ let placement = $state(newPlacement([])); let preview = $state(null); let busy = $state(false); + + // The local move-preview subsystem (dynamically imported when enabled) and the warm-up + // overlay state. dict is null until the subsystem loads, or when the feature is disabled. + let dict = $state(null); + let dictWarming = $state(false); + let warmStarted = false; + // Aborts the in-flight dictionary download (at the warm-up cap, or when leaving the game) + // so a large download does not keep starving the channel on a slow link. + let warmCtrl: AbortController | null = null; let zoomed = $state(false); let selected = $state(null); let focus = $state<{ row: number; col: number } | null>(null); @@ -244,6 +254,24 @@ void load(); void loadFriends(); void loadBlocked(); + // Load the local move-preview subsystem (kept out of the initial bundle) when enabled, + // so composing a move can be scored on-device; the network preview stays the fallback. + // Skipped in mock mode (no dictionary server; the mock uses the network preview path and + // must never see the warm-up overlay, which would intercept the e2e's taps). + if (import.meta.env.MODE !== 'mock') { + void import('../lib/dict').then((m) => { + dict = m; + }); + } + }); + + // Warm the game's dictionary for the local move preview once, when both the game and the + // dynamically-imported preview subsystem are available (see warmDict). + $effect(() => { + if (dict && view && !warmStarted) { + warmStarted = true; + void warmDict(); + } }); // cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers. @@ -569,6 +597,10 @@ clearReorder(); } onDestroy(() => { + // Leaving the game: abort a still-downloading dictionary and any in-flight preview so they + // stop holding the channel. + warmCtrl?.abort(); + evalCtrl?.abort(); window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); @@ -638,19 +670,74 @@ scheduleDraftSave(); } + // warmDict loads the game's dictionary for the local move preview when the player opens the + // game. When it is already cached the preview is instant and no overlay shows; a cold + // dictionary downloads behind a non-dismissable warm-up overlay, which hides on load or after + // a 5s cap — the preview then uses the network until the download finishes in the background. + // The bad-connection breaker skips the overlay and goes straight to the network. + async function warmDict() { + const d = dict; + const v = view; + if (!d || !v) return; + if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return; + const ctrl = new AbortController(); + warmCtrl = ctrl; + let settled = false; + // A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the + // overlay if the load has not settled by now — a cold (network) load always outlasts it. + const grace = setTimeout(() => { + if (!settled) dictWarming = true; + }, 120); + const loaded = await Promise.race([ + d.getDawg(v.game.variant, v.game.dictVersion, ctrl.signal), + new Promise((r) => setTimeout(() => r(null), 5000)), + ]); + settled = true; + clearTimeout(grace); + dictWarming = false; + if (!loaded) { + // Did not load within the cap: abort the download so it stops starving the channel this + // session, and count the miss toward the bad-connection breaker (3 -> stop warming). + ctrl.abort(); + d.noteDictMiss(); + } + } + let previewTimer: ReturnType | null = null; + let evalCtrl: AbortController | null = null; function recompute() { preview = null; if (previewTimer) clearTimeout(previewTimer); + // The tiles changed: cancel any in-flight network preview (evaluate is non-mutating, so + // aborting is safe) — a stale, out-of-order response cannot overwrite the newer one, and + // rapid placements do not pile up requests on a slow link. + evalCtrl?.abort(); + evalCtrl = null; // Off-turn the composition is position-only: no score preview or evaluate. if (!isMyTurn) return; const sub = toSubmit(placement); if (!sub) return; + // Instant on-device preview when the game's dictionary is warm; the network otherwise. + const d = dict; + const v = view; + if (d && v) { + const reader = d.peekDawg(v.game.variant, v.game.dictVersion); + if (reader && hasAlphabet(v.game.variant)) { + try { + preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn); + return; + } catch { + /* fall through to the network preview */ + } + } + } + const ctrl = new AbortController(); + evalCtrl = ctrl; previewTimer = setTimeout(async () => { try { - preview = await gateway.evaluate(id, sub.tiles, variant); + preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal); } catch { - /* best-effort */ + /* best-effort (or aborted) */ } }, 250); } @@ -1122,6 +1209,11 @@ {/if} + +{#if dictWarming} + +{/if} + {#snippet scoreboardBlock()} diff --git a/ui/src/lib/alphabet.ts b/ui/src/lib/alphabet.ts index 3f0269f..a89b0ce 100644 --- a/ui/src/lib/alphabet.ts +++ b/ui/src/lib/alphabet.ts @@ -73,6 +73,12 @@ export function valueForLetter(variant: Variant, letter: string): number { return i === undefined ? 0 : t.values[i]; } +/** alphabetValues returns a variant's tile values indexed by alphabet letter index, or an + * empty array when the table is not yet cached. The local move validator scores with it. */ +export function alphabetValues(variant: Variant): readonly number[] { + return cache.get(variant)?.values ?? []; +} + /** indexForLetter maps a display letter to its wire index; a blank ("?") maps to the blank * sentinel. It throws when the letter is outside the cached alphabet — a placement bug, not * user input (the UI constrains every entry point to the variant's alphabet). */ diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 27a56db..3f0d82b 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -91,12 +91,19 @@ export interface GatewayClient { exchange(gameId: string, tiles: string[], variant: Variant): Promise; resign(gameId: string): Promise; hint(gameId: string): Promise; - evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; + evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; /** Hide a finished game from the caller's own lobby list; per-account, irreversible. */ hideGame(gameId: string): Promise; + // --- dictionary (local move preview) --- + /** Fetch the raw serialized dictionary blob for a (variant, version) pair. Session-gated; + * lets the client validate and score a move locally instead of a network round trip. + * opts.signal aborts a stalled download; opts.reload bypasses the browser HTTP cache (the + * debug reset, for testing a cold load). */ + fetchDict(variant: Variant, version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise; + // --- draft --- /** The player's server-persisted client-side composition (rack order + board tiles), so a * reload or a second device resumes the same arrangement. The JSON is opaque to the diff --git a/ui/src/lib/dict/dawg.parity.test.ts b/ui/src/lib/dict/dawg.parity.test.ts new file mode 100644 index 0000000..239047b --- /dev/null +++ b/ui/src/lib/dict/dawg.parity.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Dawg } from './dawg'; + +// Conformance gate for the ported dawg reader: it must agree with the +// authoritative Go dafsa reader on indexOf for EVERY stored word and EVERY +// negative, across all three shipped dictionaries. The golden vectors are +// produced by `go run ./backend/cmd/dictgen`; point the test at them with: +// DICT_DAWG_DIR=/dawg DICT_GOLD_DIR= +// When those are unset the suite skips (kept out of the default unit run until +// a committed sample fixture lands in Phase 1). +const dawgDir = process.env.DICT_DAWG_DIR; +const goldDir = process.env.DICT_GOLD_DIR; +const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir); + +const names = ['en_sowpods', 'ru_scrabble', 'ru_erudit']; + +// eachWord walks the [1-byte length][index bytes] framing emitted by dictgen. +function* eachWord(buf: Buffer): Generator { + let off = 0; + while (off < buf.length) { + const len = buf[off]; + yield buf.subarray(off + 1, off + 1 + len); + off += 1 + len; + } +} + +describe.skipIf(!ready)('dawg reader parity vs Go dafsa', () => { + for (const name of names) { + it( + name, + () => { + const bytes = new Uint8Array(readFileSync(join(dawgDir!, `${name}.dawg`))); + const meta = JSON.parse(readFileSync(join(goldDir!, `${name}.meta.json`), 'utf8')); + const dawg = new Dawg(bytes); + + expect(dawg.numAdded).toBe(meta.numAdded); + expect(dawg.numNodes).toBe(meta.numNodes); + + // Every stored word: indexOf(word) equals its position in index order. + let idx = 0; + let mismatches = 0; + const firstBad: string[] = []; + for (const word of eachWord(readFileSync(join(goldDir!, `${name}.words.bin`)))) { + const got = dawg.indexOf(word); + if (got !== idx) { + mismatches++; + if (firstBad.length < 5) firstBad.push(`idx ${idx}: got ${got} word [${Array.from(word)}]`); + } + idx++; + } + expect(idx).toBe(meta.numAdded); + expect(mismatches, firstBad.join('; ')).toBe(0); + + // Negatives must resolve to -1. + let negMismatches = 0; + const firstNeg: string[] = []; + for (const word of eachWord(readFileSync(join(goldDir!, `${name}.neg.bin`)))) { + const got = dawg.indexOf(word); + if (got !== -1) { + negMismatches++; + if (firstNeg.length < 5) firstNeg.push(`got ${got} word [${Array.from(word)}]`); + } + } + expect(negMismatches, firstNeg.join('; ')).toBe(0); + }, + 120_000, + ); + } +}); diff --git a/ui/src/lib/dict/dawg.ts b/ui/src/lib/dict/dawg.ts new file mode 100644 index 0000000..0953c5f --- /dev/null +++ b/ui/src/lib/dict/dawg.ts @@ -0,0 +1,202 @@ +// In-memory reader for the dafsa DAWG binary format, ported byte-for-byte from +// github.com/iliadenisov/dafsa (bits.go / disk.go / dawg.go). It answers the one +// query the local move validator needs: given a word as alphabet-index bytes, is +// it stored in the dictionary and at what insertion index. +// +// The Go reader treats the file as a big-endian, MSB-first bit stream. Every +// field this reader touches on the lookup path is at most ~31 bits wide, so the +// bit reader accumulates into a plain JavaScript number (no BigInt) and stays +// exact. Faithfulness to the Go reader is enforced by dawg.parity.test.ts, which +// checks indexOf against the authoritative Go output across the whole dictionary. + +// bitsLen returns the number of bits needed to represent x (Go math/bits.Len). +function bitsLen(x: number): number { + return x === 0 ? 0 : 32 - Math.clz32(x); +} + +/** + * Dawg is a read-only view over a serialized dafsa dictionary held in memory. + * Construct it from the raw bytes of a `.dawg` file, then call {@link indexOf}. + */ +export class Dawg { + private readonly bytes: Uint8Array; + private p = 0; // current position, in bits + + private readonly cbits: number; + private readonly abits: number; + private readonly numEdges: number; + private readonly wbits: number; + private readonly firstNodeOffset: number; + private readonly hasEmptyWord: boolean; + + /** Number of words stored (dafsa NumAdded). */ + readonly numAdded: number; + /** Number of graph nodes (dafsa NumNodes). */ + readonly numNodes: number; + + // Scratch for the last edge resolved by getEdge, mirroring dafsa's edgeEnd + + // final flag. Reused to keep the lookup path allocation-free. + private eNode = 0; + private eCount = 0; + private eFinal = false; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + + // Reject anything that is not a serialized dawg — e.g. an HTML error page or a + // truncated download routed here by mistake. The 32-bit big-endian header size is + // the total byte length; if it does not match, the blob is not a dawg. Without + // this guard a non-dawg blob would parse into a bogus reader that silently reports + // every word as missing (so the caller must throw here to fall back to the network). + const declaredSize = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0; + if (declaredSize !== bytes.length) { + throw new Error(`dawg: not a dawg blob (size header ${declaredSize} != ${bytes.length} bytes)`); + } + + // Header: 32-bit size (skipped — the whole file is already in memory), then + // cbits, abits, the language code string, and the word/node/edge counts. + this.p = 32; + this.cbits = this.readBits(8); + this.abits = this.readBits(8); + this.skipString(); // language code — not needed to walk index bytes + + const numAdded = this.readUnsigned(); + const numNodes = this.readUnsigned(); + this.numEdges = this.readUnsigned(); + this.firstNodeOffset = this.p; + this.hasEmptyWord = this.readBits(1) === 1; + + this.numAdded = numAdded; + this.numNodes = numNodes; + this.wbits = bitsLen(numAdded); + } + + /** + * indexOf returns the insertion index of the given word (as alphabet-index + * bytes), or -1 if the word was never added. Mirrors dafsa IndexOfB. + */ + indexOf(word: ArrayLike): number { + let skipped = 0; + let node = 0; // rootNode + let final = this.hasEmptyWord; + for (let i = 0; i < word.length; i++) { + if (!this.getEdge(node, word[i])) { + return -1; + } + node = this.eNode; + final = this.eFinal; + skipped += this.eCount; + } + return final ? skipped : -1; + } + + /** has reports whether the word (as alphabet-index bytes) is in the dictionary. */ + has(word: ArrayLike): boolean { + return this.indexOf(word) >= 0; + } + + // getEdge resolves the outgoing edge for ch from the node at the given bit + // offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors + // dafsa (*dawg).getEdge. + private getEdge(node: number, ch: number): boolean { + if (this.numEdges <= 0) { + return false; + } + const pos = node === 0 ? this.firstNodeOffset : node; + this.p = pos; + const nodeFinal = this.readBits(1); + const fallthrough = this.readBits(1); + + if (fallthrough === 1) { + const edgeCh = this.readBits(this.cbits); + if (edgeCh === ch) { + this.eCount = nodeFinal; + this.eNode = this.p; // the fallthrough target is the physically next node + this.eFinal = this.readBits(1) === 1; + return true; + } + return false; + } + + const singleEdge = this.readBits(1); + let numEdges = 1; + const nskiplen = bitsLen(this.wbits); + let nskip = 0; + if (singleEdge !== 1) { + numEdges = this.readUnsigned(); + nskip = this.readBits(nskiplen); + } + + const base = this.p; // bit offset of the first edge record + const recordBits = this.cbits + nskip + this.abits; + + // Binary search over the edges, which are sorted ascending by character. + let high = numEdges; + let low = -1; + while (high - low > 1) { + const probe = (high + low) >> 1; + // The first edge omits its (zero) skip field, so every later record is + // shifted back by nskip bits. + let seekTo = base + probe * recordBits; + if (probe > 0) { + seekTo -= nskip; + } + this.p = seekTo; + const edgeCh = this.readBits(this.cbits); + const cmp = edgeCh - ch; + if (cmp === 0) { + this.eCount = probe > 0 ? this.readBits(nskip) : nodeFinal; + this.eNode = this.readBits(this.abits); + this.p = this.eNode; + this.eFinal = this.readBits(1) === 1; + return true; + } else if (cmp < 0) { + low = probe; + } else { + high = probe; + } + } + return false; + } + + // readBits reads n (<= 32) bits MSB-first from the current position and + // advances it. Mirrors dafsa (*bitSeeker).ReadBits for the widths on this path. + private readBits(n: number): number { + let result = 0; + let p = this.p; + const bytes = this.bytes; + while (n > 0) { + const byteIndex = p >>> 3; + const bitInByte = p & 7; + const avail = 8 - bitInByte; + const take = avail < n ? avail : n; + const shift = avail - take; + const chunk = (bytes[byteIndex] >>> shift) & ((1 << take) - 1); + result = result * (1 << take) + chunk; + p += take; + n -= take; + } + this.p = p; + return result; + } + + // readUnsigned reads a dafsa "7code" varint (7 payload bits per byte, high bit + // continues). Mirrors dafsa readUnsigned. + private readUnsigned(): number { + let result = 0; + for (;;) { + const d = this.readBits(8); + result = result * 128 + (d & 0x7f); + if ((d & 0x80) === 0) { + break; + } + } + return result; + } + + // skipString advances past a 7code-length-prefixed byte string. + private skipString(): void { + const n = this.readUnsigned(); + this.p += n * 8; + } +} diff --git a/ui/src/lib/dict/direction.ts b/ui/src/lib/dict/direction.ts new file mode 100644 index 0000000..c3e1cfc --- /dev/null +++ b/ui/src/lib/dict/direction.ts @@ -0,0 +1,73 @@ +// Play-direction inference, ported from the backend engine (direction.go +// resolveDirection/runLength and domain.go (*Game).playDirection, v1.1.1). The +// client sends tiles without an orientation; the server infers it, so the local +// move preview must infer the same one or its words and score would diverge. + +import { validatePlay, Horizontal, Vertical, type Board, type Ruleset, type Placement, type Direction, type Dict } from './validate'; + +// runLength mirrors engine.runLength: how many cells the word through (row, col) +// along dir spans, counting the target square plus the filled runs either side. +function runLength(b: Board, row: number, col: number, dir: Direction): number { + let dr = 0; + let dc = 1; + if (dir === Vertical) { + dr = 1; + dc = 0; + } + let n = 1; + for (let r = row - dr, c = col - dc; b.filled(r, c); r -= dr, c -= dc) n++; + for (let r = row + dr, c = col + dc; b.filled(r, c); r += dr, c += dc) n++; + return n; +} + +/** + * resolveDirection infers a play's orientation from geometry alone, mirroring + * engine.resolveDirection: two or more tiles by the line they share; a single tile + * by the axis along which it abuts existing tiles, preferring the longer word and + * horizontal on a tie; a tile abutting nothing falls back to horizontal. + */ +export function resolveDirection(b: Board, placements: Placement[]): Direction { + if (placements.length >= 2) { + const row = placements[0].row; + for (let i = 1; i < placements.length; i++) { + if (placements[i].row !== row) return Vertical; + } + return Horizontal; + } + if (placements.length === 1) { + const p = placements[0]; + const h = runLength(b, p.row, p.col, Horizontal); + const v = runLength(b, p.row, p.col, Vertical); + if (v >= 2 && v > h) return Vertical; + if (h >= 2) return Horizontal; + if (v >= 2) return Vertical; + } + return Horizontal; +} + +/** + * playDirection resolves the orientation for a live play, mirroring engine + * (*Game).playDirection. It is the geometric resolution, except a single tile under + * the single-word rule (ignoreCrossWords) is ambiguous — its two orientations may + * differ in legality — so both are tried through the validator and the + * higher-scoring legal one is kept (horizontal breaks a tie). + */ +export function playDirection(b: Board, rs: Ruleset, dict: Dict, placements: Placement[]): Direction { + const geo = resolveDirection(b, placements); + if (placements.length !== 1 || !rs.ignoreCrossWords) { + return geo; + } + let best = geo; + let found = false; + let bestScore = 0; + for (const dir of [Horizontal, Vertical] as Direction[]) { + const r = validatePlay(b, rs, dict, dir, placements); + if (!r.legal || !r.move) continue; + if (!found || r.move.score > bestScore) { + best = dir; + found = true; + bestScore = r.move.score; + } + } + return best; +} diff --git a/ui/src/lib/dict/eval.parity.test.ts b/ui/src/lib/dict/eval.parity.test.ts new file mode 100644 index 0000000..c34d6e5 --- /dev/null +++ b/ui/src/lib/dict/eval.parity.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Dawg } from './dawg'; +import { evaluateLocal } from './eval'; +import { setAlphabet } from '../alphabet'; +import type { Board as ClientBoard, BoardCell } from '../board'; +import type { PlacedTile } from '../client'; +import type { Variant } from '../model'; + +// End-to-end conformance for the local-eval ADAPTER: it drives evaluateLocal through +// the real letter-space client path (the server-sent alphabet table, a letter board, +// letter placements) and asserts it matches the Go engine's EvalResult for every +// fixture. This is the cross-test the algorithm-level validate.parity suite does not +// cover — it exercises the letter<->index mapping, the ruleset assembly and the word +// decode, not just the index-space validator. Env-gated like the other parity suites. +const dawgDir = process.env.DICT_DAWG_DIR; +const validDir = process.env.DICT_VALID_DIR; +const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir); + +const dawgFile: Record = { + scrabble_en: 'en_sowpods.dawg', + scrabble_ru: 'ru_scrabble.dawg', + erudit_ru: 'ru_erudit.dawg', +}; + +interface FxCell { + R: number; + C: number; + Letter: number; + Blank: boolean; +} +interface FxWord { + Letters: number[]; +} +interface Fx { + board: number; + ignoreCrossWords: boolean; + tiles: FxCell[]; + legal: boolean; + score: number; + main?: FxWord; + cross?: FxWord[]; +} +interface Alpha { + index: number; + letter: string; + value: number; +} +interface VF { + rows: number; + cols: number; + alphabet: Alpha[]; + boards: (FxCell[] | null)[]; + fixtures: Fx[]; +} + +describe.skipIf(!ready)('local-eval adapter parity vs Go engine', () => { + for (const variant of Object.keys(dawgFile)) { + it( + variant, + () => { + const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8')); + const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant])))); + const v = variant as Variant; + + // Seed the alphabet cache exactly as the server would (wire entries, lower-cased + // letters); the adapter re-encodes through it, just like production. + setAlphabet(v, vf.alphabet.map((a) => ({ index: a.index, letter: a.letter, value: a.value }))); + const upper = vf.alphabet.map((a) => a.letter.toUpperCase()); + const lower = vf.alphabet.map((a) => a.letter); + + let legalCount = 0; + let mismatches = 0; + const first: string[] = []; + + for (let fi = 0; fi < vf.fixtures.length; fi++) { + const fx = vf.fixtures[fi]; + + // Build the client's letter-space board and placements from the fixture. + const board: ClientBoard = Array.from({ length: vf.rows }, () => + Array.from({ length: vf.cols }, () => null as BoardCell | null), + ); + for (const cl of vf.boards[fx.board] ?? []) board[cl.R][cl.C] = { letter: upper[cl.Letter], blank: cl.Blank }; + const tiles: PlacedTile[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: upper[t.Letter], blank: t.Blank })); + + const res = evaluateLocal(dawg, v, board, tiles, !fx.ignoreCrossWords); + + if (fx.legal) legalCount++; + + let ok = res.legal === fx.legal; + if (ok && fx.legal) { + const wantWords = [fx.main!, ...(fx.cross ?? [])].map((w) => w.Letters.map((i) => lower[i]).join('')); + ok = res.score === fx.score && res.words.length === wantWords.length && res.words.every((w, i) => w === wantWords[i]); + } + if (!ok) { + mismatches++; + if (first.length < 8) { + first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.score} words=${JSON.stringify(res.words)}`); + } + } + } + + expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0); + expect(mismatches, first.join(' | ')).toBe(0); + }, + 120_000, + ); + } +}); diff --git a/ui/src/lib/dict/eval.ts b/ui/src/lib/dict/eval.ts new file mode 100644 index 0000000..2db8b2a --- /dev/null +++ b/ui/src/lib/dict/eval.ts @@ -0,0 +1,104 @@ +// The local move preview: it produces the same EvalResult the network `evaluate` +// returns (legality, score, the words formed, the inferred orientation), computed +// on-device from a loaded dictionary so composing a move needs no round trip. It +// adapts the client's letter-space board and placements into the validator's +// index space (lib/dict/validate.ts), assembling the per-variant ruleset from the +// static geometry/constants (lib/premiums.ts) and the server-sent tile values +// (lib/alphabet.ts). The server stays authoritative — submit re-validates — so any +// throw here is caught by the caller, which falls back to the network preview. + +import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate'; +import { playDirection } from './direction'; +import type { Board as ClientBoard } from '../board'; +import type { PlacedTile } from '../client'; +import type { EvalResult, Variant } from '../model'; +import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; +import { alphabetValues, indexForLetter, letterForIndex } from '../alphabet'; + +function letterMultOf(p: Premium): number { + return p === 'DL' ? 2 : p === 'TL' ? 3 : 1; +} +function wordMultOf(p: Premium): number { + return p === 'DW' ? 2 : p === 'TW' ? 3 : 1; +} + +// buildBoard adapts the client's letter-space board into the validator's +// index-space read view, precomputing the letter indices once. +function buildBoard(variant: Variant, board: ClientBoard): VBoard { + const grid: ({ letter: number; blank: boolean } | null)[] = new Array(BOARD_SIZE * BOARD_SIZE).fill(null); + let empty = true; + for (let r = 0; r < BOARD_SIZE; r++) { + for (let c = 0; c < BOARD_SIZE; c++) { + const cell = board[r][c]; + if (cell) { + grid[r * BOARD_SIZE + c] = { letter: indexForLetter(variant, cell.letter), blank: cell.blank }; + empty = false; + } + } + } + const inBounds = (r: number, c: number): boolean => r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE; + return { + inBounds, + filled: (r, c) => inBounds(r, c) && grid[r * BOARD_SIZE + c] !== null, + cellAt: (r, c) => grid[r * BOARD_SIZE + c]!, + isEmpty: () => empty, + }; +} + +// buildRuleset assembles the per-variant scoring rules the validator needs. +function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset { + const prem = premiumGrid(variant); + const ctr = centre(variant); + const values = alphabetValues(variant); + return { + cols: BOARD_SIZE, + center: ctr.row * BOARD_SIZE + ctr.col, + rackSize: RACK_SIZE, + bingo: BINGO[variant], + values, + letterMult: (r, c) => letterMultOf(prem[r][c]), + wordMult: (r, c) => wordMultOf(prem[r][c]), + ignoreCrossWords: !multipleWords, + }; +} + +// decodeWord turns a word's alphabet indices back into a lower-cased string, the +// form the network EvalResult carries (the caption renders it directly). +function decodeWord(variant: Variant, letters: number[]): string { + let s = ''; + for (const i of letters) s += letterForIndex(variant, i); + return s.toLowerCase(); +} + +/** + * evaluateLocal computes the move preview for tiles placed on board in the given + * game, returning the same shape as the network `evaluate`. dict is the loaded + * dictionary reader for the game's (variant, version); multipleWords is the game's + * cross-word rule. It may throw if the variant's alphabet table is not loaded — the + * caller guards with hasAlphabet and falls back to the network on any failure. + */ +export function evaluateLocal( + dict: Dict, + variant: Variant, + board: ClientBoard, + tiles: PlacedTile[], + multipleWords: boolean, +): EvalResult { + const vboard = buildBoard(variant, board); + const rs = buildRuleset(variant, multipleWords); + const vtiles: VPlacement[] = tiles.map((t) => ({ + row: t.row, + col: t.col, + letter: indexForLetter(variant, t.letter), + blank: t.blank, + })); + + const dir = playDirection(vboard, rs, dict, vtiles); + const res = validatePlay(vboard, rs, dict, dir, vtiles); + if (!res.legal || !res.move) { + return { legal: false, score: 0, words: [], dir: '' }; + } + const m = res.move; + const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters)); + return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' }; +} diff --git a/ui/src/lib/dict/index.ts b/ui/src/lib/dict/index.ts new file mode 100644 index 0000000..cc1d230 --- /dev/null +++ b/ui/src/lib/dict/index.ts @@ -0,0 +1,7 @@ +// Public surface of the local move-preview subsystem, imported dynamically by the +// game so the whole dict subsystem (the reader, the validator, the cache) stays out +// of the initial app bundle — it is a progressive enhancement over the network +// preview, which remains the fallback. +export { evaluateLocal } from './eval'; +export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader'; +export { clearDictCache, listCachedDicts } from './store'; diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts new file mode 100644 index 0000000..15a64db --- /dev/null +++ b/ui/src/lib/dict/loader.ts @@ -0,0 +1,136 @@ +// Loads the dictionary reader for a (variant, version) pair for the local move +// preview, three tiers deep: an in-memory reader instance, then the persistent +// IndexedDB blob cache, then a session-gated network fetch (which repopulates the +// cache). Every tier is best-effort: on any failure getDawg resolves to null and +// the caller falls back to the network `evaluate`. Concurrent requests for the +// same dictionary share one in-flight load. + +import { Dawg } from './dawg'; +import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store'; +import { gateway } from '../gateway'; +import type { Variant } from '../model'; + +// Loaded readers by key. A dictionary is immutable, so an instance is reused for +// the whole app session once loaded. +const instances = new Map(); +// In-flight loads by key, so overlapping callers (prefetch + a game open) share one. +const inflight = new Map>(); + +// Bad-connection breaker: after a few dictionaries fail to become available in a session +// (a load that failed or a warm-up that hit its cap), stop attempting them so the player +// is not stuck re-watching the warm-up overlay when switching games. Session-scoped (in +// memory) — an app relaunch is a natural retry, when the connection may have recovered. +// Cached dictionaries still load. +const MISS_LIMIT = 3; +let misses = 0; +let dictDisabled = false; + +/** + * noteDictMiss records that a dictionary did not become available in time — a load that + * failed or a warm-up that hit its cap. After MISS_LIMIT in a session the breaker trips. + */ +export function noteDictMiss(): void { + if (!dictDisabled && ++misses >= MISS_LIMIT) dictDisabled = true; +} + +/** dictLoadingDisabled reports whether the bad-connection breaker has tripped for this + * session, so callers can skip the warm-up overlay and go straight to the network. */ +export function dictLoadingDisabled(): boolean { + return dictDisabled; +} + +// One-shot: the debug reset sets this so the next network fetch bypasses the browser HTTP +// cache. The immutable dict blob is otherwise cached for a year, so clearing IndexedDB alone +// still reloads it instantly from the HTTP cache — this forces a genuinely cold load. +let bustNext = false; + +/** bustDictHttpCache makes the next dictionary download bypass the browser HTTP cache. */ +export function bustDictHttpCache(): void { + bustNext = true; +} + +/** + * getDawg resolves the reader for the (variant, version) dictionary, or null when + * it cannot be obtained (no session, offline, storage or decode failure) — the + * caller then previews over the network instead. Successful loads are cached in + * memory and, when freshly fetched, in IndexedDB. + */ +export function getDawg(variant: Variant, version: string, signal?: AbortSignal): Promise { + const key = dictKey(variant, version); + const have = instances.get(key); + if (have) return Promise.resolve(have); + const pending = inflight.get(key); + if (pending) return pending; + const p = load(variant, version, key, signal).finally(() => inflight.delete(key)); + inflight.set(key, p); + return p; +} + +async function load(variant: Variant, version: string, key: string, signal?: AbortSignal): Promise { + // Tier 1: the persistent cache. + try { + const cached = await idbGetDawg(key); + if (cached) { + const reader = new Dawg(new Uint8Array(cached)); + instances.set(key, reader); + return reader; + } + } catch { + // A cached blob the reader rejected (a stale or partial entry): evict it so the next + // load re-fetches instead of failing on it forever, then fall through to the network. + void idbDelDawg(key); + } + + // Tier 2: the session-gated download — gated by the bad-connection breaker. A caller's + // signal aborts it (the warm-up giving up at its cap, or the game being left) so a large + // download does not keep starving the channel on a slow link; the caller counts the miss. + if (dictDisabled) return null; + let buf: ArrayBuffer; + const reload = bustNext; + bustNext = false; + try { + buf = await gateway.fetchDict(variant, version, { signal, reload }); + } catch { + return null; // network error or aborted + } + + let reader: Dawg; + try { + reader = new Dawg(new Uint8Array(buf)); + } catch { + return null; // not a dawg — do not cache it + } + instances.set(key, reader); + void idbPutDawg(key, buf); + void requestPersist(); + return reader; +} + +/** + * hasDawg reports whether the (variant, version) reader is already loaded in + * memory (a warm hit), so the UI can decide instantly whether to preview locally + * or show the warm-up state while the dictionary loads. + */ +export function hasDawg(variant: Variant, version: string): boolean { + return instances.has(dictKey(variant, version)); +} + +/** + * peekDawg returns the already-loaded reader for (variant, version), or null. It is + * a synchronous in-memory lookup (no load), so the move preview can validate locally + * without awaiting when the dictionary is already warm. + */ +export function peekDawg(variant: Variant, version: string): Dawg | null { + return instances.get(dictKey(variant, version)) ?? null; +} + +/** + * clearDictInstances drops the in-memory readers (e.g. on logout). The persistent + * IndexedDB cache is left intact — a dictionary version is immutable and reusable + * by the next account on the device. + */ +export function clearDictInstances(): void { + instances.clear(); + misses = 0; + dictDisabled = false; +} diff --git a/ui/src/lib/dict/store.ts b/ui/src/lib/dict/store.ts new file mode 100644 index 0000000..f79f7ac --- /dev/null +++ b/ui/src/lib/dict/store.ts @@ -0,0 +1,154 @@ +// Persistent cache for dictionary DAWG blobs, keyed by `${variant}@${version}`. +// It lives in its own IndexedDB database, separate from session.ts's 'scrabble' +// DB, so the multi-hundred-KB binary blobs never interfere with the session / +// prefs store or its schema versioning. Everything here is best-effort: any +// failure resolves to a miss or a no-op and the caller falls back to the network. + +const DB_NAME = 'scrabble-dict'; +const STORE = 'dawg'; + +let dbPromise: Promise | null | undefined; + +function openDb(): Promise | null { + if (dbPromise !== undefined) return dbPromise; + if (typeof indexedDB === 'undefined') { + dbPromise = null; + return null; + } + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }).catch(() => { + dbPromise = null; + throw new Error('indexedDB unavailable'); + }); + return dbPromise; +} + +/** dictKey is the persistent cache key for a (variant, version) dictionary. */ +export function dictKey(variant: string, version: string): string { + return `${variant}@${version}`; +} + +/** idbGetDawg returns the cached blob for key, or null on a miss or any failure. */ +export async function idbGetDawg(key: string): Promise { + const db = openDb(); + if (!db) return null; + try { + const d = await db; + return await new Promise((resolve, reject) => { + const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key); + r.onsuccess = () => resolve((r.result ?? null) as ArrayBuffer | null); + r.onerror = () => reject(r.error); + }); + } catch { + return null; + } +} + +/** idbPutDawg stores the blob under key, swallowing any failure (best-effort). */ +export async function idbPutDawg(key: string, data: ArrayBuffer): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(data, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + /* best-effort: a failed persist just re-downloads next time */ + } +} + +/** idbDelDawg removes a cached blob — e.g. a stale or partial entry the reader rejected, so + * the next load re-fetches instead of failing on it forever. Best-effort. */ +export async function idbDelDawg(key: string): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + /* best-effort */ + } +} + +let persistRequested = false; + +/** + * requestPersist asks the browser (once) not to evict this origin's storage, so a + * cached dictionary survives storage pressure. The grant is honoured unevenly + * across platforms (see docs), so it is only a hint — the cache stays best-effort. + */ +export async function requestPersist(): Promise { + if (persistRequested) return; + persistRequested = true; + try { + if (typeof navigator !== 'undefined' && navigator.storage?.persist) { + await navigator.storage.persist(); + } + } catch { + /* ignore — persistence is only a hint */ + } +} + +/** + * listCachedDicts returns the key (variant@version) and byte length of every cached + * dictionary blob, for the debug panel's local-dictionary readout. Best-effort: an + * empty list on any failure. + */ +export async function listCachedDicts(): Promise> { + const db = openDb(); + if (!db) return []; + try { + const d = await db; + return await new Promise((resolve) => { + const out: Array<{ key: string; bytes: number }> = []; + const req = d.transaction(STORE, 'readonly').objectStore(STORE).openCursor(); + req.onsuccess = () => { + const cur = req.result; + if (!cur) { + resolve(out); + return; + } + const v = cur.value as ArrayBuffer; + out.push({ key: String(cur.key), bytes: v?.byteLength ?? 0 }); + cur.continue(); + }; + req.onerror = () => resolve(out); + }); + } catch { + return []; + } +} + +/** + * clearDictCache empties the dictionary blob store (backs the DebugPanel reset). The + * store is cleared rather than the database deleted, to avoid a delete blocked on an + * open handle. Best-effort: a failure just leaves the cache to be reused. + */ +export async function clearDictCache(): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + /* best-effort */ + } +} diff --git a/ui/src/lib/dict/validate.parity.test.ts b/ui/src/lib/dict/validate.parity.test.ts new file mode 100644 index 0000000..7839415 --- /dev/null +++ b/ui/src/lib/dict/validate.parity.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Dawg } from './dawg'; +import { validatePlay, type Board, type Ruleset, type Placement, type Cell, type Direction } from './validate'; +import { playDirection } from './direction'; +import { premiumGrid, centre, BINGO, RACK_SIZE, BOARD_SIZE } from '../premiums'; +import type { Variant } from '../model'; + +// Conformance gate for the ported validator: for every fixture produced by +// `go run ./backend/cmd/validategen`, validatePlay must agree with the Go engine +// on legality, and — when legal — on the score, the bonus and the exact words +// formed (main + cross), across all variants and both cross-word rules. Point it +// at the fixtures + dictionaries with: +// DICT_DAWG_DIR=/dawg DICT_VALID_DIR= +// It skips when those are unset (kept out of the default unit run until a +// committed sample fixture lands). +const dawgDir = process.env.DICT_DAWG_DIR; +const validDir = process.env.DICT_VALID_DIR; +const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir); + +const dawgFile: Record = { + scrabble_en: 'en_sowpods.dawg', + scrabble_ru: 'ru_scrabble.dawg', + erudit_ru: 'ru_erudit.dawg', +}; + +interface FxCell { + R: number; + C: number; + Letter: number; + Blank: boolean; +} +interface FxWord { + Letters: number[]; + Score: number; +} +interface Fx { + board: number; + dir: number; + ignoreCrossWords: boolean; + tiles: FxCell[]; + legal: boolean; + score: number; + bonus: number; + main?: FxWord; + cross?: FxWord[]; +} +interface VF { + rows: number; + cols: number; + center: number; + rackSize: number; + bingo: number; + values: number[]; + premiums: number[]; + boards: (FxCell[] | null)[]; + fixtures: Fx[]; +} + +// Mirror rules.Premium.LetterMult / WordMult over the premium codes +// (0 none, 1 DL, 2 TL, 3 DW, 4 TW) emitted by validategen. +const letterMultOf = (code: number): number => (code === 1 ? 2 : code === 2 ? 3 : 1); +const wordMultOf = (code: number): number => (code === 3 ? 2 : code === 4 ? 3 : 1); + +function makeBoard(rows: number, cols: number, cells: FxCell[]): Board { + const grid: (Cell | null)[] = new Array(rows * cols).fill(null); + for (const cl of cells) grid[cl.R * cols + cl.C] = { letter: cl.Letter, blank: cl.Blank }; + const inBounds = (r: number, c: number) => r >= 0 && r < rows && c >= 0 && c < cols; + return { + inBounds, + filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null, + cellAt: (r, c) => grid[r * cols + c] as Cell, + isEmpty: () => cells.length === 0, + }; +} + +function arrEq(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +describe.skipIf(!ready)('validator parity vs Go engine', () => { + for (const variant of Object.keys(dawgFile)) { + it( + variant, + () => { + const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8')); + const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant])))); + const v = variant as Variant; + + // Pin the client's static per-variant rule constants to the engine's ruleset. + expect(RACK_SIZE, 'rack size').toBe(vf.rackSize); + expect(BINGO[v], 'bingo').toBe(vf.bingo); + const ctr = centre(v); + expect(ctr.row * BOARD_SIZE + ctr.col, 'centre').toBe(vf.center); + const clientPrem = premiumGrid(v); + const premCode = (p: string): number => (p === 'DL' ? 1 : p === 'TL' ? 2 : p === 'DW' ? 3 : p === 'TW' ? 4 : 0); + let premOk = true; + for (let r = 0; r < vf.rows; r++) + for (let c = 0; c < vf.cols; c++) if (premCode(clientPrem[r][c]) !== vf.premiums[r * vf.cols + c]) premOk = false; + expect(premOk, 'premium grid matches engine').toBe(true); + + let legalCount = 0; + let illegalCount = 0; + let mismatches = 0; + let dirMismatches = 0; + const first: string[] = []; + const firstDir: string[] = []; + + for (let fi = 0; fi < vf.fixtures.length; fi++) { + const fx = vf.fixtures[fi]; + const board = makeBoard(vf.rows, vf.cols, vf.boards[fx.board] ?? []); + const rs: Ruleset = { + cols: vf.cols, + center: vf.center, + rackSize: vf.rackSize, + bingo: vf.bingo, + values: vf.values, + letterMult: (r, c) => letterMultOf(vf.premiums[r * vf.cols + c]), + wordMult: (r, c) => wordMultOf(vf.premiums[r * vf.cols + c]), + ignoreCrossWords: fx.ignoreCrossWords, + }; + const tiles: Placement[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: t.Letter, blank: t.Blank })); + const res = validatePlay(board, rs, dawg, fx.dir as Direction, tiles); + + const gotDir = playDirection(board, rs, dawg, tiles); + if (gotDir !== fx.dir) { + dirMismatches++; + if (firstDir.length < 8) firstDir.push(`#${fi} dir exp ${fx.dir} got ${gotDir}`); + } + + if (fx.legal) legalCount++; + else illegalCount++; + + let ok = res.legal === fx.legal; + if (ok && fx.legal) { + const m = res.move!; + ok = + m.score === fx.score && + m.bonus === fx.bonus && + arrEq(m.main.letters, fx.main!.Letters) && + m.cross.length === (fx.cross?.length ?? 0) && + m.cross.every((w, i) => arrEq(w.letters, fx.cross![i].Letters)); + } + if (!ok) { + mismatches++; + if (first.length < 8) { + first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.move?.score ?? '-'}`); + } + } + } + + expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0); + expect(illegalCount, 'fixtures should include illegal plays').toBeGreaterThan(0); + expect(mismatches, first.join(' | ')).toBe(0); + expect(dirMismatches, firstDir.join(' | ')).toBe(0); + }, + 120_000, + ); + } +}); diff --git a/ui/src/lib/dict/validate.ts b/ui/src/lib/dict/validate.ts new file mode 100644 index 0000000..6701d2d --- /dev/null +++ b/ui/src/lib/dict/validate.ts @@ -0,0 +1,338 @@ +// Local move validator + scorer, ported from the scrabble-solver engine +// (scrabble/score.go and scrabble/solver.go, v1.1.1). It answers exactly what the +// server `evaluate` endpoint answers — is a play legal, what words does it form, +// and for how many points — so the client can preview a move without a round +// trip. The server stays authoritative: `submit_play` re-validates on commit, so +// this is an advisory accelerator, never a source of truth. +// +// Everything here works in alphabet-index space, mirroring the Go engine. The +// caller adapts the client's board/placements (letters) into indices via +// lib/alphabet.ts and the premium geometry via lib/premiums.ts. Faithfulness to +// the Go engine is pinned by validate.parity.test.ts against golden fixtures. + +/** Horizontal is an across play (fixed row, axis along columns). Mirrors scrabble.Horizontal. */ +export const Horizontal = 0; +/** Vertical is a down play (fixed column, axis along rows). Mirrors scrabble.Vertical. */ +export const Vertical = 1; +export type Direction = typeof Horizontal | typeof Vertical; + +/** A single newly-placed tile (alphabet-index letter; blank scores 0). */ +export interface Placement { + row: number; + col: number; + letter: number; + blank: boolean; +} + +/** An occupied board square: an alphabet-index letter and whether it is a blank. */ +export interface Cell { + letter: number; + blank: boolean; +} + +/** + * Board is the minimal read view the validator needs over the current position. + * cellAt is only consulted for squares that filled() reports as occupied. + */ +export interface Board { + inBounds(row: number, col: number): boolean; + /** filled reports whether (row, col) is on the board AND occupied. */ + filled(row: number, col: number): boolean; + cellAt(row: number, col: number): Cell; + /** isEmpty reports whether the whole board is empty (first-move detection). */ + isEmpty(): boolean; +} + +/** + * Ruleset carries the per-variant scoring data the validator needs. values is + * indexed by alphabet letter index; letterMult/wordMult return the premium + * multipliers of a square (1 when none); center is the row-major centre index; + * ignoreCrossWords selects the single-word-per-turn rule. + */ +export interface Ruleset { + cols: number; + center: number; + rackSize: number; + bingo: number; + values: readonly number[]; + letterMult(row: number, col: number): number; + wordMult(row: number, col: number): number; + ignoreCrossWords: boolean; +} + +/** Dict is the dictionary membership test (see Dawg.indexOf). */ +export interface Dict { + indexOf(word: ArrayLike): number; +} + +/** A word formed by a play, with its location, letters (indices) and score. */ +export interface Word { + row: number; + col: number; + dir: Direction; + letters: number[]; + blanks: boolean[]; + score: number; +} + +/** A scored play: the main word, any cross words, the bingo bonus and the total. */ +export interface Move { + dir: Direction; + tiles: Placement[]; + main: Word; + cross: Word[]; + bonus: number; + score: number; +} + +/** Reason a play is rejected. Mirrors the error conditions in the Go engine. */ +export type EvalError = + | 'empty' + | 'not-one-line' + | 'off-board' + | 'occupied' + | 'same-square' + | 'gap' + | 'too-short' + | 'main-not-in-dict' + | 'cross-not-in-dict' + | 'not-connected'; + +/** Result of {@link validatePlay}: a scored move plus whether it is legal. */ +export interface ValidateResult { + move?: Move; + legal: boolean; + err?: EvalError; +} + +// coord maps a line coordinate (fixed, axis) to a board (row, col) for dir. +function coord(dir: Direction, fixed: number, axis: number): [number, number] { + return dir === Horizontal ? [fixed, axis] : [axis, fixed]; +} + +// fixedAxis is the inverse of coord: it splits (row, col) into (fixed, axis). +function fixedAxis(dir: Direction, row: number, col: number): [number, number] { + return dir === Horizontal ? [row, col] : [col, row]; +} + +function perpendicular(d: Direction): Direction { + return d === Horizontal ? Vertical : Horizontal; +} + +/** + * evaluate computes the words and score for placing tiles on b in direction dir. + * It checks geometry only (tiles on one line, on empty squares, contiguous); the + * dictionary and connectivity are layered on by {@link validatePlay}. Mirrors + * scrabble.EvaluateOpts. + */ +export function evaluate( + b: Board, + rs: Ruleset, + dir: Direction, + tiles: Placement[], +): { move?: Move; err?: EvalError } { + if (tiles.length === 0) { + return { err: 'empty' }; + } + + const ts = tiles.slice().sort((x, y) => fixedAxis(dir, x.row, x.col)[1] - fixedAxis(dir, y.row, y.col)[1]); + + const fixed = fixedAxis(dir, ts[0].row, ts[0].col)[0]; + let prevAxis = 0; + for (let i = 0; i < ts.length; i++) { + const t = ts[i]; + const [f, a] = fixedAxis(dir, t.row, t.col); + if (f !== fixed) return { err: 'not-one-line' }; + if (!b.inBounds(t.row, t.col)) return { err: 'off-board' }; + if (b.filled(t.row, t.col)) return { err: 'occupied' }; + if (i > 0 && a === prevAxis) return { err: 'same-square' }; + prevAxis = a; + } + + const main = buildMainWord(b, rs, dir, fixed, ts); + if ('err' in main) return { err: main.err }; + + const move: Move = { dir, tiles: ts, main: main.word, cross: [], bonus: 0, score: main.word.score }; + if (!rs.ignoreCrossWords) { + for (const t of ts) { + const cw = crossWord(b, rs, dir, t); + if (cw) { + move.cross.push(cw); + move.score += cw.score; + } + } + } + if (ts.length === rs.rackSize) { + move.bonus = rs.bingo; + move.score += rs.bingo; + } + return { move }; +} + +// buildMainWord assembles and scores the word along dir through the sorted +// placements plus the existing tiles that extend and bridge them. Mirrors +// scrabble.buildMainWord. +function buildMainWord( + b: Board, + rs: Ruleset, + dir: Direction, + fixed: number, + ts: Placement[], +): { word: Word } | { err: EvalError } { + const minA = fixedAxis(dir, ts[0].row, ts[0].col)[1]; + const maxA = fixedAxis(dir, ts[ts.length - 1].row, ts[ts.length - 1].col)[1]; + + let start = minA; + for (;;) { + const [r, c] = coord(dir, fixed, start - 1); + if (!b.filled(r, c)) break; + start--; + } + let end = maxA; + for (;;) { + const [r, c] = coord(dir, fixed, end + 1); + if (!b.filled(r, c)) break; + end++; + } + + const letters: number[] = []; + const blanks: boolean[] = []; + let letterSum = 0; + let wordMult = 1; + let ti = 0; + for (let a = start; a <= end; a++) { + const [r, c] = coord(dir, fixed, a); + if (ti < ts.length) { + const ta = fixedAxis(dir, ts[ti].row, ts[ti].col)[1]; + if (ta === a) { + const t = ts[ti]; + ti++; + if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c); + wordMult *= rs.wordMult(r, c); + letters.push(t.letter); + blanks.push(t.blank); + continue; + } + } + if (b.filled(r, c)) { + const cell = b.cellAt(r, c); + if (!cell.blank) letterSum += rs.values[cell.letter]; + letters.push(cell.letter); + blanks.push(cell.blank); + continue; + } + return { err: 'gap' }; + } + + const [wr, wc] = coord(dir, fixed, start); + return { word: { row: wr, col: wc, dir, letters, blanks, score: letterSum * wordMult } }; +} + +// crossWord builds the perpendicular word formed by a single new tile, or null +// when the tile has no perpendicular neighbour. Mirrors scrabble.crossWord. +function crossWord(b: Board, rs: Ruleset, dir: Direction, t: Placement): Word | null { + const cdir = perpendicular(dir); + const [fixed, axis] = fixedAxis(cdir, t.row, t.col); + + let start = axis; + for (;;) { + const [r, c] = coord(cdir, fixed, start - 1); + if (!b.filled(r, c)) break; + start--; + } + let end = axis; + for (;;) { + const [r, c] = coord(cdir, fixed, end + 1); + if (!b.filled(r, c)) break; + end++; + } + if (start === end) return null; + + const letters: number[] = []; + const blanks: boolean[] = []; + let letterSum = 0; + let wordMult = 1; + for (let a = start; a <= end; a++) { + const [r, c] = coord(cdir, fixed, a); + if (a === axis) { + if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c); + wordMult *= rs.wordMult(r, c); + letters.push(t.letter); + blanks.push(t.blank); + } else { + const cell = b.cellAt(r, c); + if (!cell.blank) letterSum += rs.values[cell.letter]; + letters.push(cell.letter); + blanks.push(cell.blank); + } + } + const [wr, wc] = coord(cdir, fixed, start); + return { row: wr, col: wc, dir: cdir, letters, blanks, score: letterSum * wordMult }; +} + +/** + * validatePlay scores a play and checks that every word it forms is in the + * dictionary and that it connects to the board (or covers the centre on the + * first move). legal is true exactly when the play is legal. Mirrors + * scrabble.ValidatePlayOpts. + */ +export function validatePlay( + b: Board, + rs: Ruleset, + dict: Dict, + dir: Direction, + tiles: Placement[], +): ValidateResult { + const res = evaluate(b, rs, dir, tiles); + if (res.err) return { legal: false, err: res.err }; + const m = res.move!; + + if (m.main.letters.length < 2) return { move: m, legal: false, err: 'too-short' }; + if (dict.indexOf(m.main.letters) < 0) return { move: m, legal: false, err: 'main-not-in-dict' }; + for (const cw of m.cross) { + if (dict.indexOf(cw.letters) < 0) return { move: m, legal: false, err: 'cross-not-in-dict' }; + } + if (!connected(b, rs, m)) return { move: m, legal: false, err: 'not-connected' }; + return { move: m, legal: true }; +} + +// connected reports whether the play connects to the position (or covers the +// centre on the first move). Mirrors (*Solver).connected. +function connected(b: Board, rs: Ruleset, m: Move): boolean { + if (b.isEmpty()) { + const cr = Math.floor(rs.center / rs.cols); + const cc = rs.center % rs.cols; + return wordCovers(m.main, cr, cc); + } + if (rs.ignoreCrossWords) { + return m.main.letters.length > m.tiles.length; + } + return m.main.letters.length > m.tiles.length || touchesPerpendicular(b, m); +} + +// touchesPerpendicular reports whether any new tile abuts an existing tile +// perpendicular to the main word. Mirrors scrabble.touchesPerpendicular. +function touchesPerpendicular(b: Board, m: Move): boolean { + const cdir = perpendicular(m.dir); + for (const t of m.tiles) { + const [fixed, axis] = fixedAxis(cdir, t.row, t.col); + let [r, c] = coord(cdir, fixed, axis - 1); + if (b.filled(r, c)) return true; + [r, c] = coord(cdir, fixed, axis + 1); + if (b.filled(r, c)) return true; + } + return false; +} + +// wordCovers reports whether word w passes through square (r, c). Mirrors +// scrabble.wordCovers. +function wordCovers(w: Word, r: number, c: number): boolean { + for (let i = 0; i < w.letters.length; i++) { + let rr = w.row; + let cc = w.col; + if (w.dir === Horizontal) cc += i; + else rr += i; + if (rr === r && cc === c) return true; + } + return false; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 5c8ff2e..2254855 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -4,6 +4,7 @@ export const en = { 'app.title': 'Scrabble', + 'dict.loading': 'Loading…', 'connection.connecting': 'Connecting…', 'blocked.title': 'Account blocked', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b2aceca..53fba60 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -5,6 +5,7 @@ import type { MessageKey } from './en'; export const ru: Record = { 'app.title': 'Scrabble', + 'dict.loading': 'Загрузка…', 'connection.connecting': 'Подключение…', 'blocked.title': 'Учётная запись заблокирована', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 1bf2644..d08656f 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -161,6 +161,11 @@ export class MockGateway implements GatewayClient { // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. return { blocked: false, permanent: false, until: '', reason: '' }; } + async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise { + // No local dictionary in mock mode; the caller falls back to the mock evaluate. + throw new Error('fetchDict unsupported in mock'); + } + async gamesList(): Promise { return { games: [...this.games.values()].map((g) => structuredClone(g.view)), @@ -432,7 +437,7 @@ export class MockGateway implements GatewayClient { }; } - async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { + async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise { const g = this.game(gameId); if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' }; let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0); diff --git a/ui/src/lib/premiums.ts b/ui/src/lib/premiums.ts index d97fb1c..782ec9b 100644 --- a/ui/src/lib/premiums.ts +++ b/ui/src/lib/premiums.ts @@ -85,5 +85,19 @@ export function centre(variant: Variant): { row: number; col: number } { return { row: 7, col: 7 }; } -// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts); -// the board geometry above is all this module owns. +/** RACK_SIZE is the number of tiles drawn to a full rack — 7 for every variant. It sizes + * the all-tiles (bingo) bonus test in the local move preview. */ +export const RACK_SIZE = 7; + +/** BINGO is the all-tiles bonus per variant, ported from scrabble-solver/rules/rules.go: + * 50 for the Scrabble variants, 15 for Эрудит. The local move preview adds it when a play + * uses the whole rack. The dict conformance test pins these to the engine's rulesets. */ +export const BINGO: Record = { + scrabble_en: 50, + scrabble_ru: 50, + erudit_ru: 15, +}; + +// Tile values and the per-variant alphabet arrive from the server (lib/alphabet.ts); the +// board geometry, centre, rack size and bingo above are the static per-variant constants +// this module owns (all ported from scrabble-solver/rules/rules.go). diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 1dfa4e4..892b982 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -36,12 +36,13 @@ export function createTransport(baseUrl: string): GatewayClient { // exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. - async function exec(messageType: string, payload: Uint8Array): Promise { + async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise { for (let attempt = 0; ; attempt++) { let res; try { - res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() }); + res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal }); } catch (e) { + if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry const err = toGatewayError(e); if (retryable(err.code, messageType) && attempt < MAX_RETRIES) { reportOffline(); @@ -62,6 +63,23 @@ export function createTransport(baseUrl: string): GatewayClient { token = t; }, + async fetchDict(variant, version, opts) { + if (!token) throw new Error('fetchDict: no session'); + // Low priority so the browser schedules the (large) dictionary behind the game's own + // requests — the move eval, state and live events — on a slow link (EDGE). Ignored + // where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The + // signal aborts a stalled download so it stops holding the channel; reload (the debug + // "clear") bypasses the browser HTTP cache to force a fresh copy. + const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, { + headers: { authorization: `Bearer ${token}` }, + priority: 'low', + signal: opts?.signal, + cache: opts?.reload ? 'reload' : undefined, + } as RequestInit); + if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`); + return res.arrayBuffer(); + }, + async authTelegram(initData) { return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); }, @@ -119,8 +137,8 @@ export function createTransport(baseUrl: string): GatewayClient { async hint(id) { return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id))); }, - async evaluate(id, tiles, variant) { - return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant))); + async evaluate(id, tiles, variant, signal) { + return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant), signal)); }, async checkWord(id, word, variant) { return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));