From c334a9d7b770cee5165a26b3249f8e39aea78ab0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 01:35:11 +0200 Subject: [PATCH 01/54] feat(offline): port DAWG cursor + move generator to TS (parity-pinned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First engine-first step of PWA offline mode (Phase A): the client-side move generator — the "robot brain" a local vs_ai game will run on-device — with no runtime wiring yet (Phase B). - dawg.ts: add the step-by-step cursor (root/final/next/arcs), a faithful port of dafsa traverse.go over the reader's existing bitstream. - generate.ts: the Appel-Jacobson generator (leftPart/extendRight + cross-sets + counts-rack + board transpose + moveKey ranking), reusing the cursor and validate.ts evaluate/connected. A cross-set LetterSet is a Uint8Array, so the 33-letter Russian alphabet (index 32) is exact under JS bit ops. - validate.ts: export connected for the generator's connectivity filter. - backend/cmd/movegen: dev tool building small sample dictionaries and emitting golden move-generation fixtures from the real Go solver (EN + RU). - tests: dawg.cursor.test.ts (enumeration bijection vs indexOf) and generate.parity.test.ts (7/7 vs the Go solver: empty board, mid-game, blank, single-word rule, Russian index-32 cross-set). The committed EN sample also unblocks the existing skipped dawg.parity.test.ts once wired with DICT_* in CI. Pure additive library code; no runtime behavior change. --- backend/cmd/movegen/main.go | 295 + ui/src/lib/dict/dawg.cursor.test.ts | 68 + ui/src/lib/dict/dawg.ts | 79 + ui/src/lib/dict/generate.parity.test.ts | 99 + ui/src/lib/dict/generate.ts | 418 + ui/src/lib/dict/testdata/sample_en.dawg | Bin 0 -> 61 bytes ui/src/lib/dict/testdata/sample_en.gen.json | 9802 +++++++++++++++++ ui/src/lib/dict/testdata/sample_en.words.json | 122 + ui/src/lib/dict/testdata/sample_ru.dawg | Bin 0 -> 37 bytes ui/src/lib/dict/testdata/sample_ru.gen.json | 1271 +++ ui/src/lib/dict/testdata/sample_ru.words.json | 38 + ui/src/lib/dict/validate.ts | 5 +- 12 files changed, 12195 insertions(+), 2 deletions(-) create mode 100644 backend/cmd/movegen/main.go create mode 100644 ui/src/lib/dict/dawg.cursor.test.ts create mode 100644 ui/src/lib/dict/generate.parity.test.ts create mode 100644 ui/src/lib/dict/generate.ts create mode 100644 ui/src/lib/dict/testdata/sample_en.dawg create mode 100644 ui/src/lib/dict/testdata/sample_en.gen.json create mode 100644 ui/src/lib/dict/testdata/sample_en.words.json create mode 100644 ui/src/lib/dict/testdata/sample_ru.dawg create mode 100644 ui/src/lib/dict/testdata/sample_ru.gen.json create mode 100644 ui/src/lib/dict/testdata/sample_ru.words.json diff --git a/backend/cmd/movegen/main.go b/backend/cmd/movegen/main.go new file mode 100644 index 0000000..f7003cb --- /dev/null +++ b/backend/cmd/movegen/main.go @@ -0,0 +1,295 @@ +// Command movegen emits golden conformance fixtures for the client-side move +// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is +// committed so the TypeScript parity tests run without a Go toolchain. +// +// For each small sample dictionary (English and Russian — the latter reaches +// alphabet index 32, exercising the 33-letter cross-set boundary) it writes: +// +// - sample_.dawg the serialized dictionary (the reader/cursor fixture) +// - sample_.words.json the stored words + their alphabet indexes +// - sample_.gen.json ranked move-generation results from the real solver, +// for a handful of positions, plus the ruleset the TS +// side rebuilds to score identically +// +// Positions are built with only the solver's public API: an empty board, and +// two-ply positions reached by applying the solver's own top move (so no internal +// encoding is needed). Regenerate with: +// +// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata +package main + +import ( + "encoding/json" + "flag" + "log" + "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" + dawg "github.com/iliadenisov/dafsa" +) + +// sampleWordsEN is the English sample dictionary, in strictly increasing +// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars), +// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word. +var sampleWordsEN = []string{ + "a", "an", "and", "ant", + "car", "care", "cared", "cares", "cars", "cat", "cats", + "do", "doe", "does", "dog", "dogs", "done", "dot", +} + +// sampleWordsRU is the Russian sample dictionary, in strictly increasing index +// order. It deliberately includes words starting with я (index 32) so the ported +// cross-set handles alphabet indexes past JS's 31-bit shift boundary. +var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"} + +// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test +// knows the exact word set (as alphabet indexes) to expect from enumeration. +type sampleFixture struct { + Alphabet string `json:"alphabet"` + NumAdded int `json:"numAdded"` + Words []string `json:"words"` + Indexes [][]int `json:"indexes"` +} + +// genFixture is the move-generation golden set for one sample dictionary. +type genFixture struct { + Ruleset genRuleset `json:"ruleset"` + Cases []genCase `json:"cases"` +} + +// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go +// solver: letter values, premium multipliers per square, the centre, rack size and bonus. +type genRuleset struct { + Size int `json:"size"` + Cols int `json:"cols"` + Center int `json:"center"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Values []int `json:"values"` + LetterMult [][]int `json:"letterMult"` + WordMult [][]int `json:"wordMult"` +} + +// genTile is one placed tile (a board tile or a move placement). +type genTile struct { + Row int `json:"row"` + Col int `json:"col"` + Letter int `json:"letter"` + Blank bool `json:"blank"` +} + +// genRack is a rack as a multiset of letter indexes plus a blank count. +type genRack struct { + Letters []int `json:"letters"` + Blanks int `json:"blanks"` +} + +// genMove is one ranked generated play: its orientation, placed tiles and total score. +type genMove struct { + Dir int `json:"dir"` + Tiles []genTile `json:"tiles"` + Score int `json:"score"` +} + +// genCase is one generation position: the tiles already on the board (empty when +// none), the rack, the mode/rule and the ranked moves the solver returns. +type genCase struct { + Name string `json:"name"` + Placed []genTile `json:"placed"` + Rack genRack `json:"rack"` + Mode int `json:"mode"` + IgnoreCrossWords bool `json:"ignoreCrossWords"` + Moves []genMove `json:"moves"` +} + +func main() { + out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures") + flag.Parse() + + if err := os.MkdirAll(*out, 0o755); err != nil { + log.Fatalf("movegen: mkdir %s: %v", *out, err) + } + + buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{ + emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false), + emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false), + emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false), + emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true), + }) + + buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{ + emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false), + }) +} + +// buildSample writes the dawg, the word fixture and the generation golden set for +// one sample dictionary. Two-ply cases are appended: the solver's own top move from +// the first non-empty result is applied, then generation runs again on the new rack. +func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) { + idx := rs.Alphabet + b := dawg.New(idx) + indexes := make([][]int, 0, len(words)) + for _, w := range words { + if err := b.Add(w); err != nil { + log.Fatalf("movegen[%s]: add %q: %v", tag, w, err) + } + enc, err := idx.Encode(w) + if err != nil { + log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err) + } + ints := make([]int, len(enc)) + for i, x := range enc { + ints[i] = int(x) + } + indexes = append(indexes, ints) + } + finder := b.Finish() + + writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{ + Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes, + }) + dawgPath := filepath.Join(out, "sample_"+tag+".dawg") + if _, err := finder.Save(dawgPath); err != nil { + log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err) + } + + s := scrabble.NewSolver(rs, finder) + for i := range cases { + runCase(s, rs, &cases[i], nil) + } + // A two-ply position from the first standard case that produced a move. + if two := twoPly(s, rs, cases); two != nil { + cases = append(cases, *two) + } + + writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{ + Ruleset: rulesetOf(rs), Cases: cases, + }) + log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases)) +} + +// runCase fills a case's Moves by generating on a board holding the given placed +// tiles (nil = empty board). +func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) { + bd := board.New(rs.Rows, rs.Cols) + for _, t := range placed { + bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank)) + } + c.Placed = placed + rk := toRack(rs.Size(), c.Rack) + moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords}) + c.Moves = movesOf(moves) +} + +// twoPly reaches a mid-game position by applying the top move of the first standard +// case that generated one, then generates again with a fresh rack of the same tiles. +func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase { + for _, c := range cases { + if c.IgnoreCrossWords || len(c.Moves) == 0 { + continue + } + placed := c.Moves[0].Tiles + next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)} + runCase(s, rs, &next, placed) + return &next + } + return nil +} + +// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold +// letter+1, bit 7 marks a blank). Duplicated here because that package is internal +// to the solver module and cannot be imported. +func cellByte(letter int, blank bool) byte { + v := byte(letter+1) & 0x3f + if blank { + v |= 0x80 + } + return v +} + +func toRack(size int, r genRack) rack.Rack { + rk := rack.New(size) + for _, l := range r.Letters { + rk.Add(byte(l)) + } + for i := 0; i < r.Blanks; i++ { + rk.AddBlank() + } + return rk +} + +func rulesetOf(rs *rules.Ruleset) genRuleset { + lm := make([][]int, rs.Rows) + wm := make([][]int, rs.Rows) + for r := 0; r < rs.Rows; r++ { + lm[r] = make([]int, rs.Cols) + wm[r] = make([]int, rs.Cols) + for c := 0; c < rs.Cols; c++ { + p := rs.Premium(r, c) + lm[r][c] = p.LetterMult() + wm[r][c] = p.WordMult() + } + } + return genRuleset{ + Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize, + Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm, + } +} + +func movesOf(ms []scrabble.Move) []genMove { + out := make([]genMove, len(ms)) + for i, m := range ms { + out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score} + } + return out +} + +func tilesOf(ps []scrabble.Placement) []genTile { + out := make([]genTile, len(ps)) + for i, p := range ps { + out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank} + } + return out +} + +// emptyCase builds an empty-board case (Moves filled later by runCase). +func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase { + return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross} +} + +// englishRack builds a rack from lowercase a-z letters (index = letter-'a'). +func englishRack(letters string, blanks int) genRack { + idx := make([]int, 0, len(letters)) + for _, ch := range letters { + idx = append(idx, int(ch-'a')) + } + return genRack{Letters: idx, Blanks: blanks} +} + +// russianRack builds a rack from the Russian sample letters used above. +func russianRack(letters string, blanks int) genRack { + m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32} + idx := make([]int, 0, len([]rune(letters))) + for _, ch := range letters { + i, ok := m[ch] + if !ok { + log.Fatalf("movegen: russianRack: no index for %q", string(ch)) + } + idx = append(idx, i) + } + return genRack{Letters: idx, Blanks: blanks} +} + +func writeJSON(path string, v any) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + log.Fatalf("movegen: marshal %s: %v", path, err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + log.Fatalf("movegen: write %s: %v", path, err) + } +} diff --git a/ui/src/lib/dict/dawg.cursor.test.ts b/ui/src/lib/dict/dawg.cursor.test.ts new file mode 100644 index 0000000..6f77b5e --- /dev/null +++ b/ui/src/lib/dict/dawg.cursor.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { Dawg } from './dawg'; + +// The step-by-step DAWG cursor (root/final/next/arcs) is the primitive the move +// generator walks. These fast unit tests pin it against a small committed sample +// dictionary (backend/cmd/movegen); the full parity vs the Go solver lands with +// the generator's conformance fixtures. +const bytes = new Uint8Array(readFileSync(new URL('./testdata/sample_en.dawg', import.meta.url))); +const fixture = JSON.parse( + readFileSync(new URL('./testdata/sample_en.words.json', import.meta.url), 'utf8'), +) as { numAdded: number; words: string[]; indexes: number[][] }; + +const key = (w: number[]): string => w.join(','); + +// enumerateWords walks the whole automaton depth-first, collecting the index path +// at every accepting node — i.e. every stored word. +function enumerateWords(d: Dawg): number[][] { + const out: number[][] = []; + const path: number[] = []; + const visit = (node: number): void => { + d.arcs(node, (label, dest, final) => { + path.push(label); + if (final) out.push(path.slice()); + visit(dest); + path.pop(); + return true; + }); + }; + if (d.final(d.root())) out.push([]); + visit(d.root()); + return out; +} + +describe('dawg cursor', () => { + it('parses the sample fixture', () => { + const d = new Dawg(bytes); + expect(d.numAdded).toBe(fixture.numAdded); + }); + + it('root is not an accepting state (the sample has no empty word)', () => { + const d = new Dawg(bytes); + expect(d.final(d.root())).toBe(false); + }); + + it('enumerates exactly the stored words', () => { + const d = new Dawg(bytes); + const got = enumerateWords(d).map(key).sort(); + const want = fixture.indexes.map(key).sort(); + expect(got).toEqual(want); + }); + + it('next walks a stored word to an accepting node and rejects a non-edge', () => { + const d = new Dawg(bytes); + let node = d.root(); + for (const ch of [2, 0, 17, 4, 3]) { + // "cared" + node = d.next(node, ch); + expect(node).toBeGreaterThanOrEqual(0); + } + expect(d.final(node)).toBe(true); + // "care" (index [2,0,17,4]) is an internal accepting node on the way to "cared". + const care = [2, 0, 17, 4].reduce((n, ch) => d.next(n, ch), d.root()); + expect(d.final(care)).toBe(true); + // No stored word starts with 'z' (index 25). + expect(d.next(d.root(), 25)).toBe(-1); + }); +}); diff --git a/ui/src/lib/dict/dawg.ts b/ui/src/lib/dict/dawg.ts index 0953c5f..24a6b0f 100644 --- a/ui/src/lib/dict/dawg.ts +++ b/ui/src/lib/dict/dawg.ts @@ -95,6 +95,85 @@ export class Dawg { return this.indexOf(word) >= 0; } + // --- Step-by-step traversal (the move generator's primitive) --------------- + // + // A `Node` is a bit offset into the graph; 0 denotes the root (which resolves + // to firstNodeOffset). These mirror dafsa's traverse.go Cursor (Root/Final/ + // Next/Arcs) over the same bitstream this reader already decodes, so the ported + // generator can drive the automaton one transition at a time. Single-threaded + // JS shares this reader's position across calls; every method re-seeks to its + // node on entry, and arcs brackets the callback with a save/restore, so nested + // use during a walk is safe. Mirrors dafsa (*Cursor). + + /** root returns the start state of the automaton. */ + root(): number { + return 0; + } + + /** final reports whether node is an accepting state (a stored word ends there). */ + final(node: number): boolean { + if (this.numEdges <= 0) { + return this.hasEmptyWord && node === 0; + } + this.p = node === 0 ? this.firstNodeOffset : node; + return this.readBits(1) === 1; + } + + /** + * next follows the edge labelled ch (an alphabet index) from node, returning the + * destination node, or -1 when no such edge exists. + */ + next(node: number, ch: number): number { + return this.getEdge(node, ch) ? this.eNode : -1; + } + + /** + * arcs calls fn for each out-edge of node in ascending label order, passing the + * edge's label, its destination node and whether that destination is accepting. + * It stops early if fn returns false. Mirrors dafsa (*Cursor).Arcs. + */ + arcs(node: number, fn: (label: number, dest: number, final: boolean) => boolean): void { + if (this.numEdges <= 0) { + return; + } + this.p = node === 0 ? this.firstNodeOffset : node; + this.readBits(1); // node final flag — not needed here + const fallthrough = this.readBits(1); + + if (fallthrough === 1) { + const label = this.readBits(this.cbits); + // The reader now sits at the destination node, whose first bit is its final flag. + const dest = this.p; + const final = this.readBits(1) === 1; + fn(label, dest, final); + return; + } + + const nskiplen = bitsLen(this.wbits); + let nskip = 0; + let numEdges = 1; + if (this.readBits(1) !== 1) { + // not a single edge + numEdges = this.readUnsigned(); + nskip = this.readBits(nskiplen); + } + + for (let i = 0; i < numEdges; i++) { + const label = this.readBits(this.cbits); + if (i > 0) { + this.readBits(nskip); // per-edge skip count, unused for traversal + } + const dest = this.readBits(this.abits); + const resume = this.p; + this.p = dest; + const final = this.readBits(1) === 1; + if (!fn(label, dest, final)) { + return; + } + this.p = resume; + } + } + // 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. diff --git a/ui/src/lib/dict/generate.parity.test.ts b/ui/src/lib/dict/generate.parity.test.ts new file mode 100644 index 0000000..2112db0 --- /dev/null +++ b/ui/src/lib/dict/generate.parity.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { Dawg } from './dawg'; +import { generateMoves, GenRack, type GenBoard, type Mode } from './generate'; +import type { Ruleset } from './validate'; + +// Conformance gate for the ported move generator: for each committed position it must +// return exactly the ranked play list the real Go solver returns (backend/cmd/movegen). +// The Russian sample reaches alphabet index 32, exercising the 33-letter cross-set. + +interface Tile { + row: number; + col: number; + letter: number; + blank: boolean; +} +interface GenMove { + dir: number; + tiles: Tile[]; + score: number; +} +interface Fixture { + ruleset: { + size: number; + cols: number; + center: number; + rackSize: number; + bingo: number; + values: number[]; + letterMult: number[][]; + wordMult: number[][]; + }; + cases: { + name: string; + placed: Tile[] | null; + rack: { letters: number[]; blanks: number }; + mode: number; + ignoreCrossWords: boolean; + moves: GenMove[]; + }[]; +} + +function load(tag: string): { dawg: Dawg; fx: Fixture } { + const bytes = new Uint8Array(readFileSync(new URL(`./testdata/sample_${tag}.dawg`, import.meta.url))); + const fx = JSON.parse( + readFileSync(new URL(`./testdata/sample_${tag}.gen.json`, import.meta.url), 'utf8'), + ) as Fixture; + return { dawg: new Dawg(bytes), fx }; +} + +function buildBoard(placed: Tile[], cols: number): GenBoard { + const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null); + for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank }; + const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols; + return { + rows: cols, + cols, + inBounds, + filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null, + cellAt: (r, c) => grid[r * cols + c]!, + isEmpty: () => placed.length === 0, + }; +} + +function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset { + const r = fx.ruleset; + return { + cols: r.cols, + center: r.center, + rackSize: r.rackSize, + bingo: r.bingo, + values: r.values, + letterMult: (row, col) => r.letterMult[row][col], + wordMult: (row, col) => r.wordMult[row][col], + ignoreCrossWords, + }; +} + +// sig is an order-stable signature of a move: orientation, score and its placed tiles +// sorted by square — so two lists compare equal iff they rank the same plays the same way. +function sig(m: GenMove): string { + const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col)); + return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';'); +} + +for (const tag of ['en', 'ru']) { + describe(`move generator parity vs Go solver (${tag})`, () => { + const { dawg, fx } = load(tag); + for (const c of fx.cases) { + it(c.name, () => { + const board = buildBoard(c.placed ?? [], fx.ruleset.cols); + const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks); + const rs = rulesetFor(fx, c.ignoreCrossWords); + const got = generateMoves(dawg, board, rack, rs, c.mode as Mode); + expect(got.map(sig)).toEqual(c.moves.map(sig)); + }); + } + }); +} diff --git a/ui/src/lib/dict/generate.ts b/ui/src/lib/dict/generate.ts new file mode 100644 index 0000000..d7f90bd --- /dev/null +++ b/ui/src/lib/dict/generate.ts @@ -0,0 +1,418 @@ +// Local move generator: every legal play for a rack on a board, ranked by +// descending score. Ported from the scrabble-solver engine — the Appel-Jacobson +// two-phase algorithm (LeftPart then ExtendRight) over a plain left-to-right DAWG +// (scrabble/gen_dawg.go, gen.go, crossset.go, solver.go, key.go). It walks the DAWG +// with the cursor from dawg.ts and scores each play with evaluate() from validate.ts, +// so the whole robot brain runs on-device. Faithfulness to the Go solver is pinned by +// generate.parity.test.ts against golden fixtures (backend/cmd/movegen). +// +// Everything works in alphabet-index space, mirroring the Go engine. A letterSet is +// a per-square cross-set (the letters that form a legal perpendicular word there); +// it is a boolean membership array rather than a uint64, so alphabet indexes past +// JS's 31-bit shift boundary (Russian has 33 letters) are handled exactly. + +import { Dawg } from './dawg'; +import { + evaluate, + connected, + Horizontal, + Vertical, + type Board, + type Ruleset, + type Move, + type Placement, + type Direction, +} from './validate'; + +/** Both generates across plays (on the board) and down plays (on its transpose). */ +export const Both = 0; +/** OnlyHorizontal generates across plays only. */ +export const OnlyHorizontal = 1; +/** OnlyVertical generates down plays only (Эрудит plays a single orientation per turn). */ +export const OnlyVertical = 2; +export type Mode = typeof Both | typeof OnlyHorizontal | typeof OnlyVertical; + +function modeIncludes(mode: Mode, dir: Direction): boolean { + if (mode === Both) return true; + if (mode === OnlyHorizontal) return dir === Horizontal; + return dir === Vertical; +} + +/** + * GenBoard is the board view the generator needs: the validator's read view plus the + * dimensions it iterates over. The whole board is square, so a transposed view (below) + * satisfies the same shape. + */ +export interface GenBoard extends Board { + rows: number; + cols: number; +} + +/** + * GenRack is a rack as per-letter tile counts plus a blank slot, mirroring + * scrabble-solver/rack. The generator mutates a single rack in place — removing a + * tile, recursing, putting it back — so the operations are O(1) and allocation-free. + */ +export class GenRack { + private readonly counts: Int32Array; + + /** Construct an empty rack for an alphabet of the given size (a trailing blank slot). */ + constructor(size: number) { + this.counts = new Int32Array(size + 1); + } + + /** from builds a rack from a multiset of letter indexes plus a blank count. */ + static from(size: number, letters: readonly number[], blanks: number): GenRack { + const r = new GenRack(size); + for (const l of letters) r.counts[l]++; + r.counts[size] += blanks; + return r; + } + + /** has reports whether at least one tile of the letter index is on the rack. */ + has(letter: number): boolean { + return this.counts[letter] > 0; + } + /** blanks returns how many blank tiles are on the rack. */ + blanks(): number { + return this.counts[this.counts.length - 1]; + } + remove(letter: number): void { + this.counts[letter]--; + } + add(letter: number): void { + this.counts[letter]++; + } + removeBlank(): void { + this.counts[this.counts.length - 1]--; + } + addBlank(): void { + this.counts[this.counts.length - 1]++; + } + /** clone returns an independent copy. */ + clone(): GenRack { + const c = new GenRack(this.counts.length - 1); + c.counts.set(this.counts); + return c; + } +} + +// --- cross-sets ------------------------------------------------------------------ +// +// A LetterSet is membership over alphabet letter indexes. Mirrors scrabble.letterSet +// (a uint64) but as a Uint8Array so index 32 (Russian) is exact under JS bit ops. +type LetterSet = Uint8Array; + +function fullSet(size: number): LetterSet { + return new Uint8Array(size).fill(1); +} + +// walk follows word (alphabet indexes) left to right from the root; -1 if it derails. +function walk(dawg: Dawg, word: readonly number[]): number { + let n = dawg.root(); + for (const l of word) { + n = dawg.next(n, l); + if (n < 0) return -1; + } + return n; +} + +// completers returns the letters X (< size) whose arc from state leads directly to an +// accepting node — the deterministic cross-set primitive. Mirrors scrabble.completers. +function completers(dawg: Dawg, state: number, size: number): LetterSet { + const set = new Uint8Array(size); + dawg.arcs(state, (label, _dest, final) => { + if (final && label < size) set[label] = 1; + return true; + }); + return set; +} + +// dawgCrossSet returns the letters X for which above·X·below is a stored word. Mirrors +// scrabble.dawgCrossSet: a right extension (no tiles below) just completes the prefix +// above; a left extension (tiles below) probes each X. above/below are letter indexes. +function dawgCrossSet(dawg: Dawg, above: number[], below: number[], size: number): LetterSet { + if (above.length === 0 && below.length === 0) return fullSet(size); + if (below.length === 0) { + const node = walk(dawg, above); + if (node < 0) return new Uint8Array(size); + return completers(dawg, node, size); + } + let node = dawg.root(); + if (above.length > 0) { + node = walk(dawg, above); + if (node < 0) return new Uint8Array(size); + } + const set = new Uint8Array(size); + for (let x = 0; x < size; x++) { + let m = dawg.next(node, x); + if (m < 0) continue; + let ok = true; + for (const l of below) { + m = dawg.next(m, l); + if (m < 0) { + ok = false; + break; + } + } + if (ok && dawg.final(m)) set[x] = 1; + } + return set; +} + +// columnContext returns the contiguous run of filled cells immediately above and below +// the empty square (r, c), each top to bottom, as letter indexes — the tiles a +// perpendicular word through (r, c) would include. Mirrors scrabble.columnContext. +function columnContext(b: GenBoard, r: number, c: number): { above: number[]; below: number[] } { + const above: number[] = []; + let start = r; + while (start - 1 >= 0 && b.filled(start - 1, c)) start--; + for (let rr = start; rr < r; rr++) above.push(b.cellAt(rr, c).letter); + + const below: number[] = []; + let end = r; + while (end + 1 < b.rows && b.filled(end + 1, c)) end++; + for (let rr = r + 1; rr <= end; rr++) below.push(b.cellAt(rr, c).letter); + return { above, below }; +} + +// transpose returns a view of b with rows and columns swapped, so down-play generation +// runs as across generation. Mirrors board.Transpose (as a lazy view). +function transpose(b: GenBoard): GenBoard { + return { + rows: b.cols, + cols: b.rows, + inBounds: (r, c) => b.inBounds(c, r), + filled: (r, c) => b.filled(c, r), + cellAt: (r, c) => b.cellAt(c, r), + isEmpty: () => b.isEmpty(), + }; +} + +// A tentatively placed left-part tile; its column is fixed only at record time. +interface TileInfo { + letter: number; + blank: boolean; +} + +// AcrossGen carries one across-generation pass over a board. Mirrors scrabble.acrossGen. +class AcrossGen { + private row = 0; + private readonly left: TileInfo[] = []; + private readonly right: Placement[] = []; + + constructor( + private readonly dawg: Dawg, + private readonly bd: GenBoard, + private readonly rk: GenRack, + private readonly cross: (r: number, c: number) => LetterSet, + private readonly emit: (placements: Placement[]) => void, + ) {} + + generateRow(row: number, firstMove: boolean, centerRow: number, centerCol: number): void { + this.row = row; + let limit = 0; + for (let col = 0; col < this.bd.cols; col++) { + if (this.bd.filled(row, col)) { + limit = 0; + continue; + } + const anchor = firstMove ? row === centerRow && col === centerCol : this.hasFilledNeighbor(row, col); + if (!anchor) { + limit++; + continue; + } + this.left.length = 0; + this.right.length = 0; + if (col > 0 && this.bd.filled(row, col - 1)) { + const pre = this.walkPrefix(row, col); + if (pre.ok) this.extendRight(pre.node, col, col); + } else { + this.leftPart(this.dawg.root(), col, limit); + } + limit = 0; + } + } + + private hasFilledNeighbor(r: number, c: number): boolean { + return this.bd.filled(r - 1, c) || this.bd.filled(r + 1, c) || this.bd.filled(r, c - 1) || this.bd.filled(r, c + 1); + } + + // walkPrefix walks the DAWG through the filled run ending at col-1, returning the + // node reached and whether that prefix exists. Mirrors scrabble.acrossGen.walkPrefix. + private walkPrefix(row: number, col: number): { node: number; ok: boolean } { + let start = col - 1; + while (start - 1 >= 0 && this.bd.filled(row, start - 1)) start--; + let node = this.dawg.root(); + for (let c = start; c < col; c++) { + node = this.dawg.next(node, this.bd.cellAt(row, c).letter); + if (node < 0) return { node, ok: false }; + } + return { node, ok: true }; + } + + // leftPart places left-part tiles from the rack (up to limit), calling extendRight + // after each prefix. Mirrors scrabble.acrossGen.leftPart. + private leftPart(node: number, anchorCol: number, limit: number): void { + this.extendRight(node, anchorCol, anchorCol); + if (limit === 0) return; + this.dawg.arcs(node, (label, dest) => { + if (this.rk.has(label)) { + this.rk.remove(label); + this.left.push({ letter: label, blank: false }); + this.leftPart(dest, anchorCol, limit - 1); + this.left.pop(); + this.rk.add(label); + } + if (this.rk.blanks() > 0) { + this.rk.removeBlank(); + this.left.push({ letter: label, blank: true }); + this.leftPart(dest, anchorCol, limit - 1); + this.left.pop(); + this.rk.addBlank(); + } + return true; + }); + } + + // extendRight extends the word rightward from col, placing rack tiles on empty + // squares (constrained by cross-sets) and following board tiles. A word is recorded + // only past the anchor. Mirrors scrabble.acrossGen.extendRight. + private extendRight(node: number, col: number, anchorCol: number): void { + if (col >= this.bd.cols) { + if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol); + return; + } + if (this.bd.filled(this.row, col)) { + const dest = this.dawg.next(node, this.bd.cellAt(this.row, col).letter); + if (dest >= 0) this.extendRight(dest, col + 1, anchorCol); + return; + } + + if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol); + const cross = this.cross(this.row, col); + this.dawg.arcs(node, (label, dest) => { + if (cross[label] !== 1) return true; + if (this.rk.has(label)) { + this.rk.remove(label); + this.right.push({ row: this.row, col, letter: label, blank: false }); + this.extendRight(dest, col + 1, anchorCol); + this.right.pop(); + this.rk.add(label); + } + if (this.rk.blanks() > 0) { + this.rk.removeBlank(); + this.right.push({ row: this.row, col, letter: label, blank: true }); + this.extendRight(dest, col + 1, anchorCol); + this.right.pop(); + this.rk.addBlank(); + } + return true; + }); + } + + // record assembles the play (left part at fixed columns, then the right part) and + // reports it, skipping plays that lay no new tile. Mirrors scrabble.acrossGen.record. + private record(anchorCol: number): void { + if (this.left.length + this.right.length === 0) return; + const placements: Placement[] = []; + const leftStart = anchorCol - this.left.length; + for (let i = 0; i < this.left.length; i++) { + placements.push({ row: this.row, col: leftStart + i, letter: this.left[i].letter, blank: this.left[i].blank }); + } + for (const p of this.right) placements.push(p); + this.emit(placements); + } +} + +// runAcross generates all across plays on bd and reports each via emit in bd's +// coordinates. Cross-sets are computed lazily (vertical words on bd) and cached. +// Mirrors DAWGGenerator.runAcross. +function runAcross( + dawg: Dawg, + bd: GenBoard, + rk: GenRack, + size: number, + rs: Ruleset, + centerRow: number, + centerCol: number, + emit: (placements: Placement[]) => void, +): void { + let crossFn: (r: number, c: number) => LetterSet; + if (rs.ignoreCrossWords) { + const full = fullSet(size); + crossFn = () => full; + } else { + const cache = new Array(bd.rows * bd.cols); + crossFn = (r, c) => { + const i = r * bd.cols + c; + let s = cache[i]; + if (!s) { + const { above, below } = columnContext(bd, r, c); + s = dawgCrossSet(dawg, above, below, size); + cache[i] = s; + } + return s; + }; + } + + const ag = new AcrossGen(dawg, bd, rk, crossFn, emit); + const firstMove = bd.isEmpty(); + for (let row = 0; row < bd.rows; row++) { + ag.generateRow(row, firstMove, centerRow, centerCol); + } +} + +// moveKey is a canonical string identifying a play (direction plus its placed tiles), +// used to de-duplicate and rank generated moves. Mirrors scrabble.moveKey. +export function moveKey(dir: Direction, placements: readonly Placement[]): string { + const ps = placements.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col)); + let s = String(dir); + for (const p of ps) s += `;${p.row},${p.col},${p.letter}${p.blank ? '*' : ''}`; + return s; +} + +/** + * generateMoves returns every legal play for rack on board in the mode's orientations, + * ranked by descending score (ties broken by the canonical move key). It walks the DAWG + * with the cursor and scores each play with evaluate(); the alphabet size is taken from + * the ruleset's value table. Mirrors (*Solver).GenerateMovesOpts (the ruleset carries + * ignoreCrossWords for the single-word rule). + */ +export function generateMoves(dawg: Dawg, board: GenBoard, rack: GenRack, rs: Ruleset, mode: Mode = Both): Move[] { + const size = rs.values.length; + const rk = rack.clone(); // generation mutates the rack in place and restores it + const centerRow = Math.floor(rs.center / rs.cols); + const centerCol = rs.center % rs.cols; + + const moves: Move[] = []; + const seen = new Set(); + const emit = (dir: Direction, placements: Placement[]): void => { + const key = moveKey(dir, placements); + if (seen.has(key)) return; + const res = evaluate(board, rs, dir, placements); + if (res.err || !res.move) return; + seen.add(key); + moves.push(res.move); + }; + + if (modeIncludes(mode, Horizontal)) { + runAcross(dawg, board, rk, size, rs, centerRow, centerCol, (p) => emit(Horizontal, p)); + } + if (modeIncludes(mode, Vertical)) { + const tb = transpose(board); + runAcross(dawg, tb, rk, size, rs, centerCol, centerRow, (p) => { + const rp = p.map((pl) => ({ row: pl.col, col: pl.row, letter: pl.letter, blank: pl.blank })); + emit(Vertical, rp); + }); + } + + const kept = moves.filter((m) => connected(board, rs, m)); + kept.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + const ka = moveKey(a.dir, a.tiles); + const kb = moveKey(b.dir, b.tiles); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); + return kept; +} diff --git a/ui/src/lib/dict/testdata/sample_en.dawg b/ui/src/lib/dict/testdata/sample_en.dawg new file mode 100644 index 0000000000000000000000000000000000000000..2dc5d6ed4f200af33826752c0e187c239673444e GIT binary patch literal 61 zcmV-D0K)$O002D&2?Awq5)2jqbP6F6#x>w}z$7-T=BxlH027Q}cHsb-D3Xm>1cud= T)v{`?fQ**|JA-dtd8$a&EKU|> literal 0 HcmV?d00001 diff --git a/ui/src/lib/dict/testdata/sample_en.gen.json b/ui/src/lib/dict/testdata/sample_en.gen.json new file mode 100644 index 0000000..30199cb --- /dev/null +++ b/ui/src/lib/dict/testdata/sample_en.gen.json @@ -0,0 +1,9802 @@ +{ + "ruleset": { + "size": 26, + "cols": 15, + "center": 112, + "rackSize": 7, + "bingo": 50, + "values": [ + 1, + 3, + 3, + 2, + 1, + 4, + 2, + 4, + 1, + 8, + 5, + 1, + 3, + 1, + 1, + 3, + 10, + 1, + 1, + 1, + 1, + 4, + 4, + 8, + 4, + 10 + ], + "letterMult": [ + [ + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 2 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 3, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 3, + 1 + ], + [ + 1, + 1, + 2, + 1, + 1, + 1, + 2, + 1, + 2, + 1, + 1, + 1, + 2, + 1, + 1 + ], + [ + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1 + ], + [ + 1, + 1, + 2, + 1, + 1, + 1, + 2, + 1, + 2, + 1, + 1, + 1, + 2, + 1, + 1 + ], + [ + 1, + 3, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 3, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 2 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1 + ] + ], + "wordMult": [ + [ + 3, + 1, + 1, + 1, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 1, + 1, + 1, + 3 + ], + [ + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1 + ], + [ + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1 + ], + [ + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 3, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 3 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1 + ], + [ + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1 + ], + [ + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1 + ], + [ + 3, + 1, + 1, + 1, + 1, + 1, + 1, + 3, + 1, + 1, + 1, + 1, + 1, + 1, + 3 + ] + ] + }, + "cases": [ + { + "name": "empty-cared", + "placed": null, + "rack": { + "letters": [ + 2, + 0, + 17, + 4, + 3, + 19, + 18 + ], + "blanks": 0 + }, + "mode": 0, + "ignoreCrossWords": false, + "moves": [ + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 3, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 4, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 22 + }, + { + "dir": 1, + "tiles": [ + { + "row": 3, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 4, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 22 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 3, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 4, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 11, + "letter": 3, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 1, + "tiles": [ + { + "row": 3, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 4, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 11, + "letter": 18, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + } + ], + "score": 2 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + } + ], + "score": 2 + } + ] + }, + { + "name": "empty-dogs", + "placed": null, + "rack": { + "letters": [ + 3, + 14, + 6, + 18, + 4, + 13, + 19 + ], + "blanks": 0 + }, + "mode": 0, + "ignoreCrossWords": false, + "moves": [ + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 6, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 6, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 6, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 6, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 6, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 6, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 6, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 6, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 13, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 13, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 6, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 13, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 6, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 13, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 6, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 13, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 13, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 6, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 13, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 6, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 13, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 6, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 14, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 14, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 14, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 14, + "blank": false + } + ], + "score": 6 + } + ] + }, + { + "name": "empty-blank", + "placed": null, + "rack": { + "letters": [ + 2, + 0, + 17, + 4, + 19 + ], + "blanks": 1 + }, + "mode": 0, + "ignoreCrossWords": false, + "moves": [ + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 3, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 4, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 18 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 3, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 4, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 3, + "blank": true + } + ], + "score": 18 + }, + { + "dir": 1, + "tiles": [ + { + "row": 3, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 4, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 18 + }, + { + "dir": 1, + "tiles": [ + { + "row": 3, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 4, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 3, + "blank": true + } + ], + "score": 18 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 11, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 11, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 7, + "letter": 3, + "blank": true + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": true + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": true + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": true + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": true + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": true + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": true + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": true + } + ], + "score": 8 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 13, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 13, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 13, + "blank": true + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 13, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 13, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 13, + "blank": true + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": true + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 4 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 13, + "blank": true + } + ], + "score": 2 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + } + ], + "score": 2 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 13, + "blank": true + } + ], + "score": 2 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 13, + "blank": true + } + ], + "score": 2 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + } + ], + "score": 2 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 13, + "blank": true + } + ], + "score": 2 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + } + ], + "score": 0 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": true + } + ], + "score": 0 + } + ] + }, + { + "name": "empty-single-word", + "placed": null, + "rack": { + "letters": [ + 2, + 0, + 17, + 4, + 3, + 19, + 18 + ], + "blanks": 0 + }, + "mode": 0, + "ignoreCrossWords": true, + "moves": [ + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 3, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 4, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 22 + }, + { + "dir": 1, + "tiles": [ + { + "row": 3, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 4, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 22 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 3, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 4, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 11, + "letter": 3, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 1, + "tiles": [ + { + "row": 3, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 4, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 20 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 11, + "letter": 18, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 10, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + }, + { + "row": 10, + "col": 7, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 8, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 9, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 17, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 7, + "letter": 19, + "blank": false + } + ], + "score": 10 + }, + { + "dir": 0, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + } + ], + "score": 2 + }, + { + "dir": 1, + "tiles": [ + { + "row": 7, + "col": 7, + "letter": 0, + "blank": false + } + ], + "score": 2 + } + ] + }, + { + "name": "two-ply", + "placed": [ + { + "row": 7, + "col": 3, + "letter": 2, + "blank": false + }, + { + "row": 7, + "col": 4, + "letter": 0, + "blank": false + }, + { + "row": 7, + "col": 5, + "letter": 17, + "blank": false + }, + { + "row": 7, + "col": 6, + "letter": 4, + "blank": false + }, + { + "row": 7, + "col": 7, + "letter": 3, + "blank": false + } + ], + "rack": { + "letters": [ + 2, + 0, + 17, + 4, + 3, + 19, + 18 + ], + "blanks": 0 + }, + "mode": 0, + "ignoreCrossWords": false, + "moves": [ + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 5, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 5, + "letter": 3, + "blank": false + } + ], + "score": 18 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 4, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 4, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 4, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 8, + "col": 3, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 3, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 3, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 3, + "letter": 3, + "blank": false + } + ], + "score": 16 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 5, + "letter": 4, + "blank": false + }, + { + "row": 9, + "col": 5, + "letter": 18, + "blank": false + } + ], + "score": 15 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 4, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 4, + "letter": 4, + "blank": false + }, + { + "row": 10, + "col": 4, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 8, + "col": 3, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 3, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 3, + "letter": 4, + "blank": false + }, + { + "row": 11, + "col": 3, + "letter": 18, + "blank": false + } + ], + "score": 14 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 5, + "letter": 18, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 5, + "letter": 0, + "blank": false + }, + { + "row": 8, + "col": 5, + "letter": 4, + "blank": false + } + ], + "score": 12 + }, + { + "dir": 1, + "tiles": [ + { + "row": 3, + "col": 7, + "letter": 2, + "blank": false + }, + { + "row": 4, + "col": 7, + "letter": 0, + "blank": false + }, + { + "row": 5, + "col": 7, + "letter": 17, + "blank": false + }, + { + "row": 6, + "col": 7, + "letter": 4, + "blank": false + } + ], + "score": 11 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 6, + "letter": 3, + "blank": false + } + ], + "score": 11 + }, + { + "dir": 1, + "tiles": [ + { + "row": 5, + "col": 5, + "letter": 2, + "blank": false + }, + { + "row": 6, + "col": 5, + "letter": 0, + "blank": false + } + ], + "score": 11 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 6, + "letter": 17, + "blank": false + }, + { + "row": 8, + "col": 6, + "letter": 18, + "blank": false + } + ], + "score": 9 + }, + { + "dir": 1, + "tiles": [ + { + "row": 4, + "col": 6, + "letter": 2, + "blank": false + }, + { + "row": 5, + "col": 6, + "letter": 0, + "blank": false + }, + { + "row": 6, + "col": 6, + "letter": 17, + "blank": false + } + ], + "score": 7 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 4, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 4, + "letter": 18, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 4, + "letter": 17, + "blank": false + }, + { + "row": 9, + "col": 4, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 4, + "letter": 19, + "blank": false + }, + { + "row": 9, + "col": 4, + "letter": 18, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 8, + "col": 3, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 3, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 3, + "letter": 18, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 8, + "col": 3, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 3, + "letter": 17, + "blank": false + }, + { + "row": 10, + "col": 3, + "letter": 4, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 8, + "col": 3, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 3, + "letter": 19, + "blank": false + }, + { + "row": 10, + "col": 3, + "letter": 18, + "blank": false + } + ], + "score": 6 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 4, + "letter": 17, + "blank": false + } + ], + "score": 5 + }, + { + "dir": 1, + "tiles": [ + { + "row": 6, + "col": 4, + "letter": 2, + "blank": false + }, + { + "row": 8, + "col": 4, + "letter": 19, + "blank": false + } + ], + "score": 5 + }, + { + "dir": 1, + "tiles": [ + { + "row": 8, + "col": 3, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 3, + "letter": 17, + "blank": false + } + ], + "score": 5 + }, + { + "dir": 1, + "tiles": [ + { + "row": 8, + "col": 3, + "letter": 0, + "blank": false + }, + { + "row": 9, + "col": 3, + "letter": 19, + "blank": false + } + ], + "score": 5 + } + ] + } + ] +} diff --git a/ui/src/lib/dict/testdata/sample_en.words.json b/ui/src/lib/dict/testdata/sample_en.words.json new file mode 100644 index 0000000..f5aa98a --- /dev/null +++ b/ui/src/lib/dict/testdata/sample_en.words.json @@ -0,0 +1,122 @@ +{ + "alphabet": "en", + "numAdded": 18, + "words": [ + "a", + "an", + "and", + "ant", + "car", + "care", + "cared", + "cares", + "cars", + "cat", + "cats", + "do", + "doe", + "does", + "dog", + "dogs", + "done", + "dot" + ], + "indexes": [ + [ + 0 + ], + [ + 0, + 13 + ], + [ + 0, + 13, + 3 + ], + [ + 0, + 13, + 19 + ], + [ + 2, + 0, + 17 + ], + [ + 2, + 0, + 17, + 4 + ], + [ + 2, + 0, + 17, + 4, + 3 + ], + [ + 2, + 0, + 17, + 4, + 18 + ], + [ + 2, + 0, + 17, + 18 + ], + [ + 2, + 0, + 19 + ], + [ + 2, + 0, + 19, + 18 + ], + [ + 3, + 14 + ], + [ + 3, + 14, + 4 + ], + [ + 3, + 14, + 4, + 18 + ], + [ + 3, + 14, + 6 + ], + [ + 3, + 14, + 6, + 18 + ], + [ + 3, + 14, + 13, + 4 + ], + [ + 3, + 14, + 19 + ] + ] +} diff --git a/ui/src/lib/dict/testdata/sample_ru.dawg b/ui/src/lib/dict/testdata/sample_ru.dawg new file mode 100644 index 0000000000000000000000000000000000000000..22627b76b1664bb4ae06dc44b9bc3b1238b0a12d GIT binary patch literal 37 scmZQzU{Gb_WGX6UW8-9~;9|a<#rB0Epyx_h4ns%>&ka_Nvu Date: Mon, 6 Jul 2026 01:54:20 +0200 Subject: [PATCH 02/54] feat(offline): port robot move-choice strategy to TS (parity-pinned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A (2/2) of PWA offline mode: the offline robot picks its move exactly as the server does, so a local vs_ai game plays the same. Builds on the move generator from #188; not wired into a game loop yet (Phase B). - ui/src/lib/robot/strategy.ts: port of backend/internal/robot/strategy.go's move-choice slice — mix (FNV-1a, via BigInt for bit-exact uint64), playToWin (~40% play-to-win), deviates (the fading off-strategy wobble) and selectMove (pick the candidate whose resulting margin lands closest to the +/-[1,30] band, conservative tie-break), composed by decide(). The generator's ranked moves feed straight in. Think-time/sleep/nudge scheduling is server-only and not ported. - backend/internal/robot/strategyfixture_test.go: an in-package, env-gated emitter (EMIT_STRATEGY_FIXTURES=1) writing golden fixtures from the real Go strategy — it reaches the unexported mix/playToWin/deviates/selectMove. - strategy.parity.test.ts: 21 mix + 56 decision cases match Go exactly (play/ exchange/pass, the deviate flip, tie-break, band overshoot). Pure additive library code; no runtime behavior change (unused at runtime, so the bundle is unchanged). --- .../internal/robot/strategyfixture_test.go | 146 +++ ui/src/lib/robot/strategy.parity.test.ts | 68 ++ ui/src/lib/robot/strategy.ts | 180 +++ ui/src/lib/robot/testdata/strategy.json | 1057 +++++++++++++++++ 4 files changed, 1451 insertions(+) create mode 100644 backend/internal/robot/strategyfixture_test.go create mode 100644 ui/src/lib/robot/strategy.parity.test.ts create mode 100644 ui/src/lib/robot/strategy.ts create mode 100644 ui/src/lib/robot/testdata/strategy.json diff --git a/backend/internal/robot/strategyfixture_test.go b/backend/internal/robot/strategyfixture_test.go new file mode 100644 index 0000000..78f1fa6 --- /dev/null +++ b/backend/internal/robot/strategyfixture_test.go @@ -0,0 +1,146 @@ +package robot + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "testing" + + "scrabble/backend/internal/engine" +) + +// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice +// so a local vs_ai game plays the same way as the server. These golden fixtures pin that +// TS port to the real Go strategy. Being in-package, this emitter can exercise the +// unexported mix / playToWin / deviates / selectMove that the port mirrors. + +type mixCase struct { + Seed string `json:"seed"` + Salt string `json:"salt"` + Nums []int `json:"nums"` + Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints) +} + +type decisionCase struct { + Seed string `json:"seed"` + MoveCount int `json:"moveCount"` + MyScore int `json:"myScore"` + OppScore int `json:"oppScore"` + CandScores []int `json:"candScores"` + RackLen int `json:"rackLen"` + BagLen int `json:"bagLen"` + PlayToWin bool `json:"playToWin"` + Win bool `json:"win"` // the per-turn intent after the deviate flip + Kind string `json:"kind"` + Index int `json:"index"` // chosen candidate index for a play, else -1 +} + +type strategyFixture struct { + PlayToWinPercent int `json:"playToWinPercent"` + Mix []mixCase `json:"mix"` + Decisions []decisionCase `json:"decisions"` +} + +// scenario is one selectMove situation exercised across several seeds. +type scenario struct { + moveCount, myScore, oppScore, rackLen, bagLen int + cands []int +} + +// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is +// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is +// what the TS parity test consumes. Regenerate with: +// +// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures +func TestEmitStrategyFixtures(t *testing.T) { + if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" { + t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json") + } + + seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789} + + mixCases := []mixCase{} + addMix := func(seed int64, salt string, nums ...int) { + mixCases = append(mixCases, mixCase{ + Seed: strconv.FormatInt(seed, 10), + Salt: salt, + Nums: append([]int{}, nums...), + Value: strconv.FormatUint(mix(seed, salt, nums...), 10), + }) + } + for _, sd := range seeds { + addMix(sd, "win") + addMix(sd, "deviate", 0) + addMix(sd, "deviate", 7) + } + + scenarios := []scenario{ + {3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game + {10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late + {1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread + {20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate) + {5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break) + {8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange + {8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass + {15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30 + } + + decisions := []decisionCase{} + for _, sd := range seeds { + for _, sc := range scenarios { + cands := make([]engine.MoveRecord, len(sc.cands)) + for i, s := range sc.cands { + cands[i] = engine.MoveRecord{Score: s, Player: i} + } + rack := make([]string, sc.rackLen) + for i := range rack { + rack[i] = "a" + } + + ptw := playToWin(sd) + win := ptw + if deviates(sd, sc.moveCount, sc.bagLen) { + win = !win + } + d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen) + + kind, index := "pass", -1 + switch d.kind { + case decidePlay: + kind, index = "play", d.move.Player + case decideExchange: + kind = "exchange" + } + + decisions = append(decisions, decisionCase{ + Seed: strconv.FormatInt(sd, 10), + MoveCount: sc.moveCount, + MyScore: sc.myScore, + OppScore: sc.oppScore, + CandScores: append([]int{}, sc.cands...), + RackLen: sc.rackLen, + BagLen: sc.bagLen, + PlayToWin: ptw, + Win: win, + Kind: kind, + Index: index, + }) + } + } + + fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions} + dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + data, err := json.MarshalIndent(fx, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + path := filepath.Join(dir, "strategy.json") + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions)) +} diff --git a/ui/src/lib/robot/strategy.parity.test.ts b/ui/src/lib/robot/strategy.parity.test.ts new file mode 100644 index 0000000..aa1effb --- /dev/null +++ b/ui/src/lib/robot/strategy.parity.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { mix, playToWin, deviates, decide, DEFAULT_BAND } from './strategy'; + +// Conformance gate for the ported robot strategy: the client must fold the game seed and +// choose its action exactly as backend/internal/robot/strategy.go does, so a local vs_ai +// game plays the same. Golden fixtures come from the in-package emitter +// (backend/internal/robot/strategyfixture_test.go). + +interface MixCase { + seed: string; + salt: string; + nums: number[]; + value: string; +} +interface DecisionCase { + seed: string; + moveCount: number; + myScore: number; + oppScore: number; + candScores: number[]; + rackLen: number; + bagLen: number; + playToWin: boolean; + win: boolean; + kind: string; + index: number; +} +interface Fixture { + playToWinPercent: number; + mix: MixCase[]; + decisions: DecisionCase[]; +} + +const fx = JSON.parse( + readFileSync(new URL('./testdata/strategy.json', import.meta.url), 'utf8'), +) as Fixture; + +describe('robot strategy parity vs Go', () => { + it('mix folds the seed like Go FNV-1a', () => { + for (const c of fx.mix) { + expect(mix(BigInt(c.seed), c.salt, ...c.nums), `${c.seed}/${c.salt}/${c.nums}`).toBe(BigInt(c.value)); + } + }); + + it('chooses the same action as the Go robot', () => { + for (const c of fx.decisions) { + const seed = BigInt(c.seed); + const cands = c.candScores.map((score, index) => ({ score, index })); + const rack = new Array(c.rackLen).fill('a'); + + expect(playToWin(seed), `playToWin ${c.seed}`).toBe(c.playToWin); + + // the per-turn intent after the occasional off-strategy deviation + let win = playToWin(seed); + if (deviates(seed, c.moveCount, c.bagLen)) win = !win; + expect(win, `win ${c.seed}/${c.moveCount}/${c.bagLen}`).toBe(c.win); + + const dec = decide(seed, c.moveCount, cands, c.myScore, c.oppScore, rack, c.bagLen, DEFAULT_BAND); + expect(dec.kind, `kind ${JSON.stringify(c)}`).toBe(c.kind); + if (dec.kind === 'play') { + expect(dec.move.index, `index ${JSON.stringify(c)}`).toBe(c.index); + } else if (dec.kind === 'exchange') { + expect(dec.exchange.length).toBe(c.rackLen); + } + } + }); +}); diff --git a/ui/src/lib/robot/strategy.ts b/ui/src/lib/robot/strategy.ts new file mode 100644 index 0000000..4c0a724 --- /dev/null +++ b/ui/src/lib/robot/strategy.ts @@ -0,0 +1,180 @@ +// The offline robot's move choice, ported from backend/internal/robot/strategy.go so a +// local vs_ai game plays exactly as the server robot does: it picks the candidate whose +// resulting score margin lands closest to a target band (playing to win ~40% of games and +// to lose the rest), with an occasional off-strategy "wobble" that fades as the bag empties. +// +// Everything derives deterministically from the game's bag seed, folded by FNV-1a (mix), +// so the choice is reproducible. The 64-bit hash is computed with BigInt to stay bit-exact +// with Go's uint64 arithmetic; faithfulness is pinned by strategy.parity.test.ts against +// golden fixtures. Only the move-choice slice of strategy.go is ported — the server's +// think-time, sleep-window and nudge scheduling are irrelevant to on-device play. + +// playToWinPercent is the probability, in percent, that the robot plays to win for a whole +// game; the rest of the time it plays to lose, so the human wins about 60% of games. +const playToWinPercent = 40; + +// deviateMaxProb is the peak probability of a single off-strategy move, held through the +// opening and midgame; it tapers linearly to 0 over the last deviateTaperTiles bag tiles. +const deviateMaxProb = 0.2; +const deviateTaperTiles = 14; + +const FNV_OFFSET = 14695981039346656037n; +const FNV_PRIME = 1099511628211n; +const TWO_POW_64 = 1n << 64n; +const MASK64 = TWO_POW_64 - 1n; + +// u64le returns the 8 little-endian bytes of v reduced mod 2^64, matching Go's +// binary.LittleEndian.PutUint64(uint64(int64(v))) — negatives wrap two's-complement. +function u64le(v: bigint): number[] { + let x = ((v % TWO_POW_64) + TWO_POW_64) % TWO_POW_64; + const out: number[] = []; + for (let i = 0; i < 8; i++) { + out.push(Number(x & 0xffn)); + x >>= 8n; + } + return out; +} + +/** + * mix folds the game seed and a salt (a label plus optional integers such as the move + * index) into a stable 64-bit value, mirroring robot.mix (FNV-1a over the seed's 8 bytes, + * the salt bytes, then each number's 8 bytes). seed is the game's bag seed. + */ +export function mix(seed: bigint, salt: string, ...nums: number[]): bigint { + let h = FNV_OFFSET; + const feed = (b: number): void => { + h = ((h ^ BigInt(b & 0xff)) * FNV_PRIME) & MASK64; + }; + for (const b of u64le(seed)) feed(b); + for (let i = 0; i < salt.length; i++) feed(salt.charCodeAt(i)); + for (const n of nums) for (const b of u64le(BigInt(n))) feed(b); + return h; +} + +/** unitFloat maps a mixed value to a float in [0, 1), mirroring robot.unitFloat. */ +export function unitFloat(v: bigint): number { + return Number(v) / Number(TWO_POW_64); +} + +/** + * playToWin reports the robot's once-per-game decision to play to win, derived from the + * seed so it is fixed for the whole game. Mirrors robot.playToWin. + */ +export function playToWin(seed: bigint): boolean { + return mix(seed, 'win') % 100n < BigInt(playToWinPercent); +} + +// deviateProb is the probability of a single off-strategy move given the tiles left in the +// bag: deviateMaxProb through the midgame, tapering to 0 over the last deviateTaperTiles. +function deviateProb(bagLen: number): number { + if (bagLen <= 0) return 0; + if (bagLen >= deviateTaperTiles) return deviateMaxProb; + return (deviateMaxProb * bagLen) / deviateTaperTiles; +} + +/** + * deviates reports whether the robot plays a single move against its per-game intent at + * moveCount, given the tiles left in the bag — a deterministic per-turn draw, never firing + * once the bag is empty. Mirrors robot.deviates. + */ +export function deviates(seed: bigint, moveCount: number, bagLen: number): boolean { + const p = deviateProb(bagLen); + if (p <= 0) return false; + return unitFloat(mix(seed, 'deviate', moveCount)) < p; +} + +/** MarginBand is an inclusive target range for the resulting score margin (own score after + * the move minus the opponent's). */ +export interface MarginBand { + lo: number; + hi: number; +} + +/** DEFAULT_BAND aims to lead by 1..30 when playing to win (negated when playing to lose), + * matching robot.defaultBand. */ +export const DEFAULT_BAND: MarginBand = { lo: 1, hi: 30 }; + +/** + * Decision is the robot's chosen action: a play (the picked candidate), an exchange of the + * listed tiles, or a pass. Generic over the candidate type (needs only a score) and the + * rack tile type. + */ +export type Decision = + | { kind: 'play'; move: C } + | { kind: 'exchange'; exchange: R[] } + | { kind: 'pass' }; + +// distanceToBand is how far m lies outside [lo, hi], or 0 when inside. +function distanceToBand(m: number, lo: number, hi: number): number { + if (m < lo) return lo - m; + if (m > hi) return m - hi; + return 0; +} + +/** + * selectMove chooses the robot's action from the ranked candidate plays and the current + * scores. With at least one legal play it picks the candidate whose resulting margin + * (myScore + score - oppScore) is closest to the band (negated when not playing to win), + * breaking ties toward the conservative edge (smallest lead when winning, smallest deficit + * when losing). With no legal play it exchanges the whole rack when the bag can refill it, + * else passes. Mirrors robot.selectMove. + */ +export function selectMove( + cands: readonly C[], + myScore: number, + oppScore: number, + win: boolean, + band: MarginBand, + rack: readonly R[], + bagLen: number, +): Decision { + if (cands.length === 0) { + if (rack.length > 0 && bagLen >= rack.length) { + return { kind: 'exchange', exchange: [...rack] }; + } + return { kind: 'pass' }; + } + + let lo = band.lo; + let hi = band.hi; + if (!win) { + lo = -band.hi; + hi = -band.lo; + } + + const margin = (c: C): number => myScore + c.score - oppScore; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < cands.length; i++) { + const m = margin(cands[i]); + const dist = distanceToBand(m, lo, hi); + if (dist < bestDist) { + best = i; + bestDist = dist; + } else if (dist === bestDist) { + // Conservative tie-break: keep the lead (win) or the deficit (lose) small. + if ((win && m < margin(cands[best])) || (!win && m > margin(cands[best]))) best = i; + } + } + return { kind: 'play', move: cands[best] }; +} + +/** + * decide is the robot's whole per-turn choice: the once-per-game play-to-win intent, flipped + * for this move by the occasional deviation, fed to selectMove. Mirrors the decision slice of + * robot (*Service).act. seed is the game's bag seed; cands are the ranked legal plays. + */ +export function decide( + seed: bigint, + moveCount: number, + cands: readonly C[], + myScore: number, + oppScore: number, + rack: readonly R[], + bagLen: number, + band: MarginBand = DEFAULT_BAND, +): Decision { + let win = playToWin(seed); + if (deviates(seed, moveCount, bagLen)) win = !win; + return selectMove(cands, myScore, oppScore, win, band, rack, bagLen); +} diff --git a/ui/src/lib/robot/testdata/strategy.json b/ui/src/lib/robot/testdata/strategy.json new file mode 100644 index 0000000..a6f10ff --- /dev/null +++ b/ui/src/lib/robot/testdata/strategy.json @@ -0,0 +1,1057 @@ +{ + "playToWinPercent": 40, + "mix": [ + { + "seed": "1", + "salt": "win", + "nums": [], + "value": "3463786966066269450" + }, + { + "seed": "1", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "16739214574173863474" + }, + { + "seed": "1", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "4989416721367080085" + }, + { + "seed": "42", + "salt": "win", + "nums": [], + "value": "11996028682749602919" + }, + { + "seed": "42", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "3199075231987854375" + }, + { + "seed": "42", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "6019611456924280128" + }, + { + "seed": "-7", + "salt": "win", + "nums": [], + "value": "5394446344798824475" + }, + { + "seed": "-7", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "13744638055244324387" + }, + { + "seed": "-7", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "15976953462211913796" + }, + { + "seed": "1000003", + "salt": "win", + "nums": [], + "value": "18263589017178353807" + }, + { + "seed": "1000003", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "7838718956381548031" + }, + { + "seed": "1000003", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "10659255181317973784" + }, + { + "seed": "9999999999", + "salt": "win", + "nums": [], + "value": "6610647830354154516" + }, + { + "seed": "9999999999", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "12675031630059091344" + }, + { + "seed": "9999999999", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "9854495405122665591" + }, + { + "seed": "123456789", + "salt": "win", + "nums": [], + "value": "12237171742041588149" + }, + { + "seed": "123456789", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "16735187508536178449" + }, + { + "seed": "123456789", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "9450020469664573878" + }, + { + "seed": "-123456789", + "salt": "win", + "nums": [], + "value": "11055856957027161150" + }, + { + "seed": "-123456789", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "14082038702359824862" + }, + { + "seed": "-123456789", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "2920461667521877817" + } + ], + "decisions": [ + { + "seed": "1", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "1", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": true, + "kind": "play", + "index": 3 + }, + { + "seed": "1", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": false, + "kind": "play", + "index": 0 + }, + { + "seed": "1", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "1", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "1", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "42", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "42", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "42", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": true, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "42", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "play", + "index": 1 + }, + { + "seed": "42", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "42", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "42", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "pass", + "index": -1 + }, + { + "seed": "42", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "-7", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-7", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "-7", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "-7", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-7", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "-7", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "-7", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "-7", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "1000003", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1000003", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "1000003", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": true, + "win": true, + "kind": "play", + "index": 3 + }, + { + "seed": "1000003", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": true, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1000003", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "1000003", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": true, + "kind": "exchange", + "index": -1 + }, + { + "seed": "1000003", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "pass", + "index": -1 + }, + { + "seed": "1000003", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "9999999999", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "9999999999", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "9999999999", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": true, + "win": true, + "kind": "play", + "index": 3 + }, + { + "seed": "9999999999", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "play", + "index": 1 + }, + { + "seed": "9999999999", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "9999999999", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": true, + "kind": "exchange", + "index": -1 + }, + { + "seed": "9999999999", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "pass", + "index": -1 + }, + { + "seed": "9999999999", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "123456789", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "123456789", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "123456789", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "123456789", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "123456789", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": false, + "kind": "play", + "index": 0 + }, + { + "seed": "123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "123456789", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "-123456789", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-123456789", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "-123456789", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "-123456789", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-123456789", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": false, + "kind": "play", + "index": 0 + }, + { + "seed": "-123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "-123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "-123456789", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + } + ] +} -- 2.52.0 From a9c8f1ecfe1c40609ca71c13b75dab1f5e47b806 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 02:09:42 +0200 Subject: [PATCH 03/54] test(offline): real-dictionary move-generator conformance in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A (A4): prove the ported move generator (#188) against the FULL shipped dictionaries, not just the tiny samples — the deep graphs and complete 26/33-letter alphabets the samples cannot reach. - backend/cmd/movegen: add a -dawg-dir mode that emits per-variant golden move-gen vectors from the real dawgs (a bounded first-move + a blank case + a deep 7-tile mid-game position). Regenerated in CI to /tmp, never committed (like the dictgen/validategen vectors), so no dictionary version is pinned into the repo. - ui/src/lib/dict/generate.realparity.test.ts: env-gated (DICT_DAWG_DIR + DICT_MOVEGEN_DIR) parity against that golden — 9 positions across scrabble_en / scrabble_ru / erudit_ru match the Go solver exactly. Skips cleanly when unset. - .gitea/workflows/ci.yaml: the conformance job now generates the movegen golden and points the gated vitest at it (DICT_MOVEGEN_DIR). --- .gitea/workflows/ci.yaml | 2 + backend/cmd/movegen/main.go | 79 ++++++++++++++ ui/src/lib/dict/generate.realparity.test.ts | 113 ++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 ui/src/lib/dict/generate.realparity.test.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e81e94e..5e58272 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -252,6 +252,7 @@ jobs: 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 + go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold" - name: Set up Node uses: actions/setup-node@v4 @@ -271,6 +272,7 @@ jobs: DICT_DAWG_DIR: ${{ github.workspace }}/dawg DICT_GOLD_DIR: /tmp/dictgold DICT_VALID_DIR: /tmp/validgold + DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold run: pnpm exec vitest run src/lib/dict/ # gate is the single branch-protection required check. It always runs and passes diff --git a/backend/cmd/movegen/main.go b/backend/cmd/movegen/main.go index f7003cb..0898722 100644 --- a/backend/cmd/movegen/main.go +++ b/backend/cmd/movegen/main.go @@ -19,6 +19,7 @@ package main import ( + "bytes" "encoding/json" "flag" "log" @@ -108,12 +109,18 @@ type genCase struct { func main() { out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures") + dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples") flag.Parse() if err := os.MkdirAll(*out, 0o755); err != nil { log.Fatalf("movegen: mkdir %s: %v", *out, err) } + if *dawgDir != "" { + buildReal(*dawgDir, *out) + return + } + buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{ emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false), emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false), @@ -172,6 +179,78 @@ func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []gen log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases)) } +// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks +// the conformance positions use. smallRack/blankRack keep the first-move (empty board) +// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position, +// kept small by the anchors around the already-placed word. +type realVariant struct { + file, variant, smallRack, blankRack, fullRack string + rs *rules.Ruleset +} + +// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir — +// the full alphabets and deep graphs the tiny samples cannot reach — one +// .movegen.json per variant. Like the dictgen/validategen vectors it is +// regenerated in CI and never committed, so it pins no dictionary version into the repo. +func buildReal(dawgDir, out string) { + reals := []realVariant{ + {"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()}, + {"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()}, + {"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()}, + } + for _, v := range reals { + data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg")) + if err != nil { + log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err) + } + finder, err := dawg.Read(bytes.NewReader(data), 0) + if err != nil { + log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err) + } + s := scrabble.NewSolver(v.rs, finder) + + cases := []genCase{ + emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false), + emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false), + } + for i := range cases { + runCase(s, v.rs, &cases[i], nil) + } + // A deep 7-tile mid-game: place the top first move, then generate again. The + // anchors around the placed word bound the list while still exercising a full rack, + // deep left/right extension and wide cross-sets over the real graph. + full := encRack(v.rs, v.fullRack, 0) + b := board.New(v.rs.Rows, v.rs.Cols) + if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 { + mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)} + runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles)) + cases = append(cases, mid) + } + + writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases}) + total := 0 + for _, c := range cases { + total += len(c.Moves) + } + _ = finder.Close() + log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total) + } +} + +// encRack encodes a rack given as the variant's letters (plus a blank count) into the +// index-based genRack the fixtures carry. +func encRack(rs *rules.Ruleset, letters string, blanks int) genRack { + enc, err := rs.Alphabet.Encode(letters) + if err != nil { + log.Fatalf("movegen: encode rack %q: %v", letters, err) + } + idx := make([]int, len(enc)) + for i, b := range enc { + idx[i] = int(b) + } + return genRack{Letters: idx, Blanks: blanks} +} + // runCase fills a case's Moves by generating on a board holding the given placed // tiles (nil = empty board). func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) { diff --git a/ui/src/lib/dict/generate.realparity.test.ts b/ui/src/lib/dict/generate.realparity.test.ts new file mode 100644 index 0000000..b460154 --- /dev/null +++ b/ui/src/lib/dict/generate.realparity.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Dawg } from './dawg'; +import { generateMoves, GenRack, type GenBoard, type Mode } from './generate'; +import type { Ruleset } from './validate'; + +// Full-dictionary conformance for the ported move generator: for positions over the real +// shipped dictionaries (deep graphs and full 26/33-letter alphabets the tiny committed +// samples cannot reach) it must return exactly the ranked list the Go solver does. The +// golden vectors come from `go run ./backend/cmd/movegen -dawg-dir -out ` and +// the CI conformance job wires the two directories in; the suite skips when they are unset. +const dawgDir = process.env.DICT_DAWG_DIR; +const goldDir = process.env.DICT_MOVEGEN_DIR; +const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir); + +// variant -> the release dawg file name (matches dawg.parity.test.ts / the movegen tool). +const variants = [ + { variant: 'scrabble_en', dawg: 'en_sowpods' }, + { variant: 'scrabble_ru', dawg: 'ru_scrabble' }, + { variant: 'erudit_ru', dawg: 'ru_erudit' }, +]; + +interface Tile { + row: number; + col: number; + letter: number; + blank: boolean; +} +interface GenMove { + dir: number; + tiles: Tile[]; + score: number; +} +interface Fixture { + ruleset: { + size: number; + cols: number; + center: number; + rackSize: number; + bingo: number; + values: number[]; + letterMult: number[][]; + wordMult: number[][]; + }; + cases: { + name: string; + placed: Tile[] | null; + rack: { letters: number[]; blanks: number }; + mode: number; + ignoreCrossWords: boolean; + moves: GenMove[]; + }[]; +} + +function buildBoard(placed: Tile[], cols: number): GenBoard { + const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null); + for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank }; + const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols; + return { + rows: cols, + cols, + inBounds, + filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null, + cellAt: (r, c) => grid[r * cols + c]!, + isEmpty: () => placed.length === 0, + }; +} + +function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset { + const r = fx.ruleset; + return { + cols: r.cols, + center: r.center, + rackSize: r.rackSize, + bingo: r.bingo, + values: r.values, + letterMult: (row, col) => r.letterMult[row][col], + wordMult: (row, col) => r.wordMult[row][col], + ignoreCrossWords, + }; +} + +function sig(m: GenMove): string { + const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col)); + return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';'); +} + +describe.skipIf(!ready)('move generator parity vs Go on the real dictionaries', () => { + for (const v of variants) { + describe(v.variant, () => { + // Guard the collection-time reads: the block is skipped when the dirs are unset, but + // the describe callback still runs to register tests, so touch the filesystem only + // when ready (otherwise join(undefined, …) would throw during a normal unit run). + if (!ready) return; + const fx = JSON.parse(readFileSync(join(goldDir!, `${v.variant}.movegen.json`), 'utf8')) as Fixture; + const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, `${v.dawg}.dawg`)))); + for (const c of fx.cases) { + it( + c.name, + () => { + const board = buildBoard(c.placed ?? [], fx.ruleset.cols); + const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks); + const rs = rulesetFor(fx, c.ignoreCrossWords); + const got = generateMoves(dawg, board, rack, rs, c.mode as Mode); + expect(got.map(sig)).toEqual(c.moves.map(sig)); + }, + 60_000, + ); + } + }); + } +}); -- 2.52.0 From e4cf143e9fecf50458dec9e0e2bdeb5fd29c66a0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 08:02:05 +0200 Subject: [PATCH 04/54] feat(offline): local game engine (Phase B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline vs_ai game engine — a faithful TS port of backend/internal/engine that drives a whole local game with no backend. Composes with the move generator (#188) and robot strategy (#189) from Phase A; not yet wired into the UI (Phase B2/B3). - ui/src/lib/localgame/ruleset.ts: static per-variant tile values / bag counts / blank count, mirrored from rules.go (offline scoring is self-contained; online uses the server alphabet). Pinned by ruleset.parity.test.ts against a Go fixture. - bag.ts: the tile bag (fill from counts/blanks, draw-from-end, return+reshuffle) on a deterministic in-house PRNG — a game replays from its seed, not bit-identical to a server game (per plan). - board.ts: the mutable board, satisfying the validator/generator read view + set(). - engine.ts: LocalGame — deal / play (reusing validate.ts) / pass / exchange / resign, scoreless(6) & out-of-tiles end detection, end-of-game rack penalties, winner; mirrors game.go. The end-game math is exported as pure functions, pinned against the Go engine (engine.parity.test.ts, 9 constructed positions). - engine.test.ts: a full-loop smoke — two robots play a whole vs_ai game to completion via generateMoves + decide, and it is reproducible from the seed. - backend: movegen now dumps the per-variant rulesets; a new in-package engine emitter (endfixture_test.go, env-gated) produces the end-game golden. Pure additive library code; no runtime behavior change (unused at runtime, bundle unchanged). --- backend/cmd/movegen/main.go | 33 ++ backend/internal/engine/endfixture_test.go | 123 ++++++ ui/src/lib/localgame/bag.test.ts | 50 +++ ui/src/lib/localgame/bag.ts | 76 ++++ ui/src/lib/localgame/board.ts | 44 +++ ui/src/lib/localgame/engine.parity.test.ts | 35 ++ ui/src/lib/localgame/engine.test.ts | 78 ++++ ui/src/lib/localgame/engine.ts | 395 ++++++++++++++++++++ ui/src/lib/localgame/ruleset.parity.test.ts | 37 ++ ui/src/lib/localgame/ruleset.ts | 56 +++ ui/src/lib/localgame/testdata/endgame.json | 246 ++++++++++++ ui/src/lib/localgame/testdata/rulesets.json | 216 +++++++++++ 12 files changed, 1389 insertions(+) create mode 100644 backend/internal/engine/endfixture_test.go create mode 100644 ui/src/lib/localgame/bag.test.ts create mode 100644 ui/src/lib/localgame/bag.ts create mode 100644 ui/src/lib/localgame/board.ts create mode 100644 ui/src/lib/localgame/engine.parity.test.ts create mode 100644 ui/src/lib/localgame/engine.test.ts create mode 100644 ui/src/lib/localgame/engine.ts create mode 100644 ui/src/lib/localgame/ruleset.parity.test.ts create mode 100644 ui/src/lib/localgame/ruleset.ts create mode 100644 ui/src/lib/localgame/testdata/endgame.json create mode 100644 ui/src/lib/localgame/testdata/rulesets.json diff --git a/backend/cmd/movegen/main.go b/backend/cmd/movegen/main.go index 0898722..5e5dfe2 100644 --- a/backend/cmd/movegen/main.go +++ b/backend/cmd/movegen/main.go @@ -121,6 +121,8 @@ func main() { return } + emitRulesets() + buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{ emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false), emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false), @@ -363,6 +365,37 @@ func russianRack(letters string, blanks int) genRack { return genRack{Letters: idx, Blanks: blanks} } +// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks, +// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS +// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules). +func emitRulesets() { + type rsFix struct { + Size int `json:"size"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Blanks int `json:"blanks"` + Values []int `json:"values"` + Counts []int `json:"counts"` + } + out := map[string]rsFix{} + for _, v := range []struct { + name string + rs *rules.Ruleset + }{ + {"scrabble_en", rules.English()}, + {"scrabble_ru", rules.RussianScrabble()}, + {"erudit_ru", rules.Erudit()}, + } { + out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts} + } + dir := filepath.Join("ui", "src", "lib", "localgame", "testdata") + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Fatalf("movegen: mkdir %s: %v", dir, err) + } + writeJSON(filepath.Join(dir, "rulesets.json"), out) + log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json")) +} + func writeJSON(path string, v any) { data, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/backend/internal/engine/endfixture_test.go b/backend/internal/engine/endfixture_test.go new file mode 100644 index 0000000..f3490f4 --- /dev/null +++ b/backend/internal/engine/endfixture_test.go @@ -0,0 +1,123 @@ +package engine + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "gitea.iliadenisov.ru/developer/scrabble-solver/rules" +) + +// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the +// winner rule so a local game finishes with the same scores as the server. These golden fixtures +// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine. +// Being in-package, this emitter constructs Game values directly and drives the unexported +// end-game math on chosen positions. + +type endCaseIn struct { + Name string `json:"name"` + Variant string `json:"variant"` + Reason string `json:"reason"` + Hands [][]int `json:"hands"` + Scores []int `json:"scores"` + Resigned []bool `json:"resigned"` + ToMove int `json:"toMove"` +} + +type endCaseOut struct { + endCaseIn + ScoresAfter []int `json:"scoresAfter"` + Winner int `json:"winner"` +} + +func rulesetFor(variant string) *rules.Ruleset { + switch variant { + case "scrabble_ru": + return rules.RussianScrabble() + case "erudit_ru": + return rules.Erudit() + default: + return rules.English() + } +} + +func reasonFor(s string) EndReason { + switch s { + case "out_of_tiles": + return EndOutOfTiles + case "scoreless": + return EndScoreless + case "resign": + return EndResign + case "aborted": + return EndAborted + } + return EndNotOver +} + +func handsBytes(hands [][]int) [][]byte { + out := make([][]byte, len(hands)) + for i, h := range hands { + b := make([]byte, len(h)) + for j, x := range h { + b[j] = byte(x) + } + out[i] = b + } + return out +} + +// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by +// EMIT_ENGINE_FIXTURES. Regenerate with: +// +// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures +func TestEmitEndgameFixtures(t *testing.T) { + if os.Getenv("EMIT_ENGINE_FIXTURES") == "" { + t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json") + } + + cases := []endCaseIn{ + {"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0}, + {"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0}, + {"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0}, + {"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0}, + {"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0}, + {"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0}, + {"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1}, + {"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0}, + {"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0}, + } + + out := make([]endCaseOut, 0, len(cases)) + for _, c := range cases { + rs := rulesetFor(c.Variant) + scores := append([]int(nil), c.Scores...) + g := &Game{ + rules: rs, + hands: handsBytes(c.Hands), + scores: scores, + resigned: c.Resigned, + toMove: c.ToMove, + } + reason := reasonFor(c.Reason) + g.over = true + g.reason = reason + g.applyEndAdjustment(reason) + out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()}) + } + + dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + path := filepath.Join(dir, "endgame.json") + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + t.Logf("wrote %s (%d cases)", path, len(out)) +} diff --git a/ui/src/lib/localgame/bag.test.ts b/ui/src/lib/localgame/bag.test.ts new file mode 100644 index 0000000..98b4b23 --- /dev/null +++ b/ui/src/lib/localgame/bag.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { Bag } from './bag'; +import { RULESETS } from './ruleset'; +import { BLANK_INDEX } from '../alphabet'; + +describe('offline bag', () => { + it('holds exactly the variant tile distribution', () => { + const rs = RULESETS.scrabble_en; + const total = rs.counts.reduce((a, b) => a + b, 0) + rs.blanks; + const bag = new Bag('scrabble_en', 42); + expect(bag.length).toBe(total); + + const drawn = bag.draw(total); + expect(drawn.length).toBe(total); + expect(bag.length).toBe(0); + + const tally = new Map(); + for (const t of drawn) tally.set(t, (tally.get(t) ?? 0) + 1); + for (let i = 0; i < rs.counts.length; i++) expect(tally.get(i) ?? 0).toBe(rs.counts[i]); + expect(tally.get(BLANK_INDEX) ?? 0).toBe(rs.blanks); + }); + + it('draws beyond the remaining, returning all and emptying', () => { + const bag = new Bag('scrabble_ru', 7); + const total = bag.length; + const all = bag.draw(total + 5); + expect(all.length).toBe(total); + expect(bag.length).toBe(0); + expect(bag.draw(3)).toEqual([]); + }); + + it('returns tiles back to the bag', () => { + const bag = new Bag('erudit_ru', 1); + const before = bag.length; + const seven = bag.draw(7); + expect(bag.length).toBe(before - 7); + bag.return(seven); + expect(bag.length).toBe(before); + }); + + it('is deterministic for a given seed and operation sequence', () => { + const a = new Bag('scrabble_en', 123); + const b = new Bag('scrabble_en', 123); + expect(a.draw(30)).toEqual(b.draw(30)); + // A return reshuffles both identically, so subsequent draws still agree. + a.return([0, 1, 2]); + b.return([0, 1, 2]); + expect(a.draw(10)).toEqual(b.draw(10)); + }); +}); diff --git a/ui/src/lib/localgame/bag.ts b/ui/src/lib/localgame/bag.ts new file mode 100644 index 0000000..8a3f981 --- /dev/null +++ b/ui/src/lib/localgame/bag.ts @@ -0,0 +1,76 @@ +// The offline tile bag — the shuffled draw pile for one local game. Structurally a port of +// backend/internal/engine/bag.go (fill from the variant's counts + blanks, draw from the end, +// return-and-reshuffle for an exchange), but shuffled with a small DETERMINISTIC in-house PRNG +// rather than Go's math/rand: a local game only needs to be reproducible from its own seed and +// sequence of operations (for replay from the stored journal), not bit-identical to a server +// game (docs plan). Blanks ride as BLANK_INDEX, matching lib/alphabet.ts (and the engine's +// blankTile = 0xff = 255). + +import { RULESETS } from './ruleset'; +import { BLANK_INDEX } from '../alphabet'; +import type { Variant } from '../model'; + +// mulberry32 is a compact deterministic PRNG returning a float in [0, 1). Seeded from the game +// seed so the shuffle sequence — and thus the draws — replay identically for the same seed and +// the same sequence of returns. +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Bag is one local game's draw pile. Construct it with the variant and a numeric seed; draw() + * and return() mutate it in place. It is reproducible: the same seed and the same sequence of + * operations yield the same draws. + */ +export class Bag { + private tiles: number[]; + private readonly rand: () => number; + + constructor(variant: Variant, seed: number) { + const rs = RULESETS[variant]; + const tiles: number[] = []; + for (let i = 0; i < rs.counts.length; i++) { + for (let n = 0; n < rs.counts[i]; n++) tiles.push(i); + } + for (let n = 0; n < rs.blanks; n++) tiles.push(BLANK_INDEX); + this.tiles = tiles; + this.rand = mulberry32(seed); + this.shuffle(); + } + + /** length is the number of tiles left in the bag. */ + get length(): number { + return this.tiles.length; + } + + /** + * draw removes up to n tiles from the end of the bag and returns them. Drawing more than + * remain returns all of them; drawing from an empty bag returns an empty array. + */ + draw(n: number): number[] { + const take = Math.min(n, this.tiles.length); + return this.tiles.splice(this.tiles.length - take, take); + } + + /** return puts tiles back into the bag and reshuffles, as when a player exchanges tiles. */ + return(tiles: readonly number[]): void { + for (const t of tiles) this.tiles.push(t); + this.shuffle(); + } + + // shuffle randomises the remaining tiles in place with the bag's own PRNG (Fisher–Yates). + private shuffle(): void { + for (let i = this.tiles.length - 1; i > 0; i--) { + const j = Math.floor(this.rand() * (i + 1)); + const tmp = this.tiles[i]; + this.tiles[i] = this.tiles[j]; + this.tiles[j] = tmp; + } + } +} diff --git a/ui/src/lib/localgame/board.ts b/ui/src/lib/localgame/board.ts new file mode 100644 index 0000000..c3f98a8 --- /dev/null +++ b/ui/src/lib/localgame/board.ts @@ -0,0 +1,44 @@ +// The mutable local-game board: a 15x15 row-major grid of placed tiles (alphabet-index letter +// plus a blank flag). It satisfies the read view the validator and generator need (GenBoard, +// which extends validate.ts's Board) and adds set() so the engine can apply a play. The board is +// alphabet-agnostic — a cell's letter is a variant index, meaningful with the variant's ruleset. + +import { BOARD_SIZE } from '../premiums'; +import type { Cell } from '../dict/validate'; +import type { GenBoard } from '../dict/generate'; + +/** LocalBoard is the engine's in-memory board; it reads as a GenBoard and applies plays via set. */ +export class LocalBoard implements GenBoard { + readonly rows = BOARD_SIZE; + readonly cols = BOARD_SIZE; + private readonly grid: (Cell | null)[]; + private count = 0; + + constructor() { + this.grid = new Array(BOARD_SIZE * BOARD_SIZE).fill(null); + } + + inBounds(row: number, col: number): boolean { + return row >= 0 && row < this.rows && col >= 0 && col < this.cols; + } + + filled(row: number, col: number): boolean { + return this.inBounds(row, col) && this.grid[row * this.cols + col] !== null; + } + + cellAt(row: number, col: number): Cell { + return this.grid[row * this.cols + col]!; + } + + isEmpty(): boolean { + return this.count === 0; + } + + /** set places a tile at (row, col). It assumes the square was empty (a play only lays tiles + * on empty squares), keeping the filled count exact for isEmpty. */ + set(row: number, col: number, letter: number, blank: boolean): void { + const i = row * this.cols + col; + if (this.grid[i] === null) this.count++; + this.grid[i] = { letter, blank }; + } +} diff --git a/ui/src/lib/localgame/engine.parity.test.ts b/ui/src/lib/localgame/engine.parity.test.ts new file mode 100644 index 0000000..830d679 --- /dev/null +++ b/ui/src/lib/localgame/engine.parity.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { applyEndAdjustment, winner, type EndReason } from './engine'; +import { RULESETS } from './ruleset'; +import type { Variant } from '../model'; + +// The offline engine must settle unplayed racks and decide the winner exactly as the Go engine +// does, so a local game finishes with the same scores. Golden from the in-package emitter +// (backend/internal/engine/endfixture_test.go). +interface EndCase { + name: string; + variant: Variant; + reason: EndReason; + hands: number[][]; + scores: number[]; + resigned: boolean[]; + toMove: number; + scoresAfter: number[]; + winner: number; +} + +const fx = JSON.parse( + readFileSync(new URL('./testdata/endgame.json', import.meta.url), 'utf8'), +) as { cases: EndCase[] }; + +describe('offline engine end-game parity vs Go', () => { + for (const c of fx.cases) { + it(c.name, () => { + const values = RULESETS[c.variant].values; + const after = applyEndAdjustment(c.reason, c.hands, c.scores, c.toMove, values); + expect(after).toEqual(c.scoresAfter); + expect(winner(true, c.reason, after, c.resigned)).toBe(c.winner); + }); + } +}); diff --git a/ui/src/lib/localgame/engine.test.ts b/ui/src/lib/localgame/engine.test.ts new file mode 100644 index 0000000..4da8800 --- /dev/null +++ b/ui/src/lib/localgame/engine.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { Dawg } from '../dict/dawg'; +import { RACK_SIZE } from '../premiums'; +import { LocalGame } from './engine'; +import { decide } from '../robot/strategy'; +import type { Move } from '../dict/validate'; + +// A full-loop smoke: two robots play a whole local vs_ai game to completion over the committed +// sample dictionary, exercising deal / play / refill / exchange / pass / end detection / winner. +// (The rich full-dictionary robot behaviour is pinned elsewhere by the generator + strategy parity +// suites; this test proves the engine drives to a valid finish.) +const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); + +function bestOpponentScore(game: LocalGame, seat: number): number { + let best = 0; + for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i)); + return best; +} + +// robotTurn plays the seat to move: it picks the robot's action from the ranked legal plays and +// dispatches it. The sample dict has a one-letter word ("a") the generator emits but the validator +// rejects (len < 2) — real dictionaries have none, so filtering to main length >= 2 is a no-op in +// production; an exchange the bag is too small to satisfy falls back to a pass. +function robotTurn(game: LocalGame, seed: bigint): void { + const seat = game.currentPlayer; + const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2); + const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength); + if (dec.kind === 'play') { + game.play(dec.move.dir, dec.move.tiles); + } else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) { + game.exchange(dec.exchange); + } else { + game.pass(); + } +} + +describe('offline engine full-game smoke', () => { + it('plays a whole 2-player vs_ai game to completion', () => { + const game = new LocalGame({ + variant: 'scrabble_en', + version: 'sample', + seed: 123456789n, + players: 2, + dawg, + multipleWords: true, + }); + + let turns = 0; + while (!game.isOver && turns < 2000) { + robotTurn(game, game.seed); + turns++; + } + + expect(game.isOver).toBe(true); + expect(turns).toBeLessThan(2000); + expect(['out_of_tiles', 'scoreless', 'resign']).toContain(game.endReason); + + const w = game.winnerIndex; + expect(w === -1 || (w >= 0 && w < game.playerCount)).toBe(true); + for (let i = 0; i < game.playerCount; i++) expect(Number.isInteger(game.scoreOf(i))).toBe(true); + // The move log recorded every turn. + expect(game.history.length).toBe(turns); + }); + + it('is reproducible from the seed', () => { + const play = (): { reason: string; scores: number[]; turns: number } => { + const g = new LocalGame({ variant: 'scrabble_en', version: 'sample', seed: 42n, players: 2, dawg, multipleWords: true }); + let t = 0; + while (!g.isOver && t < 2000) { + robotTurn(g, g.seed); + t++; + } + return { reason: g.endReason, scores: [g.scoreOf(0), g.scoreOf(1)], turns: t }; + }; + expect(play()).toEqual(play()); + }); +}); diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts new file mode 100644 index 0000000..10921cf --- /dev/null +++ b/ui/src/lib/localgame/engine.ts @@ -0,0 +1,395 @@ +// The offline game engine — a faithful port of backend/internal/engine/game.go. It owns the +// board, the bag, each seat's hand, the scores, whose turn it is and the move log, applies plays +// (reusing the already-ported validator lib/dict/validate.ts and generator lib/dict/generate.ts), +// and detects the end of a game (out-of-tiles / scoreless / resign) with the standard end-of-game +// rack adjustment. It is pure in-memory logic with no I/O; a local vs_ai game is driven by feeding +// it the robot's choice (lib/robot/strategy.ts) each turn. The bag RNG is our own deterministic +// PRNG (see bag.ts), so a game replays from its seed but is not bit-identical to a server game. +// +// End-of-game scoring, the winner rule and the rack value are exported as pure functions so they +// can be pinned against the Go engine (engine.parity.test.ts) on constructed positions. + +import { LocalBoard } from './board'; +import { Bag } from './bag'; +import { RULESETS } from './ruleset'; +import { generateMoves, GenRack, Both } from '../dict/generate'; +import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate'; +import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; +import { BLANK_INDEX } from '../alphabet'; +import type { Dawg } from '../dict/dawg'; +import type { Variant } from '../model'; + +/** scorelessLimit is the number of consecutive scoreless turns (passes and exchanges) that ends + * a game, mirroring engine.scorelessLimit. */ +export const scorelessLimit = 6; + +/** EndReason explains why a game finished, using the engine's stable labels. */ +export type EndReason = 'not_over' | 'out_of_tiles' | 'scoreless' | 'resign' | 'aborted'; + +/** Disposition of a dropped-out (resigned/timed-out) seat's tiles in a 3-4 player game. */ +export type DropoutTiles = 'remove' | 'return'; + +/** LocalMove is one recorded turn. tiles/dir are set on a play, count on an exchange. */ +export interface LocalMove { + player: number; + action: 'play' | 'pass' | 'exchange' | 'resign'; + dir?: Direction; + tiles?: Placement[]; + count?: number; + score: number; + total: number; +} + +/** GameError carries a stable code for the engine's rejection reasons. */ +export class GameError extends Error { + constructor(public readonly code: string) { + super(code); + this.name = 'GameError'; + } +} + +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; +} + +/** + * buildRuleset assembles the validator/generator ruleset for a variant from the static offline + * tile values (ruleset.ts) and the board geometry (premiums.ts). multipleWords false selects the + * single-word-per-turn rule (perpendicular cross-words ignored). Mirrors the server engine's + * ruleset for the same variant; online scoring instead reads the server-sent alphabet values. + */ +export function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset { + const prem = premiumGrid(variant); + const ctr = centre(variant); + return { + cols: BOARD_SIZE, + center: ctr.row * BOARD_SIZE + ctr.col, + rackSize: RACK_SIZE, + bingo: BINGO[variant], + values: RULESETS[variant].values, + letterMult: (r, c) => letterMultOf(prem[r][c]), + wordMult: (r, c) => wordMultOf(prem[r][c]), + ignoreCrossWords: !multipleWords, + }; +} + +/** rackValue sums the tile values left on a hand; blanks (BLANK_INDEX) count zero. Mirrors + * engine (*Game).rackValue. */ +export function rackValue(hand: readonly number[], values: readonly number[]): number { + let v = 0; + for (const t of hand) if (t !== BLANK_INDEX) v += values[t]; + return v; +} + +/** + * applyEndAdjustment settles the unplayed racks and returns the adjusted scores. Out-of-tiles: the + * player who went out (toMove) gains the sum of every opponent's rack value and each opponent loses + * their own. Scoreless: everyone loses their own rack value. Resign/aborted: no adjustment. + * Mirrors engine (*Game).applyEndAdjustment. + */ +export function applyEndAdjustment( + reason: EndReason, + hands: readonly (readonly number[])[], + scores: readonly number[], + toMove: number, + values: readonly number[], +): number[] { + const out = [...scores]; + if (reason === 'out_of_tiles') { + let bonus = 0; + for (let i = 0; i < hands.length; i++) { + if (i === toMove) continue; + const v = rackValue(hands[i], values); + out[i] -= v; + bonus += v; + } + out[toMove] += bonus; + } else if (reason === 'scoreless') { + for (let i = 0; i < hands.length; i++) out[i] -= rackValue(hands[i], values); + } + return out; +} + +/** + * winner returns the index of the single highest-scoring seat, or -1 on a tie for the lead, an + * unfinished game or an aborted (draw) game. Resigned seats are excluded, so a two-player game + * returns the remaining player even when the resigner led. Mirrors engine (*Game).winner. + */ +export function winner(over: boolean, reason: EndReason, scores: readonly number[], resigned: readonly boolean[]): number { + if (!over || reason === 'aborted') return -1; + let best = -1; + let tie = false; + for (let i = 0; i < scores.length; i++) { + if (resigned[i]) continue; + if (best === -1 || scores[i] > scores[best]) { + best = i; + tie = false; + } else if (scores[i] === scores[best]) { + tie = true; + } + } + return tie ? -1 : best; +} + +/** Options for a new local game. */ +export interface LocalGameOptions { + variant: Variant; + version: string; + /** Seeds the bag; the whole game replays from it. Also the strategy seed the caller uses. */ + seed: bigint; + players: number; + dawg: Dawg; + multipleWords: boolean; + dropoutTiles?: DropoutTiles; +} + +// placementTiles maps placements to the tiles they consume (BLANK_INDEX for a blank). +function placementTiles(tiles: readonly Placement[]): number[] { + return tiles.map((p) => (p.blank ? BLANK_INDEX : p.letter)); +} + +/** + * LocalGame is the in-memory state of one local match and the rules engine over it. Construct it + * with a loaded dictionary; drive it with play/pass/exchange/resign. It performs no I/O. + */ +export class LocalGame { + readonly variant: Variant; + readonly version: string; + readonly seed: bigint; + private readonly vrs: Ruleset; + private readonly values: readonly number[]; + private readonly rackSize: number; + private readonly dawg: Dawg; + private readonly dropoutTiles: DropoutTiles; + + private readonly board: LocalBoard; + private readonly bag: Bag; + private readonly hands: number[][]; + private readonly scores: number[]; + private readonly resigned: boolean[]; + private toMove = 0; + private scorelessRun = 0; + private over = false; + private reason: EndReason = 'not_over'; + private readonly log: LocalMove[] = []; + + constructor(opts: LocalGameOptions) { + if (opts.players < 2 || opts.players > 4) { + throw new GameError('players_out_of_range'); + } + this.variant = opts.variant; + this.version = opts.version; + this.seed = opts.seed; + this.dawg = opts.dawg; + this.dropoutTiles = opts.dropoutTiles ?? 'remove'; + this.vrs = buildRuleset(opts.variant, opts.multipleWords); + this.values = RULESETS[opts.variant].values; + this.rackSize = RULESETS[opts.variant].rackSize; + + this.board = new LocalBoard(); + this.bag = new Bag(opts.variant, Number(BigInt.asUintN(32, opts.seed))); + this.hands = []; + this.scores = []; + this.resigned = []; + for (let i = 0; i < opts.players; i++) { + this.hands.push(this.bag.draw(this.rackSize)); + this.scores.push(0); + this.resigned.push(false); + } + } + + // --- queries --------------------------------------------------------------- + get playerCount(): number { + return this.hands.length; + } + get currentPlayer(): number { + return this.toMove; + } + get isOver(): boolean { + return this.over; + } + get endReason(): EndReason { + return this.reason; + } + get bagLength(): number { + return this.bag.length; + } + get moveCount(): number { + return this.log.length; + } + scoreOf(player: number): number { + return this.scores[player]; + } + /** rackOf returns a copy of a seat's hand (alphabet-index bytes, BLANK_INDEX for blanks). */ + handOf(player: number): number[] { + return [...this.hands[player]]; + } + /** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */ + get winnerIndex(): number { + return winner(this.over, this.reason, this.scores, this.resigned); + } + /** history returns a copy of the move log. */ + get history(): LocalMove[] { + return this.log.map((m) => ({ ...m })); + } + + /** generateMoves returns every legal play for the current player, ranked by descending score. */ + generateMoves(): Move[] { + return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both); + } + + // --- turns ----------------------------------------------------------------- + + /** play validates and applies the current player's placement, scores it, refills the rack and + * advances the turn (or ends the game). Throws GameError on an illegal play. */ + play(dir: Direction, tiles: Placement[]): LocalMove { + if (this.over) throw new GameError('game_over'); + const player = this.toMove; + const used = placementTiles(tiles); + if (!this.holds(player, used)) throw new GameError('tiles_not_on_rack'); + + const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); + if (!res.legal || !res.move) throw new GameError('illegal_play'); + const move = res.move; + + for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank); + this.removeFromHand(player, used); + this.scores[player] += move.score; + this.refill(player); + this.scorelessRun = 0; + + const rec: LocalMove = { + player, + action: 'play', + dir, + tiles: tiles.map((t) => ({ ...t })), + score: move.score, + total: this.scores[player], + }; + this.log.push(rec); + + if (this.hands[player].length === 0 && this.bag.length === 0) this.finish('out_of_tiles'); + else this.advance(); + return rec; + } + + /** pass forfeits the current turn, extending the scoreless run (which may end the game). */ + pass(): LocalMove { + if (this.over) throw new GameError('game_over'); + const player = this.toMove; + this.scorelessRun++; + const rec: LocalMove = { player, action: 'pass', score: 0, total: this.scores[player] }; + this.log.push(rec); + this.endTurnAfterScoreless(); + return rec; + } + + /** exchange swaps the given tiles (alphabet-index bytes, BLANK_INDEX for blanks) for fresh ones. + * Legal only while the bag holds at least a full rack; the fresh tiles are drawn before the + * swapped ones return, so a player cannot draw back their own. Extends the scoreless run. */ + exchange(tiles: number[]): LocalMove { + if (this.over) throw new GameError('game_over'); + if (tiles.length === 0) throw new GameError('nothing_to_exchange'); + if (this.bag.length < this.rackSize) throw new GameError('not_enough_tiles_to_exchange'); + const player = this.toMove; + if (!this.holds(player, tiles)) throw new GameError('tiles_not_on_rack'); + + this.removeFromHand(player, tiles); + const drawn = this.bag.draw(tiles.length); + for (const t of drawn) this.hands[player].push(t); + this.bag.return(tiles); + this.scorelessRun++; + + const rec: LocalMove = { player, action: 'exchange', count: tiles.length, score: 0, total: this.scores[player] }; + this.log.push(rec); + this.endTurnAfterScoreless(); + return rec; + } + + /** resign drops the current player out of the game (they forfeit the win, keep their score). */ + resign(): LocalMove { + return this.resignSeat(this.toMove); + } + + /** resignSeat resigns a specific seat regardless of whose turn it is. */ + resignSeat(seat: number): LocalMove { + if (this.over) throw new GameError('game_over'); + if (seat < 0 || seat >= this.hands.length || this.resigned[seat]) throw new GameError('game_over'); + this.resigned[seat] = true; + this.disposeHand(seat); + const rec: LocalMove = { player: seat, action: 'resign', score: 0, total: this.scores[seat] }; + this.log.push(rec); + if (this.activeCount() <= 1) this.finish('resign'); + else if (seat === this.toMove) this.advance(); + return rec; + } + + // --- internals ------------------------------------------------------------- + + private rackOf(player: number): GenRack { + const letters: number[] = []; + let blanks = 0; + for (const t of this.hands[player]) { + if (t === BLANK_INDEX) blanks++; + else letters.push(t); + } + return GenRack.from(this.values.length, letters, blanks); + } + + private finish(reason: EndReason): void { + this.over = true; + this.reason = reason; + const adjusted = applyEndAdjustment(reason, this.hands, this.scores, this.toMove, this.values); + for (let i = 0; i < adjusted.length; i++) this.scores[i] = adjusted[i]; + } + + private endTurnAfterScoreless(): void { + if (this.scorelessRun >= scorelessLimit) this.finish('scoreless'); + else this.advance(); + } + + private advance(): void { + const n = this.hands.length; + for (let i = 1; i <= n; i++) { + const next = (this.toMove + i) % n; + if (!this.resigned[next]) { + this.toMove = next; + return; + } + } + } + + private activeCount(): number { + return this.resigned.reduce((n, r) => (r ? n : n + 1), 0); + } + + private disposeHand(player: number): void { + if (this.dropoutTiles === 'return') this.bag.return(this.hands[player]); + this.hands[player] = []; + } + + private holds(player: number, want: readonly number[]): boolean { + const avail = new Map(); + for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1); + const need = new Map(); + for (const t of want) need.set(t, (need.get(t) ?? 0) + 1); + for (const [t, n] of need) if ((avail.get(t) ?? 0) < n) return false; + return true; + } + + private removeFromHand(player: number, used: readonly number[]): void { + const hand = this.hands[player]; + for (const t of used) { + const i = hand.indexOf(t); + if (i >= 0) hand.splice(i, 1); + } + } + + private refill(player: number): void { + const need = this.rackSize - this.hands[player].length; + if (need > 0) for (const t of this.bag.draw(need)) this.hands[player].push(t); + } +} diff --git a/ui/src/lib/localgame/ruleset.parity.test.ts b/ui/src/lib/localgame/ruleset.parity.test.ts new file mode 100644 index 0000000..cb603ac --- /dev/null +++ b/ui/src/lib/localgame/ruleset.parity.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { RULESETS } from './ruleset'; +import type { Variant } from '../model'; + +// The offline engine is self-contained: it carries each variant's tile values, bag counts and +// blank count in ruleset.ts (these are not otherwise on the client — online scoring uses the +// server-sent alphabet). This pins that hand-copied table to the Go rulesets +// (scrabble-solver/rules), whose values the movegen tool dumps to testdata/rulesets.json. +interface RulesetFix { + size: number; + rackSize: number; + bingo: number; + blanks: number; + values: number[]; + counts: number[]; +} + +const fx = JSON.parse( + readFileSync(new URL('./testdata/rulesets.json', import.meta.url), 'utf8'), +) as Record; + +describe('offline ruleset table parity vs Go rules', () => { + for (const variant of Object.keys(fx) as Variant[]) { + it(variant, () => { + const rs = RULESETS[variant]; + expect({ + size: rs.size, + rackSize: rs.rackSize, + bingo: rs.bingo, + blanks: rs.blanks, + values: [...rs.values], + counts: [...rs.counts], + }).toEqual(fx[variant]); + }); + } +}); diff --git a/ui/src/lib/localgame/ruleset.ts b/ui/src/lib/localgame/ruleset.ts new file mode 100644 index 0000000..840ddd5 --- /dev/null +++ b/ui/src/lib/localgame/ruleset.ts @@ -0,0 +1,56 @@ +// Static per-variant ruleset data for the offline engine — tile point values, bag tile counts +// and blank count, indexed by alphabet letter index. Mirrored from scrabble-solver/rules/rules.go +// (English / RussianScrabble / Erudit) so a local game is fully self-contained: online scoring +// reads the server-sent alphabet (lib/alphabet.ts), but offline there is no server, so the values +// live here. Board geometry, the centre and RACK_SIZE/BINGO already live in lib/premiums.ts; this +// table adds the values (offline copy), the bag distribution and the blank count. Pinned to the Go +// rulesets by ruleset.parity.test.ts. + +import type { Variant } from '../model'; + +/** VariantRuleset is the offline scoring + bag data for one variant. */ +export interface VariantRuleset { + /** Number of letters in the alphabet (bag distribution and values are indexed 0..size-1). */ + size: number; + /** Tiles drawn to a full rack (7 for every variant). */ + rackSize: number; + /** All-tiles (bingo) bonus. */ + bingo: number; + /** Number of blank tiles in the bag. */ + blanks: number; + /** Tile point value per letter index; a blank scores 0 (handled by the caller). */ + values: readonly number[]; + /** Bag tile count per letter index (how many of each letter the bag holds). */ + counts: readonly number[]; +} + +/** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */ +export const RULESETS: Record = { + scrabble_en: { + size: 26, + rackSize: 7, + bingo: 50, + blanks: 2, + // a b c d e f g h i j k l m n o p q r s t u v w x y z + values: [1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10], + counts: [9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1], + }, + scrabble_ru: { + size: 33, + rackSize: 7, + bingo: 50, + blanks: 2, + // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я + values: [1, 3, 1, 3, 2, 1, 3, 5, 5, 1, 4, 2, 2, 2, 1, 1, 2, 1, 1, 1, 2, 10, 5, 5, 5, 8, 10, 10, 4, 3, 8, 8, 3], + counts: [8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2], + }, + erudit_ru: { + size: 33, + rackSize: 7, + bingo: 15, + blanks: 3, + // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я + values: [1, 3, 2, 3, 2, 1, 0, 5, 5, 1, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 10, 5, 10, 5, 10, 10, 10, 5, 5, 10, 10, 3], + counts: [10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3], + }, +}; diff --git a/ui/src/lib/localgame/testdata/endgame.json b/ui/src/lib/localgame/testdata/endgame.json new file mode 100644 index 0000000..7e9b8d1 --- /dev/null +++ b/ui/src/lib/localgame/testdata/endgame.json @@ -0,0 +1,246 @@ +{ + "cases": [ + { + "name": "out-basic", + "variant": "scrabble_en", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 0, + 1, + 2 + ] + ], + "scores": [ + 50, + 40 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 57, + 33 + ], + "winner": 0 + }, + { + "name": "out-blank", + "variant": "scrabble_en", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 255, + 0 + ] + ], + "scores": [ + 30, + 30 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 31, + 29 + ], + "winner": 0 + }, + { + "name": "out-erudit-yo", + "variant": "erudit_ru", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 6, + 32 + ] + ], + "scores": [ + 10, + 10 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 13, + 7 + ], + "winner": 0 + }, + { + "name": "out-tie", + "variant": "scrabble_en", + "reason": "out_of_tiles", + "hands": [ + [], + [] + ], + "scores": [ + 30, + 30 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 30, + 30 + ], + "winner": -1 + }, + { + "name": "out-3p", + "variant": "scrabble_ru", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 0, + 1 + ], + [ + 2 + ] + ], + "scores": [ + 10, + 10, + 10 + ], + "resigned": [ + false, + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 15, + 6, + 9 + ], + "winner": 0 + }, + { + "name": "scoreless", + "variant": "scrabble_en", + "reason": "scoreless", + "hands": [ + [ + 0, + 1 + ], + [ + 2, + 3 + ] + ], + "scores": [ + 20, + 20 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 16, + 15 + ], + "winner": 0 + }, + { + "name": "resign-2p", + "variant": "scrabble_en", + "reason": "resign", + "hands": [ + [], + [ + 0 + ] + ], + "scores": [ + 100, + 10 + ], + "resigned": [ + true, + false + ], + "toMove": 1, + "scoresAfter": [ + 100, + 10 + ], + "winner": 1 + }, + { + "name": "resign-3p", + "variant": "scrabble_en", + "reason": "resign", + "hands": [ + [], + [], + [] + ], + "scores": [ + 50, + 60, + 40 + ], + "resigned": [ + false, + true, + false + ], + "toMove": 0, + "scoresAfter": [ + 50, + 60, + 40 + ], + "winner": 0 + }, + { + "name": "aborted", + "variant": "scrabble_en", + "reason": "aborted", + "hands": [ + [ + 0 + ], + [ + 1 + ] + ], + "scores": [ + 40, + 30 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 40, + 30 + ], + "winner": -1 + } + ] +} diff --git a/ui/src/lib/localgame/testdata/rulesets.json b/ui/src/lib/localgame/testdata/rulesets.json new file mode 100644 index 0000000..a693d39 --- /dev/null +++ b/ui/src/lib/localgame/testdata/rulesets.json @@ -0,0 +1,216 @@ +{ + "erudit_ru": { + "size": 33, + "rackSize": 7, + "bingo": 15, + "blanks": 3, + "values": [ + 1, + 3, + 2, + 3, + 2, + 1, + 0, + 5, + 5, + 1, + 2, + 2, + 2, + 2, + 1, + 1, + 2, + 2, + 2, + 2, + 3, + 10, + 5, + 10, + 5, + 10, + 10, + 10, + 5, + 5, + 10, + 10, + 3 + ], + "counts": [ + 10, + 3, + 5, + 3, + 5, + 9, + 0, + 2, + 2, + 8, + 4, + 6, + 4, + 5, + 8, + 10, + 6, + 6, + 6, + 5, + 3, + 1, + 2, + 1, + 2, + 1, + 1, + 1, + 2, + 2, + 1, + 1, + 3 + ] + }, + "scrabble_en": { + "size": 26, + "rackSize": 7, + "bingo": 50, + "blanks": 2, + "values": [ + 1, + 3, + 3, + 2, + 1, + 4, + 2, + 4, + 1, + 8, + 5, + 1, + 3, + 1, + 1, + 3, + 10, + 1, + 1, + 1, + 1, + 4, + 4, + 8, + 4, + 10 + ], + "counts": [ + 9, + 2, + 2, + 4, + 12, + 2, + 3, + 2, + 9, + 1, + 1, + 4, + 2, + 6, + 8, + 2, + 1, + 6, + 4, + 6, + 4, + 2, + 2, + 1, + 2, + 1 + ] + }, + "scrabble_ru": { + "size": 33, + "rackSize": 7, + "bingo": 50, + "blanks": 2, + "values": [ + 1, + 3, + 1, + 3, + 2, + 1, + 3, + 5, + 5, + 1, + 4, + 2, + 2, + 2, + 1, + 1, + 2, + 1, + 1, + 1, + 2, + 10, + 5, + 5, + 5, + 8, + 10, + 10, + 4, + 3, + 8, + 8, + 3 + ], + "counts": [ + 8, + 2, + 4, + 2, + 4, + 8, + 1, + 1, + 2, + 5, + 1, + 4, + 4, + 3, + 5, + 10, + 4, + 5, + 5, + 5, + 4, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 1, + 1, + 2 + ] + } +} -- 2.52.0 From 4fa77bf82c39d0ad8172e6b912b7787f7c2fd2da Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 08:22:47 +0200 Subject: [PATCH 05/54] feat(offline): local game persistence + replay (Phase B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store an offline game durably and reconstruct it — the offline counterpart of the server's replay rehydration. Builds on the engine (#191); not wired into the UI yet (Phase B3). - serialize.ts: a LocalGameRecord (seed + rules + seat metadata + the alphabet-index move journal) and replayGame() — reconstruct a live LocalGame by seeding a fresh engine identically and replaying the journal. The board/bag/racks are not stored; they are deterministic from the seed and the replayed operations, so the record stays small. The journal is dictionary-independent (alphabet-index space, stable per variant). - store.ts: an IndexedDB store for local games (save/get/list/delete), mirroring lib/dict/store.ts — its own database, best-effort, guarded when IndexedDB is absent. - engine.ts: record the swapped tiles on an exchange (needed for exact replay) and expose the game's rule config. - serialize.test.ts: a round-trip — reconstruct a mid-game and a finished game by replay and assert the state (board/racks/bag/scores/turn/log) is identical. Pure additive library code; no runtime behavior change (bundle unchanged). --- ui/src/lib/localgame/engine.ts | 13 ++- ui/src/lib/localgame/serialize.test.ts | 86 ++++++++++++++++++++ ui/src/lib/localgame/serialize.ts | 107 +++++++++++++++++++++++++ ui/src/lib/localgame/store.ts | 99 +++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 ui/src/lib/localgame/serialize.test.ts create mode 100644 ui/src/lib/localgame/serialize.ts create mode 100644 ui/src/lib/localgame/store.ts diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts index 10921cf..58fc872 100644 --- a/ui/src/lib/localgame/engine.ts +++ b/ui/src/lib/localgame/engine.ts @@ -35,7 +35,11 @@ export interface LocalMove { action: 'play' | 'pass' | 'exchange' | 'resign'; dir?: Direction; tiles?: Placement[]; + /** ActionExchange only: number of tiles swapped. */ count?: number; + /** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank), + * recorded so the game can be reconstructed by replaying the journal (see localgame/serialize). */ + exchanged?: number[]; score: number; total: number; } @@ -163,6 +167,7 @@ export class LocalGame { private readonly values: readonly number[]; private readonly rackSize: number; private readonly dawg: Dawg; + private readonly multipleWords: boolean; private readonly dropoutTiles: DropoutTiles; private readonly board: LocalBoard; @@ -184,6 +189,7 @@ export class LocalGame { this.version = opts.version; this.seed = opts.seed; this.dawg = opts.dawg; + this.multipleWords = opts.multipleWords; this.dropoutTiles = opts.dropoutTiles ?? 'remove'; this.vrs = buildRuleset(opts.variant, opts.multipleWords); this.values = RULESETS[opts.variant].values; @@ -231,6 +237,11 @@ export class LocalGame { get winnerIndex(): number { return winner(this.over, this.reason, this.scores, this.resigned); } + /** config returns the rule settings the game was created with — the bits, alongside the seed and + * journal, needed to reconstruct it (see localgame/serialize). */ + get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } { + return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles }; + } /** history returns a copy of the move log. */ get history(): LocalMove[] { return this.log.map((m) => ({ ...m })); @@ -303,7 +314,7 @@ export class LocalGame { this.bag.return(tiles); this.scorelessRun++; - const rec: LocalMove = { player, action: 'exchange', count: tiles.length, score: 0, total: this.scores[player] }; + const rec: LocalMove = { player, action: 'exchange', count: tiles.length, exchanged: [...tiles], score: 0, total: this.scores[player] }; this.log.push(rec); this.endTurnAfterScoreless(); return rec; diff --git a/ui/src/lib/localgame/serialize.test.ts b/ui/src/lib/localgame/serialize.test.ts new file mode 100644 index 0000000..24ffbce --- /dev/null +++ b/ui/src/lib/localgame/serialize.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { Dawg } from '../dict/dawg'; +import { RACK_SIZE } from '../premiums'; +import { LocalGame } from './engine'; +import { serializeGame, replayGame, type Seat } from './serialize'; +import { decide } from '../robot/strategy'; +import type { Move } from '../dict/validate'; + +// A game reconstructed from its record (seed + journal) must be identical to the original — same +// board, racks, bag, scores, turn and log — since the bag is deterministic from the seed and the +// replayed sequence of operations. Uses the committed sample dictionary. +const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); +const seats: Seat[] = [ + { kind: 'human', name: 'You' }, + { kind: 'robot', name: 'Robot' }, +]; + +function bestOpponentScore(game: LocalGame, seat: number): number { + let best = 0; + for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i)); + return best; +} + +// drive plays one turn with the robot's choice (legal plays only — see engine.test.ts). +function drive(game: LocalGame, seed: bigint): void { + const seat = game.currentPlayer; + const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2); + const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength); + if (dec.kind === 'play') game.play(dec.move.dir, dec.move.tiles); + else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) game.exchange(dec.exchange); + else game.pass(); +} + +function snapshot(game: LocalGame): unknown { + const scores: number[] = []; + const hands: number[][] = []; + for (let i = 0; i < game.playerCount; i++) { + scores.push(game.scoreOf(i)); + hands.push(game.handOf(i).slice().sort((a, b) => a - b)); + } + return { scores, hands, toMove: game.currentPlayer, bagLen: game.bagLength, over: game.isOver, reason: game.endReason, history: game.history }; +} + +function makeGame(seed: bigint): LocalGame { + return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true }); +} + +const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, robotLastMoveAtUnix: 3 }; + +describe('local game serialize + replay', () => { + it('reconstructs a mid-game position by replay', () => { + const g = makeGame(777n); + for (let i = 0; i < 15 && !g.isOver; i++) drive(g, g.seed); + const rec = serializeGame(g, meta); + const r = replayGame(rec, dawg); + expect(snapshot(r)).toEqual(snapshot(g)); + }); + + it('reconstructs a finished game by replay', () => { + const g = makeGame(2024n); + let t = 0; + while (!g.isOver && t < 2000) { + drive(g, g.seed); + t++; + } + expect(g.isOver).toBe(true); + const rec = serializeGame(g, meta); + expect(rec.status).toBe('finished'); + const r = replayGame(rec, dawg); + expect(snapshot(r)).toEqual(snapshot(g)); + }); + + it('serialises the seed, rules and journal', () => { + const g = makeGame(9999999999n); + for (let i = 0; i < 5 && !g.isOver; i++) drive(g, g.seed); + const rec = serializeGame(g, meta); + expect(rec.seed).toBe('9999999999'); + expect(rec.variant).toBe('scrabble_en'); + expect(rec.multipleWords).toBe(true); + expect(rec.players).toBe(2); + expect(rec.seats).toEqual(seats); + expect(rec.journal.length).toBe(g.moveCount); + expect(replayGame(rec, dawg).seed).toBe(9999999999n); + }); +}); diff --git a/ui/src/lib/localgame/serialize.ts b/ui/src/lib/localgame/serialize.ts new file mode 100644 index 0000000..9471362 --- /dev/null +++ b/ui/src/lib/localgame/serialize.ts @@ -0,0 +1,107 @@ +// Serialising a local game to a durable record and reconstructing it by replay — the offline +// counterpart of the server's game rehydration (backend replay: engine.New(seed) + replayMove per +// journal row). A record carries the seed, the rules, the seat metadata and the move journal; the +// board, bag and racks are NOT stored — they are rebuilt by replaying the journal on a fresh engine +// seeded the same way (the bag is deterministic from the seed and the sequence of operations). +// +// The journal is in alphabet-index space, which is dictionary-independent: a variant's alphabet +// (Latin 26 / embedded Russian 33) is fixed, so the same journal replays against any dictionary +// version of that variant (only the word list changes, not the indices). The dictionary version is +// pinned in the record, and replay applies each recorded move against that dawg. + +import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine'; +import type { Dawg } from '../dict/dawg'; +import type { Variant } from '../model'; + +/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */ +export interface Seat { + kind: 'human' | 'robot'; + name: string; +} + +/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */ +export interface LocalGameRecord { + id: string; + variant: Variant; + dictVersion: string; + /** The bag seed, serialised as a decimal string (it may exceed JS safe integers). */ + seed: string; + players: number; + multipleWords: boolean; + dropoutTiles: DropoutTiles; + seats: Seat[]; + /** The dictionary-independent, alphabet-index-space move journal. */ + journal: LocalMove[]; + status: 'active' | 'finished'; + createdAtUnix: number; + updatedAtUnix: number; + /** Unix seconds of the robot's most recent move — the local hint gate (>30 min) reads this. */ + robotLastMoveAtUnix: number; +} + +/** The record fields the caller owns (the engine supplies the rest). */ +export interface RecordMeta { + id: string; + seats: Seat[]; + createdAtUnix: number; + updatedAtUnix: number; + robotLastMoveAtUnix: number; +} + +/** + * serializeGame builds the durable record for game, taking the caller-owned metadata (id, seats, + * timestamps) and deriving the rest — the seed, rules, journal and status — from the game itself. + */ +export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecord { + const cfg = game.config; + return { + id: meta.id, + variant: game.variant, + dictVersion: game.version, + seed: game.seed.toString(), + players: game.playerCount, + multipleWords: cfg.multipleWords, + dropoutTiles: cfg.dropoutTiles, + seats: meta.seats, + journal: game.history, + status: game.isOver ? 'finished' : 'active', + createdAtUnix: meta.createdAtUnix, + updatedAtUnix: meta.updatedAtUnix, + robotLastMoveAtUnix: meta.robotLastMoveAtUnix, + }; +} + +/** + * replayGame reconstructs the live LocalGame from a record and the loaded dictionary, by seeding a + * fresh engine identically and replaying every journal move in order. The recorded plays stay legal + * against the pinned dictionary, so replay reproduces the exact board, racks, bag and scores. + */ +export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame { + const opts: LocalGameOptions = { + variant: record.variant, + version: record.dictVersion, + seed: BigInt(record.seed), + players: record.players, + dawg, + multipleWords: record.multipleWords, + dropoutTiles: record.dropoutTiles, + }; + const game = new LocalGame(opts); + for (const m of record.journal) { + switch (m.action) { + case 'play': + game.play(m.dir!, m.tiles!); + break; + case 'pass': + game.pass(); + break; + case 'exchange': + game.exchange(m.exchanged!); + break; + case 'resign': + game.resignSeat(m.player); + break; + } + } + return game; +} diff --git a/ui/src/lib/localgame/store.ts b/ui/src/lib/localgame/store.ts new file mode 100644 index 0000000..d319869 --- /dev/null +++ b/ui/src/lib/localgame/store.ts @@ -0,0 +1,99 @@ +// Persistent store for local (offline) games, keyed by game id, in its own IndexedDB database +// (separate from the dict blob cache in lib/dict/store.ts and from the session store). Mirrors that +// module's shape: everything is best-effort — any failure resolves to null / an empty list / a +// no-op, and the caller degrades gracefully (a game that cannot be read is simply absent). +// +// A record is small (seed + rules + seat metadata + the move journal — see localgame/serialize), +// so games are stored whole; the live board/bag/racks are rebuilt by replay on load. + +import type { LocalGameRecord } from './serialize'; + +const DB_NAME = 'scrabble-localgames'; +const STORE = 'games'; + +let dbPromise: Promise | null | undefined; + +function openDb(): Promise | null { + if (dbPromise !== undefined) return dbPromise; + if (typeof indexedDB === 'undefined') { + dbPromise = null; + return null; + } + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'id' }); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }).catch(() => { + dbPromise = null; + throw new Error('indexedDB unavailable'); + }); + return dbPromise; +} + +/** saveLocalGame stores (inserts or replaces) a game record, swallowing any failure (best-effort). */ +export async function saveLocalGame(record: LocalGameRecord): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + /* best-effort: a failed save just loses this snapshot */ + } +} + +/** getLocalGame returns the record for id, or null on a miss or any failure. */ +export async function getLocalGame(id: string): Promise { + const db = openDb(); + if (!db) return null; + try { + const d = await db; + return await new Promise((resolve, reject) => { + const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(id); + r.onsuccess = () => resolve((r.result ?? null) as LocalGameRecord | null); + r.onerror = () => reject(r.error); + }); + } catch { + return null; + } +} + +/** listLocalGames returns every stored game record (unordered), or an empty list on any failure — + * the offline lobby sorts and filters them. */ +export async function listLocalGames(): Promise { + const db = openDb(); + if (!db) return []; + try { + const d = await db; + return await new Promise((resolve) => { + const req = d.transaction(STORE, 'readonly').objectStore(STORE).getAll(); + req.onsuccess = () => resolve((req.result ?? []) as LocalGameRecord[]); + req.onerror = () => resolve([]); + }); + } catch { + return []; + } +} + +/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */ +export async function deleteLocalGame(id: string): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + /* best-effort */ + } +} -- 2.52.0 From 32298595f27f483e8e6764b2051ee85e8d636856 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 09:07:40 +0200 Subject: [PATCH 06/54] feat(offline): local game source (Phase B3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GatewayClient-shaped facade over the offline engine, so the same game screen can drive a local vs_ai game with no backend (the wiring into Game.svelte is B3.2). - source.ts: LocalSource implements the game-loop subset (GameLoopSource) for a local game id — gameState/gameHistory via replay, submitPlay/pass/exchange/resign apply the human move then run the robot (decide(generateMoves)) synchronously, persisting both and delivering the robot's move through a per-game event emitter (no live stream). hint is gated to >30 min since the robot's last move; evaluate/checkWord are local. It translates the UI's glyph space to the engine's index space with the static letters table. - ruleset.ts: add the static per-variant letters (glyphs), pinned to the Go alphabet — offline is now fully self-contained (no reliance on a warm server alphabet cache). - engine.ts: submitPlay (infers the direction like the server SubmitPlay), evaluatePlay + dictionaryHas for the move preview / word check, and record the main-word coordinate + the words on a play (for the history MoveRecord). - source.test.ts: create -> human pass -> synchronous robot reply via the event, the hint gate, decoded history, and a whole game driven to completion. Pure additive library code; no runtime behavior change (bundle unchanged). --- backend/cmd/movegen/main.go | 24 +- ui/src/lib/localgame/engine.ts | 33 ++ ui/src/lib/localgame/ruleset.parity.test.ts | 2 + ui/src/lib/localgame/ruleset.ts | 6 + ui/src/lib/localgame/source.test.ts | 96 +++++ ui/src/lib/localgame/source.ts | 400 ++++++++++++++++++++ ui/src/lib/localgame/testdata/rulesets.json | 98 +++++ 7 files changed, 652 insertions(+), 7 deletions(-) create mode 100644 ui/src/lib/localgame/source.test.ts create mode 100644 ui/src/lib/localgame/source.ts diff --git a/backend/cmd/movegen/main.go b/backend/cmd/movegen/main.go index 5e5dfe2..233e5cd 100644 --- a/backend/cmd/movegen/main.go +++ b/backend/cmd/movegen/main.go @@ -25,6 +25,7 @@ import ( "log" "os" "path/filepath" + "strings" "gitea.iliadenisov.ru/developer/scrabble-solver/board" "gitea.iliadenisov.ru/developer/scrabble-solver/rack" @@ -370,12 +371,13 @@ func russianRack(letters string, blanks int) genRack { // parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules). func emitRulesets() { type rsFix struct { - Size int `json:"size"` - RackSize int `json:"rackSize"` - Bingo int `json:"bingo"` - Blanks int `json:"blanks"` - Values []int `json:"values"` - Counts []int `json:"counts"` + Size int `json:"size"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Blanks int `json:"blanks"` + Values []int `json:"values"` + Counts []int `json:"counts"` + Letters []string `json:"letters"` } out := map[string]rsFix{} for _, v := range []struct { @@ -386,7 +388,15 @@ func emitRulesets() { {"scrabble_ru", rules.RussianScrabble()}, {"erudit_ru", rules.Erudit()}, } { - out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts} + letters := make([]string, v.rs.Size()) + for i := range letters { + ch, err := v.rs.Alphabet.Character(byte(i)) + if err != nil { + log.Fatalf("movegen: %s letter %d: %v", v.name, i, err) + } + letters[i] = strings.ToUpper(ch) + } + out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters} } dir := filepath.Join("ui", "src", "lib", "localgame", "testdata") if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts index 58fc872..a8fbca6 100644 --- a/ui/src/lib/localgame/engine.ts +++ b/ui/src/lib/localgame/engine.ts @@ -14,6 +14,7 @@ import { Bag } from './bag'; import { RULESETS } from './ruleset'; import { generateMoves, GenRack, Both } from '../dict/generate'; import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate'; +import { playDirection } from '../dict/direction'; import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; import { BLANK_INDEX } from '../alphabet'; import type { Dawg } from '../dict/dawg'; @@ -35,6 +36,12 @@ export interface LocalMove { action: 'play' | 'pass' | 'exchange' | 'resign'; dir?: Direction; tiles?: Placement[]; + /** ActionPlay only: the main word's first-letter coordinate. */ + mainRow?: number; + mainCol?: number; + /** ActionPlay only: the words formed as alphabet-index letter arrays — the main word first, then + * the cross words. Decoded to glyphs when building the history's MoveRecord. */ + words?: number[][]; /** ActionExchange only: number of tiles swapped. */ count?: number; /** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank), @@ -252,8 +259,31 @@ export class LocalGame { return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both); } + /** evaluatePlay scores a candidate placement for the current position without committing it, + * returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index + * arrays, main first) and the inferred direction. Backs the local move preview. */ + evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } { + const dir = playDirection(this.board, this.vrs, this.dawg, tiles); + const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); + if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir }; + const m = res.move; + return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir }; + } + + /** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */ + dictionaryHas(word: readonly number[]): boolean { + return this.dawg.indexOf(word) >= 0; + } + // --- turns ----------------------------------------------------------------- + /** submitPlay infers the play's orientation from the placement (like the server's SubmitPlay), + * then plays it — the client submits tiles without a direction and the engine resolves it. */ + submitPlay(tiles: Placement[]): LocalMove { + const dir = playDirection(this.board, this.vrs, this.dawg, tiles); + return this.play(dir, tiles); + } + /** play validates and applies the current player's placement, scores it, refills the rack and * advances the turn (or ends the game). Throws GameError on an illegal play. */ play(dir: Direction, tiles: Placement[]): LocalMove { @@ -277,6 +307,9 @@ export class LocalGame { action: 'play', dir, tiles: tiles.map((t) => ({ ...t })), + mainRow: move.main.row, + mainCol: move.main.col, + words: [move.main.letters, ...move.cross.map((w) => w.letters)], score: move.score, total: this.scores[player], }; diff --git a/ui/src/lib/localgame/ruleset.parity.test.ts b/ui/src/lib/localgame/ruleset.parity.test.ts index cb603ac..8455513 100644 --- a/ui/src/lib/localgame/ruleset.parity.test.ts +++ b/ui/src/lib/localgame/ruleset.parity.test.ts @@ -14,6 +14,7 @@ interface RulesetFix { blanks: number; values: number[]; counts: number[]; + letters: string[]; } const fx = JSON.parse( @@ -31,6 +32,7 @@ describe('offline ruleset table parity vs Go rules', () => { blanks: rs.blanks, values: [...rs.values], counts: [...rs.counts], + letters: [...rs.letters], }).toEqual(fx[variant]); }); } diff --git a/ui/src/lib/localgame/ruleset.ts b/ui/src/lib/localgame/ruleset.ts index 840ddd5..99d218f 100644 --- a/ui/src/lib/localgame/ruleset.ts +++ b/ui/src/lib/localgame/ruleset.ts @@ -22,6 +22,9 @@ export interface VariantRuleset { values: readonly number[]; /** Bag tile count per letter index (how many of each letter the bag holds). */ counts: readonly number[]; + /** Display glyph per letter index, upper-cased (matching lib/alphabet.ts). Offline needs these + * to translate the engine's index space to/from the glyphs the game UI shows, with no server. */ + letters: readonly string[]; } /** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */ @@ -34,6 +37,7 @@ export const RULESETS: Record = { // a b c d e f g h i j k l m n o p q r s t u v w x y z values: [1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10], counts: [9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1], + letters: [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'], }, scrabble_ru: { size: 33, @@ -43,6 +47,7 @@ export const RULESETS: Record = { // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я values: [1, 3, 1, 3, 2, 1, 3, 5, 5, 1, 4, 2, 2, 2, 1, 1, 2, 1, 1, 1, 2, 10, 5, 5, 5, 8, 10, 10, 4, 3, 8, 8, 3], counts: [8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2], + letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'], }, erudit_ru: { size: 33, @@ -52,5 +57,6 @@ export const RULESETS: Record = { // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я values: [1, 3, 2, 3, 2, 1, 0, 5, 5, 1, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 10, 5, 10, 5, 10, 10, 10, 5, 5, 10, 10, 3], counts: [10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3], + letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'], }, }; diff --git a/ui/src/lib/localgame/source.test.ts b/ui/src/lib/localgame/source.test.ts new file mode 100644 index 0000000..c9b4f94 --- /dev/null +++ b/ui/src/lib/localgame/source.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { PushEvent } from '../model'; + +// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node, +// so games live in the source's in-memory cache — created once, then driven without reload). +vi.mock('../dict', () => ({ + getDawg: async () => { + const { Dawg } = await import('../dict/dawg'); + const { readFileSync } = await import('node:fs'); + return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); + }, +})); + +import { LocalSource, isLocalGameId } from './source'; +import type { Seat } from './serialize'; + +const seats: Seat[] = [ + { kind: 'human', name: 'You' }, + { kind: 'robot', name: 'Robot' }, +]; + +async function newSource(id: string, seed: bigint): Promise { + const src = new LocalSource(); + await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed, multipleWords: true, seats }); + return src; +} + +describe('LocalSource', () => { + it('detects local game ids', () => { + expect(isLocalGameId('local:abc')).toBe(true); + expect(isLocalGameId('deadbeef')).toBe(false); + }); + + it('creates a vs_ai game with the human to move and a full rack', async () => { + const src = await newSource('local:g1', 42n); + const st = await src.gameState('local:g1'); + expect(st.game.vsAi).toBe(true); + expect(st.game.status).toBe('active'); + expect(st.seat).toBe(0); + expect(st.rack.length).toBe(7); + expect(st.game.seats.map((s) => s.displayName)).toEqual(['You', 'Robot']); + expect(st.game.toMove).toBe(0); + }); + + it('replies to a human pass with a synchronous robot move, delivered via the event', async () => { + const src = await newSource('local:g2', 999n); + const events: PushEvent[] = []; + src.events('local:g2', (e) => events.push(e)); + + const res = await src.pass('local:g2'); + expect(res.move.action).toBe('pass'); + expect(res.move.player).toBe(0); + + // The robot has already played (synchronously) — the state reflects both moves... + const mid = await src.gameState('local:g2'); + expect(mid.game.moveCount).toBeGreaterThanOrEqual(2); + // ...and its move is delivered to subscribers via the per-game event. + await Promise.resolve(); + expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true); + }); + + it('gates the hint until 30 minutes since the robot last moved', async () => { + const src = await newSource('local:g3', 7n); + await expect(src.hint('local:g3')).rejects.toMatchObject({ code: 'hint_not_ready' }); + }); + + it('records decoded moves in the history', async () => { + const src = await newSource('local:g4', 55n); + await src.pass('local:g4'); + const h = await src.gameHistory('local:g4'); + expect(h.moves.length).toBeGreaterThanOrEqual(2); + expect(h.moves[0].action).toBe('pass'); + // the robot's reply, whatever it is, is decoded (a play carries glyph tiles + lower-case words) + const play = h.moves.find((m) => m.action === 'play'); + if (play) { + expect(play.tiles.every((t) => typeof t.letter === 'string' && t.letter.length >= 1)).toBe(true); + expect(play.words.every((w) => w === w.toLowerCase())).toBe(true); + } + }); + + it('drives a whole local game to completion', async () => { + const src = await newSource('local:g5', 2024n); + src.events('local:g5', () => {}); + let turns = 0; + while ((await src.gameState('local:g5')).game.status === 'active' && turns < 500) { + await src.pass('local:g5'); + await Promise.resolve(); + turns++; + } + const st = await src.gameState('local:g5'); + expect(st.game.status).toBe('finished'); + expect(turns).toBeLessThan(500); + const winners = st.game.seats.filter((s) => s.isWinner); + expect(winners.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts new file mode 100644 index 0000000..721e329 --- /dev/null +++ b/ui/src/lib/localgame/source.ts @@ -0,0 +1,400 @@ +// The local game source: a GatewayClient-shaped facade over the offline engine, so the SAME game +// screen drives a local vs_ai game with no backend. It implements the game-loop subset of the +// gateway (GameLoopSource) for a local game id, backed by LocalGame + the IndexedDB store + the +// real robot (lib/robot/strategy). Unlike the network flow there is no live stream: after the +// human's move the robot plays synchronously in the same call, its move persisted atomically and +// delivered to the screen through this source's own per-game event emitter (events()). +// +// It translates between the UI's glyph space (PlacedTile.letter, rack strings) and the engine's +// alphabet-index space using the static letters table (ruleset.ts), so it needs no server alphabet. + +import { LocalGame, type LocalMove } from './engine'; +import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize'; +import { getLocalGame, saveLocalGame } from './store'; +import { RULESETS } from './ruleset'; +import { getDawg } from '../dict'; +import { decide } from '../robot/strategy'; +import { GatewayError, type PlacedTile } from '../client'; +import type { + EvalResult, + GameView, + History, + HintResult, + MoveRecord, + MoveResult, + PushEvent, + Seat, + StateView, + Tile, + Variant, + WordCheckResult, +} from '../model'; +import type { Move } from '../dict/validate'; + +/** LOCAL_ID_PREFIX marks a game id as a local (offline) game, so the app dispatches it here. */ +export const LOCAL_ID_PREFIX = 'local:'; + +/** isLocalGameId reports whether an id belongs to a local game. */ +export function isLocalGameId(id: string): boolean { + return id.startsWith(LOCAL_ID_PREFIX); +} + +/** HINT_GATE_MS is the idle time since the robot's last move before an offline hint unlocks. */ +export const HINT_GATE_MS = 30 * 60 * 1000; + +/** + * GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real + * gateway satisfies it structurally; the local source implements it for offline play. + */ +export interface GameLoopSource { + gameState(gameId: string, includeAlphabet: boolean): Promise; + gameHistory(gameId: string): Promise; + submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; + pass(gameId: string): Promise; + exchange(gameId: string, tiles: string[], variant: Variant): Promise; + resign(gameId: string): Promise; + hint(gameId: string): Promise; + evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise; + checkWord(gameId: string, word: string, variant: Variant): Promise; + draftGet(gameId: string): Promise; + draftSave(gameId: string, json: string): Promise; +} + +const BLANK_GLYPH = '?'; + +// letterToIndex builds a glyph -> index map for a variant's alphabet (upper-cased, matching the +// static letters table and lib/alphabet.ts). +const indexCache = new Map>(); +function letterToIndex(variant: Variant, glyph: string): number { + if (glyph === BLANK_GLYPH) return 255; // BLANK_INDEX + let m = indexCache.get(variant); + if (!m) { + m = new Map(); + RULESETS[variant].letters.forEach((l, i) => m!.set(l, i)); + indexCache.set(variant, m); + } + const i = m.get(glyph.toUpperCase()); + if (i === undefined) throw new GatewayError('illegal_play', `no index for "${glyph}" in ${variant}`); + return i; +} + +// indexToLetter decodes an alphabet index to its display glyph; the blank sentinel maps to "?". +function indexToLetter(variant: Variant, index: number): string { + if (index === 255) return BLANK_GLYPH; + return RULESETS[variant].letters[index] ?? ''; +} + +// encodePlacements turns submitted glyph tiles into the engine's index-space placements. +function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; col: number; letter: number; blank: boolean }[] { + return tiles.map((t) => ({ row: t.row, col: t.col, letter: letterToIndex(variant, t.letter), blank: t.blank })); +} + +// A live local game held in memory for the session, so calls do not re-replay from the store. +interface Live { + game: LocalGame; + record: LocalGameRecord; +} + +/** + * LocalSource runs local games. One instance backs every local game the app has open; it caches + * the live engine per id and persists after each move. + */ +export class LocalSource implements GameLoopSource { + private readonly live = new Map(); + private readonly listeners = new Map void>>(); + + /** create starts a new local vs_ai game, persists it and returns its state. */ + async create(opts: { + id: string; + variant: Variant; + dictVersion: string; + seed: bigint; + multipleWords: boolean; + seats: LocalSeat[]; + }): Promise { + const dawg = await getDawg(opts.variant, opts.dictVersion); + if (!dawg) throw new GatewayError('dict_unavailable'); + const game = new LocalGame({ + variant: opts.variant, + version: opts.dictVersion, + seed: opts.seed, + players: opts.seats.length, + dawg, + multipleWords: opts.multipleWords, + }); + const now = nowUnix(); + const record = serializeGame(game, { + id: opts.id, + seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name })), + createdAtUnix: now, + updatedAtUnix: now, + robotLastMoveAtUnix: now, + }); + const entry: Live = { game, record }; + this.live.set(opts.id, entry); + await saveLocalGame(record); + return this.stateView(entry); + } + + async gameState(gameId: string): Promise { + return this.stateView(await this.load(gameId)); + } + + async gameHistory(gameId: string): Promise { + const { game, record } = await this.load(gameId); + return { gameId, moves: game.history.map((m) => this.moveRecord(record.variant, m)) }; + } + + async submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise { + return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles))); + } + + async pass(gameId: string): Promise { + return this.humanMove(gameId, (g) => g.pass()); + } + + async exchange(gameId: string, tiles: string[], variant: Variant): Promise { + const idx = tiles.map((t) => letterToIndex(variant, t)); + return this.humanMove(gameId, (g) => g.exchange(idx)); + } + + async resign(gameId: string): Promise { + // A resignation ends a two-player game immediately; no robot reply follows. + const entry = await this.load(gameId); + const seat = entry.game.currentPlayer; + const move = entry.game.resign(); + await this.persist(entry); + return this.moveResult(entry, move, seat); + } + + async hint(gameId: string): Promise { + const entry = await this.load(gameId); + if (nowUnix() * 1000 - entry.record.robotLastMoveAtUnix * 1000 < HINT_GATE_MS) { + throw new GatewayError('hint_not_ready'); + } + const top = this.topMove(entry.game); + if (!top) throw new GatewayError('no_hint'); + return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 }; + } + + async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise { + const { game } = await this.load(gameId); + const r = game.evaluatePlay(encodePlacements(variant, tiles)); + return { + legal: r.legal, + score: r.score, + words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()), + dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '', + }; + } + + async checkWord(gameId: string, word: string, variant: Variant): Promise { + const { game } = await this.load(gameId); + const idx = Array.from(word, (ch) => letterToIndex(variant, ch)); + return { word, legal: game.dictionaryHas(idx) }; + } + + // Drafts (the in-progress composition) are not persisted for local games — the arrangement is + // rebuilt from the rack on reopen. The screen tolerates an empty draft. + async draftGet(): Promise { + return ''; + } + async draftSave(): Promise { + /* no-op offline */ + } + + /** events subscribes to a local game's push events (the robot's reply, game over). Returns an + * unsubscribe. The network path uses the gateway's global stream instead. */ + events(gameId: string, onEvent: (e: PushEvent) => void): () => void { + let set = this.listeners.get(gameId); + if (!set) { + set = new Set(); + this.listeners.set(gameId, set); + } + set.add(onEvent); + return () => set!.delete(onEvent); + } + + // --- internals ------------------------------------------------------------- + + private async load(gameId: string): Promise { + const cached = this.live.get(gameId); + if (cached) return cached; + const record = await getLocalGame(gameId); + if (!record) throw new GatewayError('game_not_found'); + const dawg = await getDawg(record.variant, record.dictVersion); + if (!dawg) throw new GatewayError('dict_unavailable'); + const entry: Live = { game: replayGame(record, dawg), record }; + this.live.set(gameId, entry); + return entry; + } + + // humanMove applies the human's action, persists, returns their MoveResult, then (if the game is + // still on and it is the robot's turn) schedules the robot's synchronous reply — already applied + // and saved here, but emitted to subscribers on a microtask so the human's move renders first. + private async humanMove(gameId: string, act: (g: LocalGame) => LocalMove): Promise { + const entry = await this.load(gameId); + const seat = entry.game.currentPlayer; + const move = act(entry.game); + const robotMoves = this.runRobots(entry); + await this.persist(entry); + const result = this.moveResult(entry, move, seat); + if (robotMoves.length > 0) { + queueMicrotask(() => this.emitRobot(entry, robotMoves)); + } + return result; + } + + // runRobots plays every consecutive robot seat until it is a human's turn or the game ends, + // returning the robot moves in order. For a two-player vs_ai game this is a single move. + private runRobots(entry: Live): LocalMove[] { + const out: LocalMove[] = []; + while (!entry.game.isOver && this.isRobotSeat(entry, entry.game.currentPlayer)) { + const g = entry.game; + const seat = g.currentPlayer; + const cands = this.legalMoves(g); + const dec = decide(g.seed, g.moveCount, cands, g.scoreOf(seat), this.bestOpponent(g, seat), g.handOf(seat), g.bagLength); + if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles)); + else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange)); + else out.push(g.pass()); + entry.record.robotLastMoveAtUnix = nowUnix(); + } + return out; + } + + private emitRobot(entry: Live, moves: LocalMove[]): void { + const set = this.listeners.get(entry.record.id); + if (!set) return; + for (const m of moves) { + const game = this.gameView(entry); + const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength }; + for (const cb of set) cb(ev); + } + if (entry.game.isOver) { + const game = this.gameView(entry); + const ev: PushEvent = { kind: 'game_over', gameId: entry.record.id, result: entry.game.endReason, scoreLine: '', game }; + for (const cb of set) cb(ev); + } + } + + private async persist(entry: Live): Promise { + entry.record = serializeGame(entry.game, { + id: entry.record.id, + seats: entry.record.seats, + createdAtUnix: entry.record.createdAtUnix, + updatedAtUnix: nowUnix(), + robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix, + }); + await saveLocalGame(entry.record); + } + + private isRobotSeat(entry: Live, seat: number): boolean { + return entry.record.seats[seat]?.kind === 'robot'; + } + + private humanSeat(entry: Live): number { + const i = entry.record.seats.findIndex((s) => s.kind === 'human'); + return i >= 0 ? i : 0; + } + + private bestOpponent(game: LocalGame, seat: number): number { + let best = 0; + for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i)); + return best; + } + + private legalMoves(game: LocalGame): Move[] { + // The generator can emit a one-letter play only for a dictionary that has one-letter words; + // real dictionaries have none. Filtering to main length >= 2 keeps the robot to legal plays. + return game.generateMoves().filter((m) => m.main.letters.length >= 2); + } + + private topMove(game: LocalGame): Move | null { + const moves = this.legalMoves(game); + return moves.length > 0 ? moves[0] : null; + } + + // --- shape builders -------------------------------------------------------- + + private stateView(entry: Live): StateView { + const seat = this.humanSeat(entry); + const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); + return { game: this.gameView(entry), seat, rack, bagLen: entry.game.bagLength, hintsRemaining: 1, walletBalance: 0 }; + } + + private gameView(entry: Live): GameView { + const { game, record } = entry; + const seats: Seat[] = record.seats.map((s, i) => ({ + seat: i, + accountId: `${LOCAL_ID_PREFIX}${s.kind}:${i}`, + displayName: s.name, + score: game.scoreOf(i), + hintsUsed: 0, + isWinner: game.isOver && i === game.winnerIndex, + })); + return { + id: record.id, + variant: record.variant, + dictVersion: record.dictVersion, + status: game.isOver ? 'finished' : 'active', + players: game.playerCount, + toMove: game.currentPlayer, + turnTimeoutSecs: 0, + multipleWordsPerTurn: record.multipleWords, + moveCount: game.moveCount, + endReason: game.isOver ? game.endReason : '', + lastActivityUnix: record.updatedAtUnix, + vsAi: true, + unreadChat: false, + unreadMessages: false, + seats, + }; + } + + private moveResult(entry: Live, move: LocalMove, seat: number): MoveResult { + return { + move: this.moveRecord(entry.record.variant, move), + game: this.gameView(entry), + rack: entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)), + bagLen: entry.game.bagLength, + }; + } + + private moveRecord(variant: Variant, m: LocalMove): MoveRecord { + const tiles: Tile[] = (m.tiles ?? []).map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank })); + const words = (m.words ?? []).map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()); + return { + player: m.player, + action: m.action, + dir: m.dir === undefined ? '' : m.dir === 0 ? 'H' : 'V', + mainRow: m.mainRow ?? 0, + mainCol: m.mainCol ?? 0, + tiles, + words, + count: m.count ?? 0, + score: m.score, + total: m.total, + }; + } + + private moveFromGen(variant: Variant, player: number, gm: Move): MoveRecord { + const tiles: Tile[] = gm.tiles.map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank })); + const words = [gm.main, ...gm.cross].map((w) => w.letters.map((i) => indexToLetter(variant, i)).join('').toLowerCase()); + return { + player, + action: 'play', + dir: gm.dir === 0 ? 'H' : 'V', + mainRow: gm.main.row, + mainCol: gm.main.col, + tiles, + words, + count: 0, + score: gm.score, + total: 0, + }; + } +} + +function nowUnix(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/ui/src/lib/localgame/testdata/rulesets.json b/ui/src/lib/localgame/testdata/rulesets.json index a693d39..f9b4168 100644 --- a/ui/src/lib/localgame/testdata/rulesets.json +++ b/ui/src/lib/localgame/testdata/rulesets.json @@ -73,6 +73,41 @@ 1, 1, 3 + ], + "letters": [ + "А", + "Б", + "В", + "Г", + "Д", + "Е", + "Ё", + "Ж", + "З", + "И", + "Й", + "К", + "Л", + "М", + "Н", + "О", + "П", + "Р", + "С", + "Т", + "У", + "Ф", + "Х", + "Ц", + "Ч", + "Ш", + "Щ", + "Ъ", + "Ы", + "Ь", + "Э", + "Ю", + "Я" ] }, "scrabble_en": { @@ -135,6 +170,34 @@ 1, 2, 1 + ], + "letters": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z" ] }, "scrabble_ru": { @@ -211,6 +274,41 @@ 1, 1, 2 + ], + "letters": [ + "А", + "Б", + "В", + "Г", + "Д", + "Е", + "Ё", + "Ж", + "З", + "И", + "Й", + "К", + "Л", + "М", + "Н", + "О", + "П", + "Р", + "С", + "Т", + "У", + "Ф", + "Х", + "Ц", + "Ч", + "Ш", + "Щ", + "Ъ", + "Ы", + "Ь", + "Э", + "Ю", + "Я" ] } } -- 2.52.0 From 1654131904415ca118304b1f7ea75c66ae0ab7fe Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 09:24:55 +0200 Subject: [PATCH 07/54] feat(offline): wire the local game source into the game screen (Phase B3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game screen now drives a local vs_ai game through the offline engine, dispatched by game id — completing the playable local game (on top of the source, #193). Online play is unchanged. - gamesource.ts: gameSource(id) returns the local source for a `local:` id, else the gateway (the same game-loop interface). The offline engine stays OUT of the app entry bundle — it is dynamically imported on first use (a separate chunk), so online-only users never pay for it (the app entry stays within its size budget). - localgame/id.ts: the tiny id helper (no engine imports) the dispatcher branches on. - Game.svelte: the game-loop calls (state/history/submit/pass/exchange/resign/hint/ evaluate/draft) go through gameSource(id) instead of the gateway directly; a local game's robot-reply events route through the same app event hub the network stream feeds, so the screen reacts to opponent_moved / game_over identically. Behaviour-preserving for network games (gameSource returns the gateway for them). Local verify green: check + test:unit + build + bundle-size gate + e2e (196 passed). --- ui/src/game/Game.svelte | 37 +++++++++++++------ ui/src/lib/gamesource.ts | 65 ++++++++++++++++++++++++++++++++++ ui/src/lib/localgame/id.ts | 12 +++++++ ui/src/lib/localgame/source.ts | 9 ++--- 4 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 ui/src/lib/gamesource.ts create mode 100644 ui/src/lib/localgame/id.ts diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2268457..735c3ff 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -8,6 +8,7 @@ import Board from './Board.svelte'; import Rack from './Rack.svelte'; import { gateway } from '../lib/gateway'; + import { gameSource, localSource, isLocalGameId } from '../lib/gamesource'; import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics'; import { navigate } from '../lib/router.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; @@ -47,6 +48,13 @@ let { id }: { id: string } = $props(); + // The game's source: the local engine for an offline game id, otherwise the network gateway. The + // screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/ + // draft) through it, so the same UI drives a local vs_ai game and an online one alike. + const source = $derived(gameSource(id)); + // Unsubscribes from a local game's robot-reply events (offline only; null for a network game). + let localUnsub: (() => void) | null = null; + let view = $state(null); let moves = $state([]); let placement = $state(newPlacement([])); @@ -190,9 +198,9 @@ // Fetch the saved draft alongside state and history (best-effort) so the composition is // applied in the same tick the board appears — never as a second, visible rack→board step. const [st, hist, draft] = await Promise.all([ - gateway.gameState(id, includeAlphabet), - gateway.gameHistory(id), - gateway.draftGet(id).catch(() => ''), + source.gameState(id, includeAlphabet), + source.gameHistory(id), + source.draftGet(id).catch(() => ''), ]); view = st; syncWallet(st.walletBalance); @@ -220,7 +228,7 @@ setCachedDraft(id, json); if (draftSaveTimer) clearTimeout(draftSaveTimer); draftSaveTimer = setTimeout(() => { - void gateway.draftSave(id, json).catch(() => {}); + void source.draftSave(id, json).catch(() => {}); }, 500); } // applyDraft restores the player's saved composition over a freshly loaded state: the rack @@ -264,6 +272,12 @@ dict = m; }); } + // A local (offline) game has no live stream: route the source's robot-reply events through the + // same app event hub the network stream feeds, so the event effect above reacts to + // opponent_moved / game_over identically. + if (isLocalGameId(id)) { + localUnsub = localSource.events(id, (e) => (app.lastEvent = e)); + } }); // Warm the game's dictionary for the local move preview once, when both the game and the @@ -611,8 +625,9 @@ // Flush a pending draft save so leaving mid-composition still persists it. if (draftSaveTimer) { clearTimeout(draftSaveTimer); - void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); + void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); } + localUnsub?.(); }); function onCell(row: number, col: number) { @@ -740,7 +755,7 @@ evalCtrl = ctrl; previewTimer = setTimeout(async () => { try { - preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal); + preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal); notePreviewNetwork(); } catch { /* best-effort (or aborted) */ @@ -781,7 +796,7 @@ if (!sub) return; busy = true; try { - applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); + applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); haptic('success'); zoomed = false; } catch (e) { @@ -800,7 +815,7 @@ async function doPass() { busy = true; try { - applyMoveResult(await gateway.pass(id)); + applyMoveResult(await source.pass(id)); } catch (e) { handleError(e); } finally { @@ -821,7 +836,7 @@ resignOpen = false; busy = true; try { - applyMoveResult(await gateway.resign(id)); + applyMoveResult(await source.resign(id)); // Reveal the final board once the game is resigned: close the move-history drawer // (portrait — the landscape dock is unaffected) and zoom out if it was magnified. historyOpen = false; @@ -834,7 +849,7 @@ } async function doHint() { try { - const h = await gateway.hint(id); + const h = await source.hint(id); if (h.move.tiles.length && view) { placement = placementFromHint(h.move.tiles, view.rack); // Scroll the (zoomed) board to the hint's placement rather than the top-left: @@ -906,7 +921,7 @@ exchangeOpen = false; busy = true; try { - applyMoveResult(await gateway.exchange(id, tiles, variant)); + applyMoveResult(await source.exchange(id, tiles, variant)); } catch (e) { handleError(e); } finally { diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts new file mode 100644 index 0000000..a8ecd1a --- /dev/null +++ b/ui/src/lib/gamesource.ts @@ -0,0 +1,65 @@ +// Dispatch a game id to its source: the local engine (an offline vs_ai game) or the network +// gateway. The same game screen (game/Game.svelte) drives both through the shared game-loop subset +// (GameLoopSource); a game id decides which. A network game's calls are unchanged — gameSource +// returns the gateway itself for it — so the seam adds the local path without touching online play. +// +// The offline engine is kept OUT of the app entry bundle: this module statically imports only the +// gateway, the tiny id helper and types; the LocalSource is dynamically imported on first use of a +// local game, so it loads as a separate chunk only when someone plays offline. + +import { gateway } from './gateway'; +import { isLocalGameId } from './localgame/id'; +import type { GameLoopSource, LocalSource } from './localgame/source'; + +let loaded: Promise | null = null; +function load(): Promise { + if (!loaded) loaded = import('./localgame/source').then((m) => new m.LocalSource()); + return loaded; +} + +/** + * localSource is a lazy handle on the single offline LocalSource. It is obtainable synchronously, + * but each method dynamically imports the engine on first use, so the offline code stays out of the + * app entry bundle until a local game is actually played. It exposes the game-loop methods plus the + * local-only create() and the robot-reply event subscription events(). + */ +export const localSource = { + // Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate + // abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full + // gateway signatures but forwards only what the local source uses. + gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)), + gameHistory: (id) => load().then((s) => s.gameHistory(id)), + submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)), + pass: (id) => load().then((s) => s.pass(id)), + exchange: (id, tiles, variant) => load().then((s) => s.exchange(id, tiles, variant)), + resign: (id) => load().then((s) => s.resign(id)), + hint: (id) => load().then((s) => s.hint(id)), + evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)), + checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)), + draftGet: (_id) => load().then((s) => s.draftGet()), + draftSave: (_id, _json) => load().then((s) => s.draftSave()), + create: (opts) => load().then((s) => s.create(opts)), + // events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires + // the real subscription once the engine loads and, until then, cancels a pending subscribe. + events: (id, onEvent) => { + let unsub = (): void => {}; + let cancelled = false; + void load().then((s) => { + if (!cancelled) unsub = s.events(id, onEvent); + }); + return () => { + cancelled = true; + unsub(); + }; + }, +} satisfies GameLoopSource & Pick; + +/** + * gameSource returns the source that runs the game with the given id: the local engine for a local + * id, otherwise the network gateway (which satisfies the same game-loop interface). + */ +export function gameSource(id: string): GameLoopSource { + return isLocalGameId(id) ? localSource : gateway; +} + +export { isLocalGameId } from './localgame/id'; diff --git a/ui/src/lib/localgame/id.ts b/ui/src/lib/localgame/id.ts new file mode 100644 index 0000000..cbeda4b --- /dev/null +++ b/ui/src/lib/localgame/id.ts @@ -0,0 +1,12 @@ +// Local game id helpers, in a tiny standalone module with no engine/generator imports, so the +// dispatcher (lib/gamesource) can recognise a local game id — and the game screen can branch on it +// — without eagerly bundling the whole offline engine. The engine is dynamically imported only when +// a local game is actually played (keeping the app entry bundle within its size budget). + +/** LOCAL_ID_PREFIX marks a game id as a local (offline) game. */ +export const LOCAL_ID_PREFIX = 'local:'; + +/** isLocalGameId reports whether an id belongs to a local game. */ +export function isLocalGameId(id: string): boolean { + return id.startsWith(LOCAL_ID_PREFIX); +} diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 721e329..213b12f 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -13,6 +13,7 @@ import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat import { getLocalGame, saveLocalGame } from './store'; import { RULESETS } from './ruleset'; import { getDawg } from '../dict'; +import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { decide } from '../robot/strategy'; import { GatewayError, type PlacedTile } from '../client'; import type { @@ -31,13 +32,7 @@ import type { } from '../model'; import type { Move } from '../dict/validate'; -/** LOCAL_ID_PREFIX marks a game id as a local (offline) game, so the app dispatches it here. */ -export const LOCAL_ID_PREFIX = 'local:'; - -/** isLocalGameId reports whether an id belongs to a local game. */ -export function isLocalGameId(id: string): boolean { - return id.startsWith(LOCAL_ID_PREFIX); -} +export { LOCAL_ID_PREFIX, isLocalGameId }; /** HINT_GATE_MS is the idle time since the robot's last move before an offline hint unlocks. */ export const HINT_GATE_MS = 30 * 60 * 1000; -- 2.52.0 From 68ecd881d9da462010d6a8de02eed7753fb80ff3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 09:44:05 +0200 Subject: [PATCH 08/54] feat(offline): offline-mode state, Settings toggle + blue chrome (Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deliberate offline mode is now visible: an installed web-PWA user with a confirmed email can switch to offline in Settings, and the header turns blue with an "Offline" chip. (Network gating, the dict preload, the offline lobby + local-game creation, and the service-worker precache follow in later Phase C steps.) - offline.ts / offline.svelte.ts: a sticky, device-scoped reactive offline flag (offlineMode), distinct from connection.svelte's transient reachability signal, with the pure persistence + readiness helpers (offlineReady / missingDicts) unit-tested. - Settings.svelte: an Online / Offline toggle, shown only for an installed web PWA with a confirmed email (the SW-launch + durable-account preconditions). - Header.svelte: a blue-tinted nav (color-mix from the accent, so it tracks light/dark and a Telegram theme override) + an "Offline" chip while offline; the deliberate mode suppresses the transient "Connecting…" indicator. Behaviour-preserving online (the toggle is hidden and offlineMode is false there; e2e 196 green). App entry stays within its size budget (111.6 / 112 KB gzip). --- ui/src/components/Header.svelte | 36 ++++++++++++++++++++++++--- ui/src/lib/i18n/en.ts | 3 +++ ui/src/lib/i18n/ru.ts | 3 +++ ui/src/lib/offline.svelte.ts | 23 ++++++++++++++++++ ui/src/lib/offline.test.ts | 38 +++++++++++++++++++++++++++++ ui/src/lib/offline.ts | 43 +++++++++++++++++++++++++++++++++ ui/src/screens/Settings.svelte | 24 ++++++++++++++++++ 7 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 ui/src/lib/offline.svelte.ts create mode 100644 ui/src/lib/offline.test.ts create mode 100644 ui/src/lib/offline.ts diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 222051d..6e14256 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -1,6 +1,7 @@ -