Compare commits

..

19 Commits

Author SHA1 Message Date
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
18 changed files with 2 additions and 13840 deletions
-2
View File
@@ -252,7 +252,6 @@ 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 /tmp/movegold
- name: Set up Node
uses: actions/setup-node@v4
@@ -272,7 +271,6 @@ jobs:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold
DICT_MOVEGEN_DIR: /tmp/movegold
run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes
-374
View File
@@ -1,374 +0,0 @@
// 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 (
"bytes"
"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")
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),
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))
}
// 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
// <variant>.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) {
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)
}
}
@@ -1,146 +0,0 @@
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))
}
-68
View File
@@ -1,68 +0,0 @@
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);
});
});
-79
View File
@@ -95,85 +95,6 @@ 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.
-99
View File
@@ -1,99 +0,0 @@
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));
});
}
});
}
-113
View File
@@ -1,113 +0,0 @@
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 <dawg> -out <dir>` 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,
);
}
});
}
});
-418
View File
@@ -1,418 +0,0 @@
// 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;
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
-122
View File
@@ -1,122 +0,0 @@
{
"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
]
]
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
{
"alphabet": "ru",
"numAdded": 6,
"words": [
"ад",
"ар",
"оса",
"я",
"яд",
"яр"
],
"indexes": [
[
0,
4
],
[
0,
17
],
[
15,
18,
0
],
[
32
],
[
32,
4
],
[
32,
17
]
]
}
+2 -3
View File
@@ -297,9 +297,8 @@ export function validatePlay(
}
// connected reports whether the play connects to the position (or covers the
// 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 {
// centre on the first move). Mirrors (*Solver).connected.
function connected(b: Board, rs: Ruleset, m: Move): boolean {
if (b.isEmpty()) {
const cr = Math.floor(rs.center / rs.cols);
const cc = rs.center % rs.cols;
-68
View File
@@ -1,68 +0,0 @@
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<string>(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);
}
}
});
});
-180
View File
@@ -1,180 +0,0 @@
// 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<C, R> =
| { 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<C extends { score: number }, R>(
cands: readonly C[],
myScore: number,
oppScore: number,
win: boolean,
band: MarginBand,
rack: readonly R[],
bagLen: number,
): Decision<C, R> {
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<C extends { score: number }, R>(
seed: bigint,
moveCount: number,
cands: readonly C[],
myScore: number,
oppScore: number,
rack: readonly R[],
bagLen: number,
band: MarginBand = DEFAULT_BAND,
): Decision<C, R> {
let win = playToWin(seed);
if (deviates(seed, moveCount, bagLen)) win = !win;
return selectMove(cands, myScore, oppScore, win, band, rack, bagLen);
}
File diff suppressed because it is too large Load Diff