release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212
@@ -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_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
|
||||
// - sample_<tag>.words.json the stored words + their alphabet indexes
|
||||
// - sample_<tag>.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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<LetterSet | undefined>(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<string>();
|
||||
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;
|
||||
}
|
||||
BIN
Binary file not shown.
+9802
File diff suppressed because it is too large
Load Diff
+122
@@ -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
|
||||
]
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
+1271
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"alphabet": "ru",
|
||||
"numAdded": 6,
|
||||
"words": [
|
||||
"ад",
|
||||
"ар",
|
||||
"оса",
|
||||
"я",
|
||||
"яд",
|
||||
"яр"
|
||||
],
|
||||
"indexes": [
|
||||
[
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
0,
|
||||
17
|
||||
],
|
||||
[
|
||||
15,
|
||||
18,
|
||||
0
|
||||
],
|
||||
[
|
||||
32
|
||||
],
|
||||
[
|
||||
32,
|
||||
4
|
||||
],
|
||||
[
|
||||
32,
|
||||
17
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -297,8 +297,9 @@ export function validatePlay(
|
||||
}
|
||||
|
||||
// connected reports whether the play connects to the position (or covers the
|
||||
// centre on the first move). Mirrors (*Solver).connected.
|
||||
function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
||||
// centre on the first move). Mirrors (*Solver).connected. Exported so the move
|
||||
// generator can apply the same post-generation connectivity filter.
|
||||
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
||||
if (b.isEmpty()) {
|
||||
const cr = Math.floor(rs.center / rs.cols);
|
||||
const cc = rs.center % rs.cols;
|
||||
|
||||
Reference in New Issue
Block a user