feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.
- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
(immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
IndexedDB (best-effort, self-healing on a rejected blob) and reused across
sessions; a warm-up overlay covers a cold load, then the network preview is the
fallback; a bad-connection breaker stops warming after repeated misses; the move
preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
CI against the release dictionaries. A hidden debug readout lists the cached
dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Command dictgen dumps golden parity vectors from the committed dawg
|
||||
// dictionaries so the TypeScript dawg reader can be checked byte-for-byte
|
||||
// against the authoritative Go dafsa reader.
|
||||
//
|
||||
// For each *.dawg file it writes, into the output directory:
|
||||
//
|
||||
// - <name>.words.bin — every stored word as alphabet-index bytes, in index
|
||||
// order, framed as [1-byte length][length index bytes]. The word at stream
|
||||
// position k has IndexOfB == k.
|
||||
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
|
||||
// framing, to exercise the not-found path at varying depths.
|
||||
// - <name>.meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for
|
||||
// a header-parse sanity cross-check on the TS side.
|
||||
//
|
||||
// It is a development tool (not built into any service), analogous to
|
||||
// cmd/jetgen. Run it from the repository root:
|
||||
//
|
||||
// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out <dir>
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
)
|
||||
|
||||
// meta is the per-dictionary sanity payload cross-checked by the TS reader.
|
||||
type meta struct {
|
||||
NumAdded int `json:"numAdded"`
|
||||
NumNodes int `json:"numNodes"`
|
||||
NumEdges int `json:"numEdges"`
|
||||
Alphabet int `json:"alphabet"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
|
||||
outDir := flag.String("out", "", "output directory for the golden files (required)")
|
||||
negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary")
|
||||
flag.Parse()
|
||||
|
||||
if *outDir == "" {
|
||||
fail("-out is required")
|
||||
}
|
||||
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||
fail("mkdir out: %v", err)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg"))
|
||||
if err != nil {
|
||||
fail("glob: %v", err)
|
||||
}
|
||||
sort.Strings(files)
|
||||
if len(files) == 0 {
|
||||
fail("no .dawg files in %s", *dawgDir)
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if err := process(f, *outDir, *negCount); err != nil {
|
||||
fail("%s: %v", filepath.Base(f), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process emits the golden files for a single dawg dictionary.
|
||||
func process(path, outDir string, negCount int) error {
|
||||
name := strings.TrimSuffix(filepath.Base(path), ".dawg")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dawg: %w", err)
|
||||
}
|
||||
defer finder.Close()
|
||||
|
||||
// Stream every stored word in index order; keep a decimated sample and the
|
||||
// maximum alphabet index for negative generation.
|
||||
wf, err := os.Create(filepath.Join(outDir, name+".words.bin"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bw := bufio.NewWriter(wf)
|
||||
|
||||
var (
|
||||
count int
|
||||
maxIx byte
|
||||
sample [][]byte
|
||||
)
|
||||
finder.EnumerateB(func(index int, word []byte, final bool) int {
|
||||
if !final {
|
||||
return 0 // Continue
|
||||
}
|
||||
if index != count {
|
||||
panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count))
|
||||
}
|
||||
writeWord(bw, word)
|
||||
for _, b := range word {
|
||||
if b > maxIx {
|
||||
maxIx = b
|
||||
}
|
||||
}
|
||||
if count%4 == 0 && len(sample) < 60000 {
|
||||
sample = append(sample, append([]byte(nil), word...))
|
||||
}
|
||||
count++
|
||||
return 0 // Continue
|
||||
})
|
||||
if err := bw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wf.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != finder.NumAdded() {
|
||||
return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded())
|
||||
}
|
||||
|
||||
alphabet := int(maxIx) + 1
|
||||
|
||||
// Negatives: mutate sampled real words and keep the ones the reader rejects.
|
||||
nf, err := os.Create(filepath.Join(outDir, name+".neg.bin"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nbw := bufio.NewWriter(nf)
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
neg := 0
|
||||
for neg < negCount && len(sample) > 0 {
|
||||
base := sample[rng.Intn(len(sample))]
|
||||
cand := append([]byte(nil), base...)
|
||||
switch rng.Intn(3) {
|
||||
case 0: // extend by one index
|
||||
cand = append(cand, byte(rng.Intn(alphabet)))
|
||||
case 1: // flip one index
|
||||
if len(cand) > 0 {
|
||||
cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet))
|
||||
}
|
||||
case 2: // drop the tail and flip the new last index
|
||||
if len(cand) > 1 {
|
||||
cand = cand[:len(cand)-1]
|
||||
cand[len(cand)-1] = byte(rng.Intn(alphabet))
|
||||
}
|
||||
}
|
||||
if finder.IndexOfB(cand) == -1 {
|
||||
writeWord(nbw, cand)
|
||||
neg++
|
||||
}
|
||||
}
|
||||
if err := nbw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := nf.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet}
|
||||
mb, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n",
|
||||
name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges())
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeWord frames one index-byte word as [length][bytes].
|
||||
func writeWord(w *bufio.Writer, word []byte) {
|
||||
if len(word) > 255 {
|
||||
panic(fmt.Sprintf("word too long to frame: %d", len(word)))
|
||||
}
|
||||
w.WriteByte(byte(len(word)))
|
||||
w.Write(word)
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
// Command validategen produces golden conformance fixtures for the TypeScript
|
||||
// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays
|
||||
// greedy games with the authoritative scrabble-solver engine to build realistic
|
||||
// board positions, then records a battery of candidate plays — the engine's own
|
||||
// top move, letter-mutated variants, random scatters and (on the empty board) an
|
||||
// off-centre translation — each paired with the ground-truth result of
|
||||
// ValidatePlayOpts (legal, score, the words formed). The TS conformance test
|
||||
// replays these and must agree exactly.
|
||||
//
|
||||
// It is a development tool (not built into any service), analogous to
|
||||
// cmd/dictgen. Run it from the repository root:
|
||||
//
|
||||
// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out <dir>
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
)
|
||||
|
||||
// blankTile marks a blank tile in a drawn hand (matches selfplay).
|
||||
const blankTile byte = 0xff
|
||||
|
||||
// variantSpec pairs a variant label with its ruleset and dawg file.
|
||||
type variantSpec struct {
|
||||
name string
|
||||
rules *rules.Ruleset
|
||||
dawg string
|
||||
}
|
||||
|
||||
// cell is an occupied board square or a placement (alphabet-index letter).
|
||||
type cell struct {
|
||||
R, C, Letter int
|
||||
Blank bool
|
||||
}
|
||||
|
||||
// word mirrors scrabble.Word in index space.
|
||||
type word struct {
|
||||
Row, Col, Dir int
|
||||
Letters []int
|
||||
Blanks []bool
|
||||
Score int
|
||||
}
|
||||
|
||||
// fixture is one candidate play with the engine's ground-truth verdict.
|
||||
type fixture struct {
|
||||
Board int `json:"board"` // index into the boards list
|
||||
Dir int `json:"dir"`
|
||||
IgnoreCrossWords bool `json:"ignoreCrossWords"`
|
||||
Tiles []cell `json:"tiles"`
|
||||
Legal bool `json:"legal"`
|
||||
Score int `json:"score"`
|
||||
Bonus int `json:"bonus"`
|
||||
Main *word `json:"main,omitempty"`
|
||||
Cross []word `json:"cross,omitempty"`
|
||||
}
|
||||
|
||||
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
|
||||
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
|
||||
// cross-test can drive the letter-space client path exactly as production does.
|
||||
type alphaEntry struct {
|
||||
Index int `json:"index"`
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// variantFile is the whole conformance payload for one variant.
|
||||
type variantFile struct {
|
||||
Variant string `json:"variant"`
|
||||
Rows int `json:"rows"`
|
||||
Cols int `json:"cols"`
|
||||
Center int `json:"center"`
|
||||
RackSize int `json:"rackSize"`
|
||||
Bingo int `json:"bingo"`
|
||||
Values []int `json:"values"`
|
||||
Premiums []int `json:"premiums"` // row-major rules.Premium codes
|
||||
Alphabet []alphaEntry `json:"alphabet"`
|
||||
Boards [][]cell `json:"boards"`
|
||||
Fixtures []fixture `json:"fixtures"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
|
||||
outDir := flag.String("out", "", "output directory for the fixture files (required)")
|
||||
games := flag.Int("games", 6, "self-play games per (variant, rule)")
|
||||
plies := flag.Int("plies", 40, "maximum plies captured per game")
|
||||
flag.Parse()
|
||||
if *outDir == "" {
|
||||
fail("-out is required")
|
||||
}
|
||||
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||
fail("mkdir out: %v", err)
|
||||
}
|
||||
|
||||
specs := []variantSpec{
|
||||
{"scrabble_en", rules.English(), "en_sowpods.dawg"},
|
||||
{"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"},
|
||||
{"erudit_ru", rules.Erudit(), "ru_erudit.dawg"},
|
||||
}
|
||||
for _, sp := range specs {
|
||||
if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil {
|
||||
fail("%s: %v", sp.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
|
||||
data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dawg: %w", err)
|
||||
}
|
||||
defer finder.Close()
|
||||
|
||||
rs := sp.rules
|
||||
solver := scrabble.NewSolver(rs, finder)
|
||||
|
||||
out := variantFile{
|
||||
Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
|
||||
RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
|
||||
Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
|
||||
}
|
||||
|
||||
// Capture under both the standard rule and the single-word rule, building the
|
||||
// board with the same rule so positions are reachable under it.
|
||||
for _, ignore := range []bool{false, true} {
|
||||
opts := scrabble.PlayOptions{IgnoreCrossWords: ignore}
|
||||
for g := range games {
|
||||
seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name)
|
||||
playAndCapture(&out, rs, solver, opts, seed, plies)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(&out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures))
|
||||
return nil
|
||||
}
|
||||
|
||||
// playAndCapture greedily self-plays one game, recording candidate plays against
|
||||
// each board position along the way.
|
||||
func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) {
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
bag := selfplay.NewBag(rs, seed)
|
||||
b := board.New(rs.Rows, rs.Cols)
|
||||
hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)}
|
||||
|
||||
passes := 0
|
||||
for turn := range plies {
|
||||
p := turn % 2
|
||||
rk := rackOf(hands[p], rs.Size())
|
||||
moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts)
|
||||
if len(moves) == 0 {
|
||||
if passes++; passes >= 4 {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
passes = 0
|
||||
top := moves[0]
|
||||
|
||||
boardIdx := len(out.Boards)
|
||||
out.Boards = append(out.Boards, boardCells(b))
|
||||
captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng)
|
||||
|
||||
scrabble.Apply(b, top)
|
||||
hands[p] = removeUsed(hands[p], top)
|
||||
if need := rs.RackSize - len(hands[p]); need > 0 {
|
||||
hands[p] = append(hands[p], bag.Draw(need)...)
|
||||
}
|
||||
if len(hands[p]) == 0 && bag.Len() == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureCandidates records the engine's top move plus derived candidates for one
|
||||
// board, each with its ValidatePlayOpts verdict.
|
||||
func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) {
|
||||
size := rs.Size()
|
||||
record := func(tiles []scrabble.Placement) {
|
||||
if len(tiles) == 0 {
|
||||
return
|
||||
}
|
||||
out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles))
|
||||
}
|
||||
|
||||
// The engine's own top move (legal).
|
||||
record(top.Tiles)
|
||||
|
||||
// Letter-mutated variants: usually reject on the dictionary, occasionally form
|
||||
// a different legal word.
|
||||
for range 3 {
|
||||
mut := clonePlacements(top.Tiles)
|
||||
i := rng.Intn(len(mut))
|
||||
mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size)
|
||||
record(mut)
|
||||
}
|
||||
|
||||
// Random scatters: exercise geometry, dictionary and connectivity paths.
|
||||
for range 3 {
|
||||
record(randomScatter(b, size, 2+rng.Intn(4), rng))
|
||||
}
|
||||
|
||||
// Single tiles abutting the board exercise the direction inference — a single
|
||||
// tile is ambiguous, its orientation resolved from which axis it extends.
|
||||
for range 3 {
|
||||
if t, ok := randomAdjacentSingle(b, size, rng); ok {
|
||||
record([]scrabble.Placement{t})
|
||||
}
|
||||
}
|
||||
|
||||
// On the empty board, an off-centre translation of the first move exercises the
|
||||
// first-move centre rule.
|
||||
if b.IsEmpty() {
|
||||
shifted := clonePlacements(top.Tiles)
|
||||
ok := true
|
||||
for i := range shifted {
|
||||
shifted[i].Row++
|
||||
shifted[i].Col++
|
||||
if !b.InBounds(shifted[i].Row, shifted[i].Col) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
record(shifted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeFixture validates a candidate against board b and serializes it with its
|
||||
// ground truth. Word breakdown is recorded only for legal plays (the TS test
|
||||
// checks words only then); an illegal play records legal=false alone.
|
||||
func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture {
|
||||
// Infer the orientation exactly as the backend evaluate does (dir-less), so the
|
||||
// fixture matches the real eval path and pins the client's ported inference.
|
||||
dir := playDirectionMirror(solver, b, tiles, opts)
|
||||
fx := fixture{
|
||||
Board: boardIdx,
|
||||
Dir: int(dir),
|
||||
IgnoreCrossWords: opts.IgnoreCrossWords,
|
||||
Tiles: placementCells(tiles),
|
||||
}
|
||||
m, err := solver.ValidatePlayOpts(b, dir, tiles, opts)
|
||||
if err == nil {
|
||||
fx.Legal = true
|
||||
fx.Score = m.Score
|
||||
fx.Bonus = m.Bonus
|
||||
fx.Main = toWord(m.Main)
|
||||
for _, cw := range m.Cross {
|
||||
fx.Cross = append(fx.Cross, *toWord(cw))
|
||||
}
|
||||
}
|
||||
return fx
|
||||
}
|
||||
|
||||
func placementCells(ts []scrabble.Placement) []cell {
|
||||
cs := make([]cell, len(ts))
|
||||
for i, t := range ts {
|
||||
cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank}
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
func toWord(w scrabble.Word) *word {
|
||||
letters := make([]int, len(w.Letters))
|
||||
for i, l := range w.Letters {
|
||||
letters[i] = int(l)
|
||||
}
|
||||
return &word{
|
||||
Row: w.Row, Col: w.Col, Dir: int(w.Dir),
|
||||
Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score,
|
||||
}
|
||||
}
|
||||
|
||||
func alphabetOf(rs *rules.Ruleset) []alphaEntry {
|
||||
n := rs.Alphabet.Size()
|
||||
out := make([]alphaEntry, n)
|
||||
for i := range n {
|
||||
ch, _ := rs.Alphabet.Character(byte(i))
|
||||
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func premiumCodes(rs *rules.Ruleset) []int {
|
||||
codes := make([]int, rs.Rows*rs.Cols)
|
||||
for i := range codes {
|
||||
codes[i] = int(rs.PremiumAt(i))
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func boardCells(b *board.Board) []cell {
|
||||
var cs []cell
|
||||
for r := 0; r < b.Rows(); r++ {
|
||||
for c := 0; c < b.Cols(); c++ {
|
||||
if b.Filled(r, c) {
|
||||
v := b.At(r, c)
|
||||
cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0})
|
||||
}
|
||||
}
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
func clonePlacements(ts []scrabble.Placement) []scrabble.Placement {
|
||||
return append([]scrabble.Placement(nil), ts...)
|
||||
}
|
||||
|
||||
// randomScatter picks n distinct empty in-bounds squares with random letters.
|
||||
func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement {
|
||||
seen := map[[2]int]bool{}
|
||||
var ts []scrabble.Placement
|
||||
for tries := 0; tries < n*20 && len(ts) < n; tries++ {
|
||||
r := rng.Intn(b.Rows())
|
||||
c := rng.Intn(b.Cols())
|
||||
if seen[[2]int{r, c}] || b.Filled(r, c) {
|
||||
continue
|
||||
}
|
||||
seen[[2]int{r, c}] = true
|
||||
ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0})
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// randomAdjacentSingle picks a random empty in-bounds square abutting at least one
|
||||
// filled square, with a random letter — a single-tile play whose orientation the
|
||||
// inference must resolve. It returns ok=false on an empty board.
|
||||
func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) {
|
||||
var cands [][2]int
|
||||
for r := 0; r < b.Rows(); r++ {
|
||||
for c := 0; c < b.Cols(); c++ {
|
||||
if b.Filled(r, c) {
|
||||
continue
|
||||
}
|
||||
if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) {
|
||||
cands = append(cands, [2]int{r, c})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return scrabble.Placement{}, false
|
||||
}
|
||||
rc := cands[rng.Intn(len(cands))]
|
||||
return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true
|
||||
}
|
||||
|
||||
// playDirectionMirror mirrors engine (*Game).playDirection: the geometric
|
||||
// resolution, except a single tile under the single-word rule tries both
|
||||
// orientations through the solver and keeps the higher-scoring legal one (H wins
|
||||
// ties). It reproduces the orientation the backend evaluate infers.
|
||||
func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction {
|
||||
geo := resolveDirectionMirror(b, placements)
|
||||
if len(placements) != 1 || !opts.IgnoreCrossWords {
|
||||
return geo
|
||||
}
|
||||
best, found, bestScore := geo, false, 0
|
||||
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
|
||||
m, err := solver.ValidatePlayOpts(b, dir, placements, opts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !found || m.Score > bestScore {
|
||||
best, found, bestScore = dir, true, m.Score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// resolveDirectionMirror mirrors engine.resolveDirection.
|
||||
func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
|
||||
if len(placements) >= 2 {
|
||||
row := placements[0].Row
|
||||
for _, p := range placements[1:] {
|
||||
if p.Row != row {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
}
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
if len(placements) == 1 {
|
||||
p := placements[0]
|
||||
h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal)
|
||||
v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical)
|
||||
if v >= 2 && v > h {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
if h >= 2 {
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
if v >= 2 {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
}
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
|
||||
// runLengthMirror mirrors engine.runLength.
|
||||
func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int {
|
||||
dr, dc := 0, 1
|
||||
if dir == scrabble.Vertical {
|
||||
dr, dc = 1, 0
|
||||
}
|
||||
n := 1
|
||||
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
|
||||
n++
|
||||
}
|
||||
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// rackOf builds a generation rack from a hand of tiles (reimplemented from the
|
||||
// unexported selfplay helper).
|
||||
func rackOf(tiles []byte, size int) rack.Rack {
|
||||
r := rack.New(size)
|
||||
for _, t := range tiles {
|
||||
if t == blankTile {
|
||||
r.AddBlank()
|
||||
} else {
|
||||
r.Add(t)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// removeUsed returns the hand with the tiles consumed by m removed.
|
||||
func removeUsed(tiles []byte, m scrabble.Move) []byte {
|
||||
out := append([]byte(nil), tiles...)
|
||||
for _, p := range m.Tiles {
|
||||
want := p.Letter
|
||||
if p.Blank {
|
||||
want = blankTile
|
||||
}
|
||||
for i, t := range out {
|
||||
if t == want {
|
||||
out = append(out[:i], out[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolseed(b bool) int64 {
|
||||
if b {
|
||||
return 500000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func variantSeed(name string) int64 {
|
||||
var s int64
|
||||
for _, r := range name {
|
||||
s = s*131 + int64(r)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{
|
||||
VariantErudit: "ru_erudit.dawg",
|
||||
}
|
||||
|
||||
// entry is one resident dictionary: the loaded finder and the solver built over
|
||||
// it. The finder is retained so Close can release it.
|
||||
// entry is one resident dictionary: the loaded finder, the solver built over it
|
||||
// and the file it was loaded from. The finder is retained so Close can release
|
||||
// it; path is retained so the raw bytes can be re-read for the client download.
|
||||
type entry struct {
|
||||
finder dawg.Finder
|
||||
solver *scrabble.Solver
|
||||
path string
|
||||
}
|
||||
|
||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
|
||||
if old, ok := r.entries[v][version]; ok {
|
||||
_ = old.finder.Close()
|
||||
}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
|
||||
r.latest[v] = version
|
||||
return nil
|
||||
}
|
||||
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
|
||||
return versions
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
|
||||
// re-read from the file it was loaded from — the same immutable bytes the solver
|
||||
// holds. It backs the client-side dictionary download for the local move
|
||||
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
|
||||
// is not resident, and wraps any read error. The file is read outside the lock.
|
||||
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
|
||||
r.mu.RLock()
|
||||
versions, ok := r.entries[v]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||
}
|
||||
e, ok := versions[version]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||
}
|
||||
data, err := os.ReadFile(e.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Lookup reports whether word is present in the (variant, version) dictionary,
|
||||
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
||||
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
||||
|
||||
@@ -1649,6 +1649,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
|
||||
return present, nil
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
|
||||
// from the registry, backing the client-side dictionary download used by the
|
||||
// local move preview. It surfaces engine.ErrUnknownVariant /
|
||||
// engine.ErrUnknownVersion when that dictionary is not resident.
|
||||
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
|
||||
return svc.registry.DictBytes(variant, version)
|
||||
}
|
||||
|
||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||||
// allowance plus the profile wallet.
|
||||
func hintsRemaining(allowance, used, wallet int) int {
|
||||
|
||||
@@ -90,6 +90,9 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
u.POST("/games/:id/hide", s.handleHideGame)
|
||||
// Raw dictionary download for the client-side local move preview, keyed by
|
||||
// the game's pinned (variant, version); immutable, so cached hard.
|
||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||
}
|
||||
if s.feedback != nil {
|
||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// handleDictBytes streams the raw serialized dictionary for a (variant, version)
|
||||
// pair so the client can validate and score moves locally — the local move
|
||||
// preview — instead of a network round trip per tile arrangement. It is reached
|
||||
// only through the gateway's session-gated /dict route (which resolves the
|
||||
// X-User-ID this group requires), and served with a long immutable cache lifetime
|
||||
// because a published dictionary version never changes. An unknown variant or a
|
||||
// version that is not resident is a 404.
|
||||
func (s *Server) handleDictBytes(c *gin.Context) {
|
||||
variant, err := engine.ParseVariant(c.Param("variant"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unknown variant"})
|
||||
return
|
||||
}
|
||||
data, err := s.games.DictBytes(variant, c.Param("version"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "dictionary not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 —
|
||||
|
||||
@@ -171,7 +171,9 @@ _Вход сейчас только через провайдера, поэто
|
||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
||||
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
|
||||
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
|
||||
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export const en = {
|
||||
'app.title': 'Scrabble',
|
||||
'dict.loading': 'Loading…',
|
||||
'connection.connecting': 'Connecting…',
|
||||
|
||||
'blocked.title': 'Account blocked',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
||||
|
||||
export const ru: Record<MessageKey, string> = {
|
||||
'app.title': 'Scrabble',
|
||||
'dict.loading': 'Загрузка…',
|
||||
'connection.connecting': 'Подключение…',
|
||||
|
||||
'blocked.title': 'Учётная запись заблокирована',
|
||||
|
||||
@@ -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
@@ -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
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user