feat: on-device move preview (local eval) with network fallback #154

Merged
developer merged 1 commits from feature/local-eval into development 2026-07-01 21:02:09 +00:00
38 changed files with 2612 additions and 28 deletions
+74 -2
View File
@@ -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
+193
View File
@@ -0,0 +1,193 @@
// Command dictgen dumps golden parity vectors from the committed dawg
// dictionaries so the TypeScript dawg reader can be checked byte-for-byte
// against the authoritative Go dafsa reader.
//
// For each *.dawg file it writes, into the output directory:
//
// - <name>.words.bin — every stored word as alphabet-index bytes, in index
// order, framed as [1-byte length][length index bytes]. The word at stream
// position k has IndexOfB == k.
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
// framing, to exercise the not-found path at varying depths.
// - <name>.meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for
// a header-parse sanity cross-check on the TS side.
//
// It is a development tool (not built into any service), analogous to
// cmd/jetgen. Run it from the repository root:
//
// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"sort"
"strings"
dawg "github.com/iliadenisov/dafsa"
)
// meta is the per-dictionary sanity payload cross-checked by the TS reader.
type meta struct {
NumAdded int `json:"numAdded"`
NumNodes int `json:"numNodes"`
NumEdges int `json:"numEdges"`
Alphabet int `json:"alphabet"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the golden files (required)")
negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg"))
if err != nil {
fail("glob: %v", err)
}
sort.Strings(files)
if len(files) == 0 {
fail("no .dawg files in %s", *dawgDir)
}
for _, f := range files {
if err := process(f, *outDir, *negCount); err != nil {
fail("%s: %v", filepath.Base(f), err)
}
}
}
// process emits the golden files for a single dawg dictionary.
func process(path, outDir string, negCount int) error {
name := strings.TrimSuffix(filepath.Base(path), ".dawg")
data, err := os.ReadFile(path)
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
// Stream every stored word in index order; keep a decimated sample and the
// maximum alphabet index for negative generation.
wf, err := os.Create(filepath.Join(outDir, name+".words.bin"))
if err != nil {
return err
}
bw := bufio.NewWriter(wf)
var (
count int
maxIx byte
sample [][]byte
)
finder.EnumerateB(func(index int, word []byte, final bool) int {
if !final {
return 0 // Continue
}
if index != count {
panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count))
}
writeWord(bw, word)
for _, b := range word {
if b > maxIx {
maxIx = b
}
}
if count%4 == 0 && len(sample) < 60000 {
sample = append(sample, append([]byte(nil), word...))
}
count++
return 0 // Continue
})
if err := bw.Flush(); err != nil {
return err
}
if err := wf.Close(); err != nil {
return err
}
if count != finder.NumAdded() {
return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded())
}
alphabet := int(maxIx) + 1
// Negatives: mutate sampled real words and keep the ones the reader rejects.
nf, err := os.Create(filepath.Join(outDir, name+".neg.bin"))
if err != nil {
return err
}
nbw := bufio.NewWriter(nf)
rng := rand.New(rand.NewSource(1))
neg := 0
for neg < negCount && len(sample) > 0 {
base := sample[rng.Intn(len(sample))]
cand := append([]byte(nil), base...)
switch rng.Intn(3) {
case 0: // extend by one index
cand = append(cand, byte(rng.Intn(alphabet)))
case 1: // flip one index
if len(cand) > 0 {
cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet))
}
case 2: // drop the tail and flip the new last index
if len(cand) > 1 {
cand = cand[:len(cand)-1]
cand[len(cand)-1] = byte(rng.Intn(alphabet))
}
}
if finder.IndexOfB(cand) == -1 {
writeWord(nbw, cand)
neg++
}
}
if err := nbw.Flush(); err != nil {
return err
}
if err := nf.Close(); err != nil {
return err
}
m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet}
mb, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil {
return err
}
fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n",
name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges())
return nil
}
// writeWord frames one index-byte word as [length][bytes].
func writeWord(w *bufio.Writer, word []byte) {
if len(word) > 255 {
panic(fmt.Sprintf("word too long to frame: %d", len(word)))
}
w.WriteByte(byte(len(word)))
w.Write(word)
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...)
os.Exit(1)
}
+486
View File
@@ -0,0 +1,486 @@
// Command validategen produces golden conformance fixtures for the TypeScript
// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays
// greedy games with the authoritative scrabble-solver engine to build realistic
// board positions, then records a battery of candidate plays — the engine's own
// top move, letter-mutated variants, random scatters and (on the empty board) an
// off-centre translation — each paired with the ground-truth result of
// ValidatePlayOpts (legal, score, the words formed). The TS conformance test
// replays these and must agree exactly.
//
// It is a development tool (not built into any service), analogous to
// cmd/dictgen. Run it from the repository root:
//
// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
dawg "github.com/iliadenisov/dafsa"
)
// blankTile marks a blank tile in a drawn hand (matches selfplay).
const blankTile byte = 0xff
// variantSpec pairs a variant label with its ruleset and dawg file.
type variantSpec struct {
name string
rules *rules.Ruleset
dawg string
}
// cell is an occupied board square or a placement (alphabet-index letter).
type cell struct {
R, C, Letter int
Blank bool
}
// word mirrors scrabble.Word in index space.
type word struct {
Row, Col, Dir int
Letters []int
Blanks []bool
Score int
}
// fixture is one candidate play with the engine's ground-truth verdict.
type fixture struct {
Board int `json:"board"` // index into the boards list
Dir int `json:"dir"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Tiles []cell `json:"tiles"`
Legal bool `json:"legal"`
Score int `json:"score"`
Bonus int `json:"bonus"`
Main *word `json:"main,omitempty"`
Cross []word `json:"cross,omitempty"`
}
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
// cross-test can drive the letter-space client path exactly as production does.
type alphaEntry struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
// variantFile is the whole conformance payload for one variant.
type variantFile struct {
Variant string `json:"variant"`
Rows int `json:"rows"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
Premiums []int `json:"premiums"` // row-major rules.Premium codes
Alphabet []alphaEntry `json:"alphabet"`
Boards [][]cell `json:"boards"`
Fixtures []fixture `json:"fixtures"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the fixture files (required)")
games := flag.Int("games", 6, "self-play games per (variant, rule)")
plies := flag.Int("plies", 40, "maximum plies captured per game")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
specs := []variantSpec{
{"scrabble_en", rules.English(), "en_sowpods.dawg"},
{"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"},
{"erudit_ru", rules.Erudit(), "ru_erudit.dawg"},
}
for _, sp := range specs {
if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil {
fail("%s: %v", sp.name, err)
}
}
}
func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg))
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
rs := sp.rules
solver := scrabble.NewSolver(rs, finder)
out := variantFile{
Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
}
// Capture under both the standard rule and the single-word rule, building the
// board with the same rule so positions are reachable under it.
for _, ignore := range []bool{false, true} {
opts := scrabble.PlayOptions{IgnoreCrossWords: ignore}
for g := range games {
seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name)
playAndCapture(&out, rs, solver, opts, seed, plies)
}
}
b, err := json.Marshal(&out)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil {
return err
}
fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures))
return nil
}
// playAndCapture greedily self-plays one game, recording candidate plays against
// each board position along the way.
func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) {
rng := rand.New(rand.NewSource(seed))
bag := selfplay.NewBag(rs, seed)
b := board.New(rs.Rows, rs.Cols)
hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)}
passes := 0
for turn := range plies {
p := turn % 2
rk := rackOf(hands[p], rs.Size())
moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts)
if len(moves) == 0 {
if passes++; passes >= 4 {
break
}
continue
}
passes = 0
top := moves[0]
boardIdx := len(out.Boards)
out.Boards = append(out.Boards, boardCells(b))
captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng)
scrabble.Apply(b, top)
hands[p] = removeUsed(hands[p], top)
if need := rs.RackSize - len(hands[p]); need > 0 {
hands[p] = append(hands[p], bag.Draw(need)...)
}
if len(hands[p]) == 0 && bag.Len() == 0 {
break
}
}
}
// captureCandidates records the engine's top move plus derived candidates for one
// board, each with its ValidatePlayOpts verdict.
func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) {
size := rs.Size()
record := func(tiles []scrabble.Placement) {
if len(tiles) == 0 {
return
}
out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles))
}
// The engine's own top move (legal).
record(top.Tiles)
// Letter-mutated variants: usually reject on the dictionary, occasionally form
// a different legal word.
for range 3 {
mut := clonePlacements(top.Tiles)
i := rng.Intn(len(mut))
mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size)
record(mut)
}
// Random scatters: exercise geometry, dictionary and connectivity paths.
for range 3 {
record(randomScatter(b, size, 2+rng.Intn(4), rng))
}
// Single tiles abutting the board exercise the direction inference — a single
// tile is ambiguous, its orientation resolved from which axis it extends.
for range 3 {
if t, ok := randomAdjacentSingle(b, size, rng); ok {
record([]scrabble.Placement{t})
}
}
// On the empty board, an off-centre translation of the first move exercises the
// first-move centre rule.
if b.IsEmpty() {
shifted := clonePlacements(top.Tiles)
ok := true
for i := range shifted {
shifted[i].Row++
shifted[i].Col++
if !b.InBounds(shifted[i].Row, shifted[i].Col) {
ok = false
break
}
}
if ok {
record(shifted)
}
}
}
// makeFixture validates a candidate against board b and serializes it with its
// ground truth. Word breakdown is recorded only for legal plays (the TS test
// checks words only then); an illegal play records legal=false alone.
func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture {
// Infer the orientation exactly as the backend evaluate does (dir-less), so the
// fixture matches the real eval path and pins the client's ported inference.
dir := playDirectionMirror(solver, b, tiles, opts)
fx := fixture{
Board: boardIdx,
Dir: int(dir),
IgnoreCrossWords: opts.IgnoreCrossWords,
Tiles: placementCells(tiles),
}
m, err := solver.ValidatePlayOpts(b, dir, tiles, opts)
if err == nil {
fx.Legal = true
fx.Score = m.Score
fx.Bonus = m.Bonus
fx.Main = toWord(m.Main)
for _, cw := range m.Cross {
fx.Cross = append(fx.Cross, *toWord(cw))
}
}
return fx
}
func placementCells(ts []scrabble.Placement) []cell {
cs := make([]cell, len(ts))
for i, t := range ts {
cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank}
}
return cs
}
func toWord(w scrabble.Word) *word {
letters := make([]int, len(w.Letters))
for i, l := range w.Letters {
letters[i] = int(l)
}
return &word{
Row: w.Row, Col: w.Col, Dir: int(w.Dir),
Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score,
}
}
func alphabetOf(rs *rules.Ruleset) []alphaEntry {
n := rs.Alphabet.Size()
out := make([]alphaEntry, n)
for i := range n {
ch, _ := rs.Alphabet.Character(byte(i))
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
}
return out
}
func premiumCodes(rs *rules.Ruleset) []int {
codes := make([]int, rs.Rows*rs.Cols)
for i := range codes {
codes[i] = int(rs.PremiumAt(i))
}
return codes
}
func boardCells(b *board.Board) []cell {
var cs []cell
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
v := b.At(r, c)
cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0})
}
}
}
return cs
}
func clonePlacements(ts []scrabble.Placement) []scrabble.Placement {
return append([]scrabble.Placement(nil), ts...)
}
// randomScatter picks n distinct empty in-bounds squares with random letters.
func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement {
seen := map[[2]int]bool{}
var ts []scrabble.Placement
for tries := 0; tries < n*20 && len(ts) < n; tries++ {
r := rng.Intn(b.Rows())
c := rng.Intn(b.Cols())
if seen[[2]int{r, c}] || b.Filled(r, c) {
continue
}
seen[[2]int{r, c}] = true
ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0})
}
return ts
}
// randomAdjacentSingle picks a random empty in-bounds square abutting at least one
// filled square, with a random letter — a single-tile play whose orientation the
// inference must resolve. It returns ok=false on an empty board.
func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) {
var cands [][2]int
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
continue
}
if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) {
cands = append(cands, [2]int{r, c})
}
}
}
if len(cands) == 0 {
return scrabble.Placement{}, false
}
rc := cands[rng.Intn(len(cands))]
return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true
}
// playDirectionMirror mirrors engine (*Game).playDirection: the geometric
// resolution, except a single tile under the single-word rule tries both
// orientations through the solver and keeps the higher-scoring legal one (H wins
// ties). It reproduces the orientation the backend evaluate infers.
func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction {
geo := resolveDirectionMirror(b, placements)
if len(placements) != 1 || !opts.IgnoreCrossWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := solver.ValidatePlayOpts(b, dir, placements, opts)
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// resolveDirectionMirror mirrors engine.resolveDirection.
func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
if len(placements) >= 2 {
row := placements[0].Row
for _, p := range placements[1:] {
if p.Row != row {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
if len(placements) == 1 {
p := placements[0]
h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal)
v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical)
if v >= 2 && v > h {
return scrabble.Vertical
}
if h >= 2 {
return scrabble.Horizontal
}
if v >= 2 {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
// runLengthMirror mirrors engine.runLength.
func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int {
dr, dc := 0, 1
if dir == scrabble.Vertical {
dr, dc = 1, 0
}
n := 1
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
n++
}
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
n++
}
return n
}
// rackOf builds a generation rack from a hand of tiles (reimplemented from the
// unexported selfplay helper).
func rackOf(tiles []byte, size int) rack.Rack {
r := rack.New(size)
for _, t := range tiles {
if t == blankTile {
r.AddBlank()
} else {
r.Add(t)
}
}
return r
}
// removeUsed returns the hand with the tiles consumed by m removed.
func removeUsed(tiles []byte, m scrabble.Move) []byte {
out := append([]byte(nil), tiles...)
for _, p := range m.Tiles {
want := p.Letter
if p.Blank {
want = blankTile
}
for i, t := range out {
if t == want {
out = append(out[:i], out[i+1:]...)
break
}
}
}
return out
}
func boolseed(b bool) int64 {
if b {
return 500000
}
return 0
}
func variantSeed(name string) int64 {
var s int64
for _, r := range name {
s = s*131 + int64(r)
}
return s
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...)
os.Exit(1)
}
+29 -3
View File
@@ -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
+8
View File
@@ -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 {
+3
View File
@@ -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)
+31
View File
@@ -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)
}
+1 -1
View File
@@ -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
+11
View File
@@ -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
+4 -1
View File
@@ -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 —
+3 -1
View File
@@ -171,7 +171,9 @@ _Вход сейчас только через провайдера, поэто
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками
+9
View File
@@ -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
+14
View File
@@ -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
+1
View File
@@ -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,
+7
View File
@@ -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) {
+28
View File
@@ -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 {
+78 -1
View File
@@ -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.
+4 -2
View File
@@ -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.
+35 -7
View File
@@ -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<void> {
@@ -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);
}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
// A cute emoji cycler shown while a game's dictionary warms for the local move
// preview (docs/UI_DESIGN.md). One emoji per 500ms tick — 300ms static, then a
// 200ms clockwise spin with the current emoji fading out and the next fading in —
// looping through the sequence; the ten emojis span the 5s warm-up cap.
const EMOJIS = ['🎲', '🤔', '📡', '📀', '💾', '⏳', '🐌', '🙈', '🇷🇺', '✌️'];
const TICK_MS = 500;
const SPIN_MS = 200;
let index = $state(0);
let timer: ReturnType<typeof setInterval> | null = null;
onMount(() => {
timer = setInterval(() => (index = (index + 1) % EMOJIS.length), TICK_MS);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
// A simultaneous fade + clockwise spin used as both the in and out transition on the
// keyed glyph, so the outgoing and incoming emoji cross-fade in place. Under
// reduce-motion it degrades to a plain fade.
function spinFade(_node: Element, { duration = SPIN_MS }: { duration?: number } = {}) {
const spin = !app.reduceMotion;
return {
duration,
css: (u: number) => (spin ? `opacity:${u};transform:rotate(${(1 - u) * 360}deg)` : `opacity:${u}`),
};
}
</script>
<div class="overlay" role="status" aria-live="polite">
<div class="box">
<div class="stage">
{#key index}
<span class="emoji" in:spinFade out:spinFade>{EMOJIS[index]}</span>
{/key}
</div>
<span class="caption">{t('dict.loading')}</span>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 200;
display: grid;
place-items: center;
/* Darker than the onboarding scrim — this blocks the board until the dictionary is ready. */
background: rgba(0, 0, 0, 0.72);
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
.box {
display: grid;
justify-items: center;
gap: 1rem;
}
.stage {
display: grid;
place-items: center;
/* Reserve the glyph box so the caption never shifts as the emoji swaps. */
width: 4rem;
height: 4rem;
}
.emoji {
grid-area: 1 / 1;
font-size: 3.25rem; /* about twice a tab-bar button */
line-height: 1;
will-change: transform, opacity;
}
.caption {
color: #fff;
font-size: 1rem;
opacity: 0.85;
}
</style>
+94 -2
View File
@@ -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<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(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<typeof import('../lib/dict') | null>(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<number | null>(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<null>((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<typeof setTimeout> | 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}
</Screen>
<!-- Warm-up overlay while the game's dictionary loads for the local move preview. -->
{#if dictWarming}
<DictWarmup />
{/if}
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet scoreboardBlock()}
+6
View File
@@ -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). */
+8 -1
View File
@@ -91,12 +91,19 @@ export interface GatewayClient {
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
hideGame(gameId: string): Promise<void>;
// --- 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<ArrayBuffer>;
// --- 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
+71
View File
@@ -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=<scrabble-solver>/dawg DICT_GOLD_DIR=<dictgen out>
// 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<Uint8Array> {
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,
);
}
});
+202
View File
@@ -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>): 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<number>): 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;
}
}
+73
View File
@@ -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;
}
+110
View File
@@ -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<string, string> = {
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,
);
}
});
+104
View File
@@ -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' };
}
+7
View File
@@ -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';
+136
View File
@@ -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<string, Dawg>();
// In-flight loads by key, so overlapping callers (prefetch + a game open) share one.
const inflight = new Map<string, Promise<Dawg | null>>();
// 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<Dawg | null> {
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<Dawg | null> {
// 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;
}
+154
View File
@@ -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<IDBDatabase> | null | undefined;
function openDb(): Promise<IDBDatabase> | null {
if (dbPromise !== undefined) return dbPromise;
if (typeof indexedDB === 'undefined') {
dbPromise = null;
return null;
}
dbPromise = new Promise<IDBDatabase>((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<ArrayBuffer | null> {
const db = openDb();
if (!db) return null;
try {
const d = await db;
return await new Promise<ArrayBuffer | null>((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<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((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<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((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<void> {
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<Array<{ key: string; bytes: number }>> {
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<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
/* best-effort */
}
}
+163
View File
@@ -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=<scrabble-solver>/dawg DICT_VALID_DIR=<validategen out>
// 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<string, string> = {
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,
);
}
});
+338
View File
@@ -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>): 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;
}
+1
View File
@@ -4,6 +4,7 @@
export const en = {
'app.title': 'Scrabble',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
'blocked.title': 'Account blocked',
+1
View File
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
export const ru: Record<MessageKey, string> = {
'app.title': 'Scrabble',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
'blocked.title': 'Учётная запись заблокирована',
+6 -1
View File
@@ -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<ArrayBuffer> {
// No local dictionary in mock mode; the caller falls back to the mock evaluate.
throw new Error('fetchDict unsupported in mock');
}
async gamesList(): Promise<GameList> {
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<EvalResult> {
async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise<EvalResult> {
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);
+16 -2
View File
@@ -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<Variant, number> = {
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).
+22 -4
View File
@@ -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<Uint8Array> {
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
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)));