Compare commits
8 Commits
v1.11.0
...
afa44d41b4
| Author | SHA1 | Date | |
|---|---|---|---|
| afa44d41b4 | |||
| e4cf143e9f | |||
| 543cbe56b9 | |||
| a9c8f1ecfe | |||
| d694ead7b6 | |||
| 8c5995c076 | |||
| cedc9ffae1 | |||
| c334a9d7b7 |
@@ -252,6 +252,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
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/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
||||||
|
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -271,6 +272,7 @@ jobs:
|
|||||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||||
DICT_GOLD_DIR: /tmp/dictgold
|
DICT_GOLD_DIR: /tmp/dictgold
|
||||||
DICT_VALID_DIR: /tmp/validgold
|
DICT_VALID_DIR: /tmp/validgold
|
||||||
|
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
||||||
run: pnpm exec vitest run src/lib/dict/
|
run: pnpm exec vitest run src/lib/dict/
|
||||||
|
|
||||||
# gate is the single branch-protection required check. It always runs and passes
|
# gate is the single branch-protection required check. It always runs and passes
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
emitRulesets()
|
||||||
|
|
||||||
|
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
||||||
|
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
||||||
|
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
|
||||||
|
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
|
||||||
|
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
|
||||||
|
func emitRulesets() {
|
||||||
|
type rsFix struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
RackSize int `json:"rackSize"`
|
||||||
|
Bingo int `json:"bingo"`
|
||||||
|
Blanks int `json:"blanks"`
|
||||||
|
Values []int `json:"values"`
|
||||||
|
Counts []int `json:"counts"`
|
||||||
|
}
|
||||||
|
out := map[string]rsFix{}
|
||||||
|
for _, v := range []struct {
|
||||||
|
name string
|
||||||
|
rs *rules.Ruleset
|
||||||
|
}{
|
||||||
|
{"scrabble_en", rules.English()},
|
||||||
|
{"scrabble_ru", rules.RussianScrabble()},
|
||||||
|
{"erudit_ru", rules.Erudit()},
|
||||||
|
} {
|
||||||
|
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts}
|
||||||
|
}
|
||||||
|
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
log.Fatalf("movegen: mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
writeJSON(filepath.Join(dir, "rulesets.json"), out)
|
||||||
|
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, v any) {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
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,123 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
|
||||||
|
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
|
||||||
|
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
|
||||||
|
// Being in-package, this emitter constructs Game values directly and drives the unexported
|
||||||
|
// end-game math on chosen positions.
|
||||||
|
|
||||||
|
type endCaseIn struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Hands [][]int `json:"hands"`
|
||||||
|
Scores []int `json:"scores"`
|
||||||
|
Resigned []bool `json:"resigned"`
|
||||||
|
ToMove int `json:"toMove"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type endCaseOut struct {
|
||||||
|
endCaseIn
|
||||||
|
ScoresAfter []int `json:"scoresAfter"`
|
||||||
|
Winner int `json:"winner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rulesetFor(variant string) *rules.Ruleset {
|
||||||
|
switch variant {
|
||||||
|
case "scrabble_ru":
|
||||||
|
return rules.RussianScrabble()
|
||||||
|
case "erudit_ru":
|
||||||
|
return rules.Erudit()
|
||||||
|
default:
|
||||||
|
return rules.English()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonFor(s string) EndReason {
|
||||||
|
switch s {
|
||||||
|
case "out_of_tiles":
|
||||||
|
return EndOutOfTiles
|
||||||
|
case "scoreless":
|
||||||
|
return EndScoreless
|
||||||
|
case "resign":
|
||||||
|
return EndResign
|
||||||
|
case "aborted":
|
||||||
|
return EndAborted
|
||||||
|
}
|
||||||
|
return EndNotOver
|
||||||
|
}
|
||||||
|
|
||||||
|
func handsBytes(hands [][]int) [][]byte {
|
||||||
|
out := make([][]byte, len(hands))
|
||||||
|
for i, h := range hands {
|
||||||
|
b := make([]byte, len(h))
|
||||||
|
for j, x := range h {
|
||||||
|
b[j] = byte(x)
|
||||||
|
}
|
||||||
|
out[i] = b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
|
||||||
|
// EMIT_ENGINE_FIXTURES. Regenerate with:
|
||||||
|
//
|
||||||
|
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
|
||||||
|
func TestEmitEndgameFixtures(t *testing.T) {
|
||||||
|
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
|
||||||
|
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []endCaseIn{
|
||||||
|
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
|
||||||
|
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
|
||||||
|
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
|
||||||
|
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
|
||||||
|
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
|
||||||
|
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
|
||||||
|
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
|
||||||
|
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
|
||||||
|
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]endCaseOut, 0, len(cases))
|
||||||
|
for _, c := range cases {
|
||||||
|
rs := rulesetFor(c.Variant)
|
||||||
|
scores := append([]int(nil), c.Scores...)
|
||||||
|
g := &Game{
|
||||||
|
rules: rs,
|
||||||
|
hands: handsBytes(c.Hands),
|
||||||
|
scores: scores,
|
||||||
|
resigned: c.Resigned,
|
||||||
|
toMove: c.ToMove,
|
||||||
|
}
|
||||||
|
reason := reasonFor(c.Reason)
|
||||||
|
g.over = true
|
||||||
|
g.reason = reason
|
||||||
|
g.applyEndAdjustment(reason)
|
||||||
|
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "endgame.json")
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s (%d cases)", path, len(out))
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package robot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice
|
||||||
|
// so a local vs_ai game plays the same way as the server. These golden fixtures pin that
|
||||||
|
// TS port to the real Go strategy. Being in-package, this emitter can exercise the
|
||||||
|
// unexported mix / playToWin / deviates / selectMove that the port mirrors.
|
||||||
|
|
||||||
|
type mixCase struct {
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
Nums []int `json:"nums"`
|
||||||
|
Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints)
|
||||||
|
}
|
||||||
|
|
||||||
|
type decisionCase struct {
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
MoveCount int `json:"moveCount"`
|
||||||
|
MyScore int `json:"myScore"`
|
||||||
|
OppScore int `json:"oppScore"`
|
||||||
|
CandScores []int `json:"candScores"`
|
||||||
|
RackLen int `json:"rackLen"`
|
||||||
|
BagLen int `json:"bagLen"`
|
||||||
|
PlayToWin bool `json:"playToWin"`
|
||||||
|
Win bool `json:"win"` // the per-turn intent after the deviate flip
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Index int `json:"index"` // chosen candidate index for a play, else -1
|
||||||
|
}
|
||||||
|
|
||||||
|
type strategyFixture struct {
|
||||||
|
PlayToWinPercent int `json:"playToWinPercent"`
|
||||||
|
Mix []mixCase `json:"mix"`
|
||||||
|
Decisions []decisionCase `json:"decisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scenario is one selectMove situation exercised across several seeds.
|
||||||
|
type scenario struct {
|
||||||
|
moveCount, myScore, oppScore, rackLen, bagLen int
|
||||||
|
cands []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is
|
||||||
|
// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is
|
||||||
|
// what the TS parity test consumes. Regenerate with:
|
||||||
|
//
|
||||||
|
// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures
|
||||||
|
func TestEmitStrategyFixtures(t *testing.T) {
|
||||||
|
if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" {
|
||||||
|
t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789}
|
||||||
|
|
||||||
|
mixCases := []mixCase{}
|
||||||
|
addMix := func(seed int64, salt string, nums ...int) {
|
||||||
|
mixCases = append(mixCases, mixCase{
|
||||||
|
Seed: strconv.FormatInt(seed, 10),
|
||||||
|
Salt: salt,
|
||||||
|
Nums: append([]int{}, nums...),
|
||||||
|
Value: strconv.FormatUint(mix(seed, salt, nums...), 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, sd := range seeds {
|
||||||
|
addMix(sd, "win")
|
||||||
|
addMix(sd, "deviate", 0)
|
||||||
|
addMix(sd, "deviate", 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []scenario{
|
||||||
|
{3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game
|
||||||
|
{10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late
|
||||||
|
{1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread
|
||||||
|
{20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate)
|
||||||
|
{5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break)
|
||||||
|
{8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange
|
||||||
|
{8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass
|
||||||
|
{15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions := []decisionCase{}
|
||||||
|
for _, sd := range seeds {
|
||||||
|
for _, sc := range scenarios {
|
||||||
|
cands := make([]engine.MoveRecord, len(sc.cands))
|
||||||
|
for i, s := range sc.cands {
|
||||||
|
cands[i] = engine.MoveRecord{Score: s, Player: i}
|
||||||
|
}
|
||||||
|
rack := make([]string, sc.rackLen)
|
||||||
|
for i := range rack {
|
||||||
|
rack[i] = "a"
|
||||||
|
}
|
||||||
|
|
||||||
|
ptw := playToWin(sd)
|
||||||
|
win := ptw
|
||||||
|
if deviates(sd, sc.moveCount, sc.bagLen) {
|
||||||
|
win = !win
|
||||||
|
}
|
||||||
|
d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen)
|
||||||
|
|
||||||
|
kind, index := "pass", -1
|
||||||
|
switch d.kind {
|
||||||
|
case decidePlay:
|
||||||
|
kind, index = "play", d.move.Player
|
||||||
|
case decideExchange:
|
||||||
|
kind = "exchange"
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions = append(decisions, decisionCase{
|
||||||
|
Seed: strconv.FormatInt(sd, 10),
|
||||||
|
MoveCount: sc.moveCount,
|
||||||
|
MyScore: sc.myScore,
|
||||||
|
OppScore: sc.oppScore,
|
||||||
|
CandScores: append([]int{}, sc.cands...),
|
||||||
|
RackLen: sc.rackLen,
|
||||||
|
BagLen: sc.bagLen,
|
||||||
|
PlayToWin: ptw,
|
||||||
|
Win: win,
|
||||||
|
Kind: kind,
|
||||||
|
Index: index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions}
|
||||||
|
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(fx, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "strategy.json")
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions))
|
||||||
|
}
|
||||||
@@ -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;
|
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
|
// 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
|
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
|
||||||
// dafsa (*dawg).getEdge.
|
// 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,113 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Dawg } from './dawg';
|
||||||
|
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
|
||||||
|
import type { Ruleset } from './validate';
|
||||||
|
|
||||||
|
// Full-dictionary conformance for the ported move generator: for positions over the real
|
||||||
|
// shipped dictionaries (deep graphs and full 26/33-letter alphabets the tiny committed
|
||||||
|
// samples cannot reach) it must return exactly the ranked list the Go solver does. The
|
||||||
|
// golden vectors come from `go run ./backend/cmd/movegen -dawg-dir <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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
// connected reports whether the play connects to the position (or covers the
|
||||||
// centre on the first move). Mirrors (*Solver).connected.
|
// centre on the first move). Mirrors (*Solver).connected. Exported so the move
|
||||||
function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
// generator can apply the same post-generation connectivity filter.
|
||||||
|
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
||||||
if (b.isEmpty()) {
|
if (b.isEmpty()) {
|
||||||
const cr = Math.floor(rs.center / rs.cols);
|
const cr = Math.floor(rs.center / rs.cols);
|
||||||
const cc = rs.center % rs.cols;
|
const cc = rs.center % rs.cols;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Bag } from './bag';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
|
|
||||||
|
describe('offline bag', () => {
|
||||||
|
it('holds exactly the variant tile distribution', () => {
|
||||||
|
const rs = RULESETS.scrabble_en;
|
||||||
|
const total = rs.counts.reduce((a, b) => a + b, 0) + rs.blanks;
|
||||||
|
const bag = new Bag('scrabble_en', 42);
|
||||||
|
expect(bag.length).toBe(total);
|
||||||
|
|
||||||
|
const drawn = bag.draw(total);
|
||||||
|
expect(drawn.length).toBe(total);
|
||||||
|
expect(bag.length).toBe(0);
|
||||||
|
|
||||||
|
const tally = new Map<number, number>();
|
||||||
|
for (const t of drawn) tally.set(t, (tally.get(t) ?? 0) + 1);
|
||||||
|
for (let i = 0; i < rs.counts.length; i++) expect(tally.get(i) ?? 0).toBe(rs.counts[i]);
|
||||||
|
expect(tally.get(BLANK_INDEX) ?? 0).toBe(rs.blanks);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws beyond the remaining, returning all and emptying', () => {
|
||||||
|
const bag = new Bag('scrabble_ru', 7);
|
||||||
|
const total = bag.length;
|
||||||
|
const all = bag.draw(total + 5);
|
||||||
|
expect(all.length).toBe(total);
|
||||||
|
expect(bag.length).toBe(0);
|
||||||
|
expect(bag.draw(3)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tiles back to the bag', () => {
|
||||||
|
const bag = new Bag('erudit_ru', 1);
|
||||||
|
const before = bag.length;
|
||||||
|
const seven = bag.draw(7);
|
||||||
|
expect(bag.length).toBe(before - 7);
|
||||||
|
bag.return(seven);
|
||||||
|
expect(bag.length).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for a given seed and operation sequence', () => {
|
||||||
|
const a = new Bag('scrabble_en', 123);
|
||||||
|
const b = new Bag('scrabble_en', 123);
|
||||||
|
expect(a.draw(30)).toEqual(b.draw(30));
|
||||||
|
// A return reshuffles both identically, so subsequent draws still agree.
|
||||||
|
a.return([0, 1, 2]);
|
||||||
|
b.return([0, 1, 2]);
|
||||||
|
expect(a.draw(10)).toEqual(b.draw(10));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// The offline tile bag — the shuffled draw pile for one local game. Structurally a port of
|
||||||
|
// backend/internal/engine/bag.go (fill from the variant's counts + blanks, draw from the end,
|
||||||
|
// return-and-reshuffle for an exchange), but shuffled with a small DETERMINISTIC in-house PRNG
|
||||||
|
// rather than Go's math/rand: a local game only needs to be reproducible from its own seed and
|
||||||
|
// sequence of operations (for replay from the stored journal), not bit-identical to a server
|
||||||
|
// game (docs plan). Blanks ride as BLANK_INDEX, matching lib/alphabet.ts (and the engine's
|
||||||
|
// blankTile = 0xff = 255).
|
||||||
|
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
// mulberry32 is a compact deterministic PRNG returning a float in [0, 1). Seeded from the game
|
||||||
|
// seed so the shuffle sequence — and thus the draws — replay identically for the same seed and
|
||||||
|
// the same sequence of returns.
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bag is one local game's draw pile. Construct it with the variant and a numeric seed; draw()
|
||||||
|
* and return() mutate it in place. It is reproducible: the same seed and the same sequence of
|
||||||
|
* operations yield the same draws.
|
||||||
|
*/
|
||||||
|
export class Bag {
|
||||||
|
private tiles: number[];
|
||||||
|
private readonly rand: () => number;
|
||||||
|
|
||||||
|
constructor(variant: Variant, seed: number) {
|
||||||
|
const rs = RULESETS[variant];
|
||||||
|
const tiles: number[] = [];
|
||||||
|
for (let i = 0; i < rs.counts.length; i++) {
|
||||||
|
for (let n = 0; n < rs.counts[i]; n++) tiles.push(i);
|
||||||
|
}
|
||||||
|
for (let n = 0; n < rs.blanks; n++) tiles.push(BLANK_INDEX);
|
||||||
|
this.tiles = tiles;
|
||||||
|
this.rand = mulberry32(seed);
|
||||||
|
this.shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** length is the number of tiles left in the bag. */
|
||||||
|
get length(): number {
|
||||||
|
return this.tiles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* draw removes up to n tiles from the end of the bag and returns them. Drawing more than
|
||||||
|
* remain returns all of them; drawing from an empty bag returns an empty array.
|
||||||
|
*/
|
||||||
|
draw(n: number): number[] {
|
||||||
|
const take = Math.min(n, this.tiles.length);
|
||||||
|
return this.tiles.splice(this.tiles.length - take, take);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** return puts tiles back into the bag and reshuffles, as when a player exchanges tiles. */
|
||||||
|
return(tiles: readonly number[]): void {
|
||||||
|
for (const t of tiles) this.tiles.push(t);
|
||||||
|
this.shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// shuffle randomises the remaining tiles in place with the bag's own PRNG (Fisher–Yates).
|
||||||
|
private shuffle(): void {
|
||||||
|
for (let i = this.tiles.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(this.rand() * (i + 1));
|
||||||
|
const tmp = this.tiles[i];
|
||||||
|
this.tiles[i] = this.tiles[j];
|
||||||
|
this.tiles[j] = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// The mutable local-game board: a 15x15 row-major grid of placed tiles (alphabet-index letter
|
||||||
|
// plus a blank flag). It satisfies the read view the validator and generator need (GenBoard,
|
||||||
|
// which extends validate.ts's Board) and adds set() so the engine can apply a play. The board is
|
||||||
|
// alphabet-agnostic — a cell's letter is a variant index, meaningful with the variant's ruleset.
|
||||||
|
|
||||||
|
import { BOARD_SIZE } from '../premiums';
|
||||||
|
import type { Cell } from '../dict/validate';
|
||||||
|
import type { GenBoard } from '../dict/generate';
|
||||||
|
|
||||||
|
/** LocalBoard is the engine's in-memory board; it reads as a GenBoard and applies plays via set. */
|
||||||
|
export class LocalBoard implements GenBoard {
|
||||||
|
readonly rows = BOARD_SIZE;
|
||||||
|
readonly cols = BOARD_SIZE;
|
||||||
|
private readonly grid: (Cell | null)[];
|
||||||
|
private count = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.grid = new Array<Cell | null>(BOARD_SIZE * BOARD_SIZE).fill(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
inBounds(row: number, col: number): boolean {
|
||||||
|
return row >= 0 && row < this.rows && col >= 0 && col < this.cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
filled(row: number, col: number): boolean {
|
||||||
|
return this.inBounds(row, col) && this.grid[row * this.cols + col] !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cellAt(row: number, col: number): Cell {
|
||||||
|
return this.grid[row * this.cols + col]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty(): boolean {
|
||||||
|
return this.count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** set places a tile at (row, col). It assumes the square was empty (a play only lays tiles
|
||||||
|
* on empty squares), keeping the filled count exact for isEmpty. */
|
||||||
|
set(row: number, col: number, letter: number, blank: boolean): void {
|
||||||
|
const i = row * this.cols + col;
|
||||||
|
if (this.grid[i] === null) this.count++;
|
||||||
|
this.grid[i] = { letter, blank };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { applyEndAdjustment, winner, type EndReason } from './engine';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
// The offline engine must settle unplayed racks and decide the winner exactly as the Go engine
|
||||||
|
// does, so a local game finishes with the same scores. Golden from the in-package emitter
|
||||||
|
// (backend/internal/engine/endfixture_test.go).
|
||||||
|
interface EndCase {
|
||||||
|
name: string;
|
||||||
|
variant: Variant;
|
||||||
|
reason: EndReason;
|
||||||
|
hands: number[][];
|
||||||
|
scores: number[];
|
||||||
|
resigned: boolean[];
|
||||||
|
toMove: number;
|
||||||
|
scoresAfter: number[];
|
||||||
|
winner: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fx = JSON.parse(
|
||||||
|
readFileSync(new URL('./testdata/endgame.json', import.meta.url), 'utf8'),
|
||||||
|
) as { cases: EndCase[] };
|
||||||
|
|
||||||
|
describe('offline engine end-game parity vs Go', () => {
|
||||||
|
for (const c of fx.cases) {
|
||||||
|
it(c.name, () => {
|
||||||
|
const values = RULESETS[c.variant].values;
|
||||||
|
const after = applyEndAdjustment(c.reason, c.hands, c.scores, c.toMove, values);
|
||||||
|
expect(after).toEqual(c.scoresAfter);
|
||||||
|
expect(winner(true, c.reason, after, c.resigned)).toBe(c.winner);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Dawg } from '../dict/dawg';
|
||||||
|
import { RACK_SIZE } from '../premiums';
|
||||||
|
import { LocalGame } from './engine';
|
||||||
|
import { decide } from '../robot/strategy';
|
||||||
|
import type { Move } from '../dict/validate';
|
||||||
|
|
||||||
|
// A full-loop smoke: two robots play a whole local vs_ai game to completion over the committed
|
||||||
|
// sample dictionary, exercising deal / play / refill / exchange / pass / end detection / winner.
|
||||||
|
// (The rich full-dictionary robot behaviour is pinned elsewhere by the generator + strategy parity
|
||||||
|
// suites; this test proves the engine drives to a valid finish.)
|
||||||
|
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
|
||||||
|
function bestOpponentScore(game: LocalGame, seat: number): number {
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotTurn plays the seat to move: it picks the robot's action from the ranked legal plays and
|
||||||
|
// dispatches it. The sample dict has a one-letter word ("a") the generator emits but the validator
|
||||||
|
// rejects (len < 2) — real dictionaries have none, so filtering to main length >= 2 is a no-op in
|
||||||
|
// production; an exchange the bag is too small to satisfy falls back to a pass.
|
||||||
|
function robotTurn(game: LocalGame, seed: bigint): void {
|
||||||
|
const seat = game.currentPlayer;
|
||||||
|
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
|
||||||
|
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
|
||||||
|
if (dec.kind === 'play') {
|
||||||
|
game.play(dec.move.dir, dec.move.tiles);
|
||||||
|
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
|
||||||
|
game.exchange(dec.exchange);
|
||||||
|
} else {
|
||||||
|
game.pass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('offline engine full-game smoke', () => {
|
||||||
|
it('plays a whole 2-player vs_ai game to completion', () => {
|
||||||
|
const game = new LocalGame({
|
||||||
|
variant: 'scrabble_en',
|
||||||
|
version: 'sample',
|
||||||
|
seed: 123456789n,
|
||||||
|
players: 2,
|
||||||
|
dawg,
|
||||||
|
multipleWords: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let turns = 0;
|
||||||
|
while (!game.isOver && turns < 2000) {
|
||||||
|
robotTurn(game, game.seed);
|
||||||
|
turns++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(game.isOver).toBe(true);
|
||||||
|
expect(turns).toBeLessThan(2000);
|
||||||
|
expect(['out_of_tiles', 'scoreless', 'resign']).toContain(game.endReason);
|
||||||
|
|
||||||
|
const w = game.winnerIndex;
|
||||||
|
expect(w === -1 || (w >= 0 && w < game.playerCount)).toBe(true);
|
||||||
|
for (let i = 0; i < game.playerCount; i++) expect(Number.isInteger(game.scoreOf(i))).toBe(true);
|
||||||
|
// The move log recorded every turn.
|
||||||
|
expect(game.history.length).toBe(turns);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is reproducible from the seed', () => {
|
||||||
|
const play = (): { reason: string; scores: number[]; turns: number } => {
|
||||||
|
const g = new LocalGame({ variant: 'scrabble_en', version: 'sample', seed: 42n, players: 2, dawg, multipleWords: true });
|
||||||
|
let t = 0;
|
||||||
|
while (!g.isOver && t < 2000) {
|
||||||
|
robotTurn(g, g.seed);
|
||||||
|
t++;
|
||||||
|
}
|
||||||
|
return { reason: g.endReason, scores: [g.scoreOf(0), g.scoreOf(1)], turns: t };
|
||||||
|
};
|
||||||
|
expect(play()).toEqual(play());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// The offline game engine — a faithful port of backend/internal/engine/game.go. It owns the
|
||||||
|
// board, the bag, each seat's hand, the scores, whose turn it is and the move log, applies plays
|
||||||
|
// (reusing the already-ported validator lib/dict/validate.ts and generator lib/dict/generate.ts),
|
||||||
|
// and detects the end of a game (out-of-tiles / scoreless / resign) with the standard end-of-game
|
||||||
|
// rack adjustment. It is pure in-memory logic with no I/O; a local vs_ai game is driven by feeding
|
||||||
|
// it the robot's choice (lib/robot/strategy.ts) each turn. The bag RNG is our own deterministic
|
||||||
|
// PRNG (see bag.ts), so a game replays from its seed but is not bit-identical to a server game.
|
||||||
|
//
|
||||||
|
// End-of-game scoring, the winner rule and the rack value are exported as pure functions so they
|
||||||
|
// can be pinned against the Go engine (engine.parity.test.ts) on constructed positions.
|
||||||
|
|
||||||
|
import { LocalBoard } from './board';
|
||||||
|
import { Bag } from './bag';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import { generateMoves, GenRack, Both } from '../dict/generate';
|
||||||
|
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
|
||||||
|
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
|
||||||
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
|
import type { Dawg } from '../dict/dawg';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
/** scorelessLimit is the number of consecutive scoreless turns (passes and exchanges) that ends
|
||||||
|
* a game, mirroring engine.scorelessLimit. */
|
||||||
|
export const scorelessLimit = 6;
|
||||||
|
|
||||||
|
/** EndReason explains why a game finished, using the engine's stable labels. */
|
||||||
|
export type EndReason = 'not_over' | 'out_of_tiles' | 'scoreless' | 'resign' | 'aborted';
|
||||||
|
|
||||||
|
/** Disposition of a dropped-out (resigned/timed-out) seat's tiles in a 3-4 player game. */
|
||||||
|
export type DropoutTiles = 'remove' | 'return';
|
||||||
|
|
||||||
|
/** LocalMove is one recorded turn. tiles/dir are set on a play, count on an exchange. */
|
||||||
|
export interface LocalMove {
|
||||||
|
player: number;
|
||||||
|
action: 'play' | 'pass' | 'exchange' | 'resign';
|
||||||
|
dir?: Direction;
|
||||||
|
tiles?: Placement[];
|
||||||
|
count?: number;
|
||||||
|
score: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GameError carries a stable code for the engine's rejection reasons. */
|
||||||
|
export class GameError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'GameError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function letterMultOf(p: Premium): number {
|
||||||
|
return p === 'DL' ? 2 : p === 'TL' ? 3 : 1;
|
||||||
|
}
|
||||||
|
function wordMultOf(p: Premium): number {
|
||||||
|
return p === 'DW' ? 2 : p === 'TW' ? 3 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildRuleset assembles the validator/generator ruleset for a variant from the static offline
|
||||||
|
* tile values (ruleset.ts) and the board geometry (premiums.ts). multipleWords false selects the
|
||||||
|
* single-word-per-turn rule (perpendicular cross-words ignored). Mirrors the server engine's
|
||||||
|
* ruleset for the same variant; online scoring instead reads the server-sent alphabet values.
|
||||||
|
*/
|
||||||
|
export function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
|
||||||
|
const prem = premiumGrid(variant);
|
||||||
|
const ctr = centre(variant);
|
||||||
|
return {
|
||||||
|
cols: BOARD_SIZE,
|
||||||
|
center: ctr.row * BOARD_SIZE + ctr.col,
|
||||||
|
rackSize: RACK_SIZE,
|
||||||
|
bingo: BINGO[variant],
|
||||||
|
values: RULESETS[variant].values,
|
||||||
|
letterMult: (r, c) => letterMultOf(prem[r][c]),
|
||||||
|
wordMult: (r, c) => wordMultOf(prem[r][c]),
|
||||||
|
ignoreCrossWords: !multipleWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rackValue sums the tile values left on a hand; blanks (BLANK_INDEX) count zero. Mirrors
|
||||||
|
* engine (*Game).rackValue. */
|
||||||
|
export function rackValue(hand: readonly number[], values: readonly number[]): number {
|
||||||
|
let v = 0;
|
||||||
|
for (const t of hand) if (t !== BLANK_INDEX) v += values[t];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applyEndAdjustment settles the unplayed racks and returns the adjusted scores. Out-of-tiles: the
|
||||||
|
* player who went out (toMove) gains the sum of every opponent's rack value and each opponent loses
|
||||||
|
* their own. Scoreless: everyone loses their own rack value. Resign/aborted: no adjustment.
|
||||||
|
* Mirrors engine (*Game).applyEndAdjustment.
|
||||||
|
*/
|
||||||
|
export function applyEndAdjustment(
|
||||||
|
reason: EndReason,
|
||||||
|
hands: readonly (readonly number[])[],
|
||||||
|
scores: readonly number[],
|
||||||
|
toMove: number,
|
||||||
|
values: readonly number[],
|
||||||
|
): number[] {
|
||||||
|
const out = [...scores];
|
||||||
|
if (reason === 'out_of_tiles') {
|
||||||
|
let bonus = 0;
|
||||||
|
for (let i = 0; i < hands.length; i++) {
|
||||||
|
if (i === toMove) continue;
|
||||||
|
const v = rackValue(hands[i], values);
|
||||||
|
out[i] -= v;
|
||||||
|
bonus += v;
|
||||||
|
}
|
||||||
|
out[toMove] += bonus;
|
||||||
|
} else if (reason === 'scoreless') {
|
||||||
|
for (let i = 0; i < hands.length; i++) out[i] -= rackValue(hands[i], values);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* winner returns the index of the single highest-scoring seat, or -1 on a tie for the lead, an
|
||||||
|
* unfinished game or an aborted (draw) game. Resigned seats are excluded, so a two-player game
|
||||||
|
* returns the remaining player even when the resigner led. Mirrors engine (*Game).winner.
|
||||||
|
*/
|
||||||
|
export function winner(over: boolean, reason: EndReason, scores: readonly number[], resigned: readonly boolean[]): number {
|
||||||
|
if (!over || reason === 'aborted') return -1;
|
||||||
|
let best = -1;
|
||||||
|
let tie = false;
|
||||||
|
for (let i = 0; i < scores.length; i++) {
|
||||||
|
if (resigned[i]) continue;
|
||||||
|
if (best === -1 || scores[i] > scores[best]) {
|
||||||
|
best = i;
|
||||||
|
tie = false;
|
||||||
|
} else if (scores[i] === scores[best]) {
|
||||||
|
tie = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tie ? -1 : best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for a new local game. */
|
||||||
|
export interface LocalGameOptions {
|
||||||
|
variant: Variant;
|
||||||
|
version: string;
|
||||||
|
/** Seeds the bag; the whole game replays from it. Also the strategy seed the caller uses. */
|
||||||
|
seed: bigint;
|
||||||
|
players: number;
|
||||||
|
dawg: Dawg;
|
||||||
|
multipleWords: boolean;
|
||||||
|
dropoutTiles?: DropoutTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// placementTiles maps placements to the tiles they consume (BLANK_INDEX for a blank).
|
||||||
|
function placementTiles(tiles: readonly Placement[]): number[] {
|
||||||
|
return tiles.map((p) => (p.blank ? BLANK_INDEX : p.letter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalGame is the in-memory state of one local match and the rules engine over it. Construct it
|
||||||
|
* with a loaded dictionary; drive it with play/pass/exchange/resign. It performs no I/O.
|
||||||
|
*/
|
||||||
|
export class LocalGame {
|
||||||
|
readonly variant: Variant;
|
||||||
|
readonly version: string;
|
||||||
|
readonly seed: bigint;
|
||||||
|
private readonly vrs: Ruleset;
|
||||||
|
private readonly values: readonly number[];
|
||||||
|
private readonly rackSize: number;
|
||||||
|
private readonly dawg: Dawg;
|
||||||
|
private readonly dropoutTiles: DropoutTiles;
|
||||||
|
|
||||||
|
private readonly board: LocalBoard;
|
||||||
|
private readonly bag: Bag;
|
||||||
|
private readonly hands: number[][];
|
||||||
|
private readonly scores: number[];
|
||||||
|
private readonly resigned: boolean[];
|
||||||
|
private toMove = 0;
|
||||||
|
private scorelessRun = 0;
|
||||||
|
private over = false;
|
||||||
|
private reason: EndReason = 'not_over';
|
||||||
|
private readonly log: LocalMove[] = [];
|
||||||
|
|
||||||
|
constructor(opts: LocalGameOptions) {
|
||||||
|
if (opts.players < 2 || opts.players > 4) {
|
||||||
|
throw new GameError('players_out_of_range');
|
||||||
|
}
|
||||||
|
this.variant = opts.variant;
|
||||||
|
this.version = opts.version;
|
||||||
|
this.seed = opts.seed;
|
||||||
|
this.dawg = opts.dawg;
|
||||||
|
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
|
||||||
|
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
|
||||||
|
this.values = RULESETS[opts.variant].values;
|
||||||
|
this.rackSize = RULESETS[opts.variant].rackSize;
|
||||||
|
|
||||||
|
this.board = new LocalBoard();
|
||||||
|
this.bag = new Bag(opts.variant, Number(BigInt.asUintN(32, opts.seed)));
|
||||||
|
this.hands = [];
|
||||||
|
this.scores = [];
|
||||||
|
this.resigned = [];
|
||||||
|
for (let i = 0; i < opts.players; i++) {
|
||||||
|
this.hands.push(this.bag.draw(this.rackSize));
|
||||||
|
this.scores.push(0);
|
||||||
|
this.resigned.push(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- queries ---------------------------------------------------------------
|
||||||
|
get playerCount(): number {
|
||||||
|
return this.hands.length;
|
||||||
|
}
|
||||||
|
get currentPlayer(): number {
|
||||||
|
return this.toMove;
|
||||||
|
}
|
||||||
|
get isOver(): boolean {
|
||||||
|
return this.over;
|
||||||
|
}
|
||||||
|
get endReason(): EndReason {
|
||||||
|
return this.reason;
|
||||||
|
}
|
||||||
|
get bagLength(): number {
|
||||||
|
return this.bag.length;
|
||||||
|
}
|
||||||
|
get moveCount(): number {
|
||||||
|
return this.log.length;
|
||||||
|
}
|
||||||
|
scoreOf(player: number): number {
|
||||||
|
return this.scores[player];
|
||||||
|
}
|
||||||
|
/** rackOf returns a copy of a seat's hand (alphabet-index bytes, BLANK_INDEX for blanks). */
|
||||||
|
handOf(player: number): number[] {
|
||||||
|
return [...this.hands[player]];
|
||||||
|
}
|
||||||
|
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
||||||
|
get winnerIndex(): number {
|
||||||
|
return winner(this.over, this.reason, this.scores, this.resigned);
|
||||||
|
}
|
||||||
|
/** history returns a copy of the move log. */
|
||||||
|
get history(): LocalMove[] {
|
||||||
|
return this.log.map((m) => ({ ...m }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** generateMoves returns every legal play for the current player, ranked by descending score. */
|
||||||
|
generateMoves(): Move[] {
|
||||||
|
return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- turns -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** play validates and applies the current player's placement, scores it, refills the rack and
|
||||||
|
* advances the turn (or ends the game). Throws GameError on an illegal play. */
|
||||||
|
play(dir: Direction, tiles: Placement[]): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
const player = this.toMove;
|
||||||
|
const used = placementTiles(tiles);
|
||||||
|
if (!this.holds(player, used)) throw new GameError('tiles_not_on_rack');
|
||||||
|
|
||||||
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
||||||
|
if (!res.legal || !res.move) throw new GameError('illegal_play');
|
||||||
|
const move = res.move;
|
||||||
|
|
||||||
|
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
|
||||||
|
this.removeFromHand(player, used);
|
||||||
|
this.scores[player] += move.score;
|
||||||
|
this.refill(player);
|
||||||
|
this.scorelessRun = 0;
|
||||||
|
|
||||||
|
const rec: LocalMove = {
|
||||||
|
player,
|
||||||
|
action: 'play',
|
||||||
|
dir,
|
||||||
|
tiles: tiles.map((t) => ({ ...t })),
|
||||||
|
score: move.score,
|
||||||
|
total: this.scores[player],
|
||||||
|
};
|
||||||
|
this.log.push(rec);
|
||||||
|
|
||||||
|
if (this.hands[player].length === 0 && this.bag.length === 0) this.finish('out_of_tiles');
|
||||||
|
else this.advance();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pass forfeits the current turn, extending the scoreless run (which may end the game). */
|
||||||
|
pass(): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
const player = this.toMove;
|
||||||
|
this.scorelessRun++;
|
||||||
|
const rec: LocalMove = { player, action: 'pass', score: 0, total: this.scores[player] };
|
||||||
|
this.log.push(rec);
|
||||||
|
this.endTurnAfterScoreless();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** exchange swaps the given tiles (alphabet-index bytes, BLANK_INDEX for blanks) for fresh ones.
|
||||||
|
* Legal only while the bag holds at least a full rack; the fresh tiles are drawn before the
|
||||||
|
* swapped ones return, so a player cannot draw back their own. Extends the scoreless run. */
|
||||||
|
exchange(tiles: number[]): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
if (tiles.length === 0) throw new GameError('nothing_to_exchange');
|
||||||
|
if (this.bag.length < this.rackSize) throw new GameError('not_enough_tiles_to_exchange');
|
||||||
|
const player = this.toMove;
|
||||||
|
if (!this.holds(player, tiles)) throw new GameError('tiles_not_on_rack');
|
||||||
|
|
||||||
|
this.removeFromHand(player, tiles);
|
||||||
|
const drawn = this.bag.draw(tiles.length);
|
||||||
|
for (const t of drawn) this.hands[player].push(t);
|
||||||
|
this.bag.return(tiles);
|
||||||
|
this.scorelessRun++;
|
||||||
|
|
||||||
|
const rec: LocalMove = { player, action: 'exchange', count: tiles.length, score: 0, total: this.scores[player] };
|
||||||
|
this.log.push(rec);
|
||||||
|
this.endTurnAfterScoreless();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resign drops the current player out of the game (they forfeit the win, keep their score). */
|
||||||
|
resign(): LocalMove {
|
||||||
|
return this.resignSeat(this.toMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resignSeat resigns a specific seat regardless of whose turn it is. */
|
||||||
|
resignSeat(seat: number): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
if (seat < 0 || seat >= this.hands.length || this.resigned[seat]) throw new GameError('game_over');
|
||||||
|
this.resigned[seat] = true;
|
||||||
|
this.disposeHand(seat);
|
||||||
|
const rec: LocalMove = { player: seat, action: 'resign', score: 0, total: this.scores[seat] };
|
||||||
|
this.log.push(rec);
|
||||||
|
if (this.activeCount() <= 1) this.finish('resign');
|
||||||
|
else if (seat === this.toMove) this.advance();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals -------------------------------------------------------------
|
||||||
|
|
||||||
|
private rackOf(player: number): GenRack {
|
||||||
|
const letters: number[] = [];
|
||||||
|
let blanks = 0;
|
||||||
|
for (const t of this.hands[player]) {
|
||||||
|
if (t === BLANK_INDEX) blanks++;
|
||||||
|
else letters.push(t);
|
||||||
|
}
|
||||||
|
return GenRack.from(this.values.length, letters, blanks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(reason: EndReason): void {
|
||||||
|
this.over = true;
|
||||||
|
this.reason = reason;
|
||||||
|
const adjusted = applyEndAdjustment(reason, this.hands, this.scores, this.toMove, this.values);
|
||||||
|
for (let i = 0; i < adjusted.length; i++) this.scores[i] = adjusted[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
private endTurnAfterScoreless(): void {
|
||||||
|
if (this.scorelessRun >= scorelessLimit) this.finish('scoreless');
|
||||||
|
else this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private advance(): void {
|
||||||
|
const n = this.hands.length;
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const next = (this.toMove + i) % n;
|
||||||
|
if (!this.resigned[next]) {
|
||||||
|
this.toMove = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private activeCount(): number {
|
||||||
|
return this.resigned.reduce((n, r) => (r ? n : n + 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeHand(player: number): void {
|
||||||
|
if (this.dropoutTiles === 'return') this.bag.return(this.hands[player]);
|
||||||
|
this.hands[player] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private holds(player: number, want: readonly number[]): boolean {
|
||||||
|
const avail = new Map<number, number>();
|
||||||
|
for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1);
|
||||||
|
const need = new Map<number, number>();
|
||||||
|
for (const t of want) need.set(t, (need.get(t) ?? 0) + 1);
|
||||||
|
for (const [t, n] of need) if ((avail.get(t) ?? 0) < n) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeFromHand(player: number, used: readonly number[]): void {
|
||||||
|
const hand = this.hands[player];
|
||||||
|
for (const t of used) {
|
||||||
|
const i = hand.indexOf(t);
|
||||||
|
if (i >= 0) hand.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refill(player: number): void {
|
||||||
|
const need = this.rackSize - this.hands[player].length;
|
||||||
|
if (need > 0) for (const t of this.bag.draw(need)) this.hands[player].push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
// The offline engine is self-contained: it carries each variant's tile values, bag counts and
|
||||||
|
// blank count in ruleset.ts (these are not otherwise on the client — online scoring uses the
|
||||||
|
// server-sent alphabet). This pins that hand-copied table to the Go rulesets
|
||||||
|
// (scrabble-solver/rules), whose values the movegen tool dumps to testdata/rulesets.json.
|
||||||
|
interface RulesetFix {
|
||||||
|
size: number;
|
||||||
|
rackSize: number;
|
||||||
|
bingo: number;
|
||||||
|
blanks: number;
|
||||||
|
values: number[];
|
||||||
|
counts: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fx = JSON.parse(
|
||||||
|
readFileSync(new URL('./testdata/rulesets.json', import.meta.url), 'utf8'),
|
||||||
|
) as Record<Variant, RulesetFix>;
|
||||||
|
|
||||||
|
describe('offline ruleset table parity vs Go rules', () => {
|
||||||
|
for (const variant of Object.keys(fx) as Variant[]) {
|
||||||
|
it(variant, () => {
|
||||||
|
const rs = RULESETS[variant];
|
||||||
|
expect({
|
||||||
|
size: rs.size,
|
||||||
|
rackSize: rs.rackSize,
|
||||||
|
bingo: rs.bingo,
|
||||||
|
blanks: rs.blanks,
|
||||||
|
values: [...rs.values],
|
||||||
|
counts: [...rs.counts],
|
||||||
|
}).toEqual(fx[variant]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Static per-variant ruleset data for the offline engine — tile point values, bag tile counts
|
||||||
|
// and blank count, indexed by alphabet letter index. Mirrored from scrabble-solver/rules/rules.go
|
||||||
|
// (English / RussianScrabble / Erudit) so a local game is fully self-contained: online scoring
|
||||||
|
// reads the server-sent alphabet (lib/alphabet.ts), but offline there is no server, so the values
|
||||||
|
// live here. Board geometry, the centre and RACK_SIZE/BINGO already live in lib/premiums.ts; this
|
||||||
|
// table adds the values (offline copy), the bag distribution and the blank count. Pinned to the Go
|
||||||
|
// rulesets by ruleset.parity.test.ts.
|
||||||
|
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
/** VariantRuleset is the offline scoring + bag data for one variant. */
|
||||||
|
export interface VariantRuleset {
|
||||||
|
/** Number of letters in the alphabet (bag distribution and values are indexed 0..size-1). */
|
||||||
|
size: number;
|
||||||
|
/** Tiles drawn to a full rack (7 for every variant). */
|
||||||
|
rackSize: number;
|
||||||
|
/** All-tiles (bingo) bonus. */
|
||||||
|
bingo: number;
|
||||||
|
/** Number of blank tiles in the bag. */
|
||||||
|
blanks: number;
|
||||||
|
/** Tile point value per letter index; a blank scores 0 (handled by the caller). */
|
||||||
|
values: readonly number[];
|
||||||
|
/** Bag tile count per letter index (how many of each letter the bag holds). */
|
||||||
|
counts: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */
|
||||||
|
export const RULESETS: Record<Variant, VariantRuleset> = {
|
||||||
|
scrabble_en: {
|
||||||
|
size: 26,
|
||||||
|
rackSize: 7,
|
||||||
|
bingo: 50,
|
||||||
|
blanks: 2,
|
||||||
|
// a b c d e f g h i j k l m n o p q r s t u v w x y z
|
||||||
|
values: [1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10],
|
||||||
|
counts: [9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1],
|
||||||
|
},
|
||||||
|
scrabble_ru: {
|
||||||
|
size: 33,
|
||||||
|
rackSize: 7,
|
||||||
|
bingo: 50,
|
||||||
|
blanks: 2,
|
||||||
|
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
||||||
|
values: [1, 3, 1, 3, 2, 1, 3, 5, 5, 1, 4, 2, 2, 2, 1, 1, 2, 1, 1, 1, 2, 10, 5, 5, 5, 8, 10, 10, 4, 3, 8, 8, 3],
|
||||||
|
counts: [8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2],
|
||||||
|
},
|
||||||
|
erudit_ru: {
|
||||||
|
size: 33,
|
||||||
|
rackSize: 7,
|
||||||
|
bingo: 15,
|
||||||
|
blanks: 3,
|
||||||
|
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
||||||
|
values: [1, 3, 2, 3, 2, 1, 0, 5, 5, 1, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 10, 5, 10, 5, 10, 10, 10, 5, 5, 10, 10, 3],
|
||||||
|
counts: [10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3],
|
||||||
|
},
|
||||||
|
};
|
||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "out-basic",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
50,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
57,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-blank",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
255,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
30,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
31,
|
||||||
|
29
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-erudit-yo",
|
||||||
|
"variant": "erudit_ru",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
6,
|
||||||
|
32
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
13,
|
||||||
|
7
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-tie",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
30,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
30,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"winner": -1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-3p",
|
||||||
|
"variant": "scrabble_ru",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
15,
|
||||||
|
6,
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scoreless",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "scoreless",
|
||||||
|
"hands": [
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
20,
|
||||||
|
20
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
16,
|
||||||
|
15
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resign-2p",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "resign",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
100,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 1,
|
||||||
|
"scoresAfter": [
|
||||||
|
100,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"winner": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resign-3p",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "resign",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
50,
|
||||||
|
60,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
50,
|
||||||
|
60,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aborted",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "aborted",
|
||||||
|
"hands": [
|
||||||
|
[
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
40,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
40,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"winner": -1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
{
|
||||||
|
"erudit_ru": {
|
||||||
|
"size": 33,
|
||||||
|
"rackSize": 7,
|
||||||
|
"bingo": 15,
|
||||||
|
"blanks": 3,
|
||||||
|
"values": [
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"counts": [
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
9,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
6,
|
||||||
|
6,
|
||||||
|
6,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scrabble_en": {
|
||||||
|
"size": 26,
|
||||||
|
"rackSize": 7,
|
||||||
|
"bingo": 50,
|
||||||
|
"blanks": 2,
|
||||||
|
"values": [
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
8,
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
10,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"counts": [
|
||||||
|
9,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
9,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scrabble_ru": {
|
||||||
|
"size": 33,
|
||||||
|
"rackSize": 7,
|
||||||
|
"bingo": 50,
|
||||||
|
"blanks": 2,
|
||||||
|
"values": [
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
8,
|
||||||
|
8,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"counts": [
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { mix, playToWin, deviates, decide, DEFAULT_BAND } from './strategy';
|
||||||
|
|
||||||
|
// Conformance gate for the ported robot strategy: the client must fold the game seed and
|
||||||
|
// choose its action exactly as backend/internal/robot/strategy.go does, so a local vs_ai
|
||||||
|
// game plays the same. Golden fixtures come from the in-package emitter
|
||||||
|
// (backend/internal/robot/strategyfixture_test.go).
|
||||||
|
|
||||||
|
interface MixCase {
|
||||||
|
seed: string;
|
||||||
|
salt: string;
|
||||||
|
nums: number[];
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
interface DecisionCase {
|
||||||
|
seed: string;
|
||||||
|
moveCount: number;
|
||||||
|
myScore: number;
|
||||||
|
oppScore: number;
|
||||||
|
candScores: number[];
|
||||||
|
rackLen: number;
|
||||||
|
bagLen: number;
|
||||||
|
playToWin: boolean;
|
||||||
|
win: boolean;
|
||||||
|
kind: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
interface Fixture {
|
||||||
|
playToWinPercent: number;
|
||||||
|
mix: MixCase[];
|
||||||
|
decisions: DecisionCase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fx = JSON.parse(
|
||||||
|
readFileSync(new URL('./testdata/strategy.json', import.meta.url), 'utf8'),
|
||||||
|
) as Fixture;
|
||||||
|
|
||||||
|
describe('robot strategy parity vs Go', () => {
|
||||||
|
it('mix folds the seed like Go FNV-1a', () => {
|
||||||
|
for (const c of fx.mix) {
|
||||||
|
expect(mix(BigInt(c.seed), c.salt, ...c.nums), `${c.seed}/${c.salt}/${c.nums}`).toBe(BigInt(c.value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chooses the same action as the Go robot', () => {
|
||||||
|
for (const c of fx.decisions) {
|
||||||
|
const seed = BigInt(c.seed);
|
||||||
|
const cands = c.candScores.map((score, index) => ({ score, index }));
|
||||||
|
const rack = new Array<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// The offline robot's move choice, ported from backend/internal/robot/strategy.go so a
|
||||||
|
// local vs_ai game plays exactly as the server robot does: it picks the candidate whose
|
||||||
|
// resulting score margin lands closest to a target band (playing to win ~40% of games and
|
||||||
|
// to lose the rest), with an occasional off-strategy "wobble" that fades as the bag empties.
|
||||||
|
//
|
||||||
|
// Everything derives deterministically from the game's bag seed, folded by FNV-1a (mix),
|
||||||
|
// so the choice is reproducible. The 64-bit hash is computed with BigInt to stay bit-exact
|
||||||
|
// with Go's uint64 arithmetic; faithfulness is pinned by strategy.parity.test.ts against
|
||||||
|
// golden fixtures. Only the move-choice slice of strategy.go is ported — the server's
|
||||||
|
// think-time, sleep-window and nudge scheduling are irrelevant to on-device play.
|
||||||
|
|
||||||
|
// playToWinPercent is the probability, in percent, that the robot plays to win for a whole
|
||||||
|
// game; the rest of the time it plays to lose, so the human wins about 60% of games.
|
||||||
|
const playToWinPercent = 40;
|
||||||
|
|
||||||
|
// deviateMaxProb is the peak probability of a single off-strategy move, held through the
|
||||||
|
// opening and midgame; it tapers linearly to 0 over the last deviateTaperTiles bag tiles.
|
||||||
|
const deviateMaxProb = 0.2;
|
||||||
|
const deviateTaperTiles = 14;
|
||||||
|
|
||||||
|
const FNV_OFFSET = 14695981039346656037n;
|
||||||
|
const FNV_PRIME = 1099511628211n;
|
||||||
|
const TWO_POW_64 = 1n << 64n;
|
||||||
|
const MASK64 = TWO_POW_64 - 1n;
|
||||||
|
|
||||||
|
// u64le returns the 8 little-endian bytes of v reduced mod 2^64, matching Go's
|
||||||
|
// binary.LittleEndian.PutUint64(uint64(int64(v))) — negatives wrap two's-complement.
|
||||||
|
function u64le(v: bigint): number[] {
|
||||||
|
let x = ((v % TWO_POW_64) + TWO_POW_64) % TWO_POW_64;
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
out.push(Number(x & 0xffn));
|
||||||
|
x >>= 8n;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mix folds the game seed and a salt (a label plus optional integers such as the move
|
||||||
|
* index) into a stable 64-bit value, mirroring robot.mix (FNV-1a over the seed's 8 bytes,
|
||||||
|
* the salt bytes, then each number's 8 bytes). seed is the game's bag seed.
|
||||||
|
*/
|
||||||
|
export function mix(seed: bigint, salt: string, ...nums: number[]): bigint {
|
||||||
|
let h = FNV_OFFSET;
|
||||||
|
const feed = (b: number): void => {
|
||||||
|
h = ((h ^ BigInt(b & 0xff)) * FNV_PRIME) & MASK64;
|
||||||
|
};
|
||||||
|
for (const b of u64le(seed)) feed(b);
|
||||||
|
for (let i = 0; i < salt.length; i++) feed(salt.charCodeAt(i));
|
||||||
|
for (const n of nums) for (const b of u64le(BigInt(n))) feed(b);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** unitFloat maps a mixed value to a float in [0, 1), mirroring robot.unitFloat. */
|
||||||
|
export function unitFloat(v: bigint): number {
|
||||||
|
return Number(v) / Number(TWO_POW_64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* playToWin reports the robot's once-per-game decision to play to win, derived from the
|
||||||
|
* seed so it is fixed for the whole game. Mirrors robot.playToWin.
|
||||||
|
*/
|
||||||
|
export function playToWin(seed: bigint): boolean {
|
||||||
|
return mix(seed, 'win') % 100n < BigInt(playToWinPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviateProb is the probability of a single off-strategy move given the tiles left in the
|
||||||
|
// bag: deviateMaxProb through the midgame, tapering to 0 over the last deviateTaperTiles.
|
||||||
|
function deviateProb(bagLen: number): number {
|
||||||
|
if (bagLen <= 0) return 0;
|
||||||
|
if (bagLen >= deviateTaperTiles) return deviateMaxProb;
|
||||||
|
return (deviateMaxProb * bagLen) / deviateTaperTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deviates reports whether the robot plays a single move against its per-game intent at
|
||||||
|
* moveCount, given the tiles left in the bag — a deterministic per-turn draw, never firing
|
||||||
|
* once the bag is empty. Mirrors robot.deviates.
|
||||||
|
*/
|
||||||
|
export function deviates(seed: bigint, moveCount: number, bagLen: number): boolean {
|
||||||
|
const p = deviateProb(bagLen);
|
||||||
|
if (p <= 0) return false;
|
||||||
|
return unitFloat(mix(seed, 'deviate', moveCount)) < p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MarginBand is an inclusive target range for the resulting score margin (own score after
|
||||||
|
* the move minus the opponent's). */
|
||||||
|
export interface MarginBand {
|
||||||
|
lo: number;
|
||||||
|
hi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DEFAULT_BAND aims to lead by 1..30 when playing to win (negated when playing to lose),
|
||||||
|
* matching robot.defaultBand. */
|
||||||
|
export const DEFAULT_BAND: MarginBand = { lo: 1, hi: 30 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decision is the robot's chosen action: a play (the picked candidate), an exchange of the
|
||||||
|
* listed tiles, or a pass. Generic over the candidate type (needs only a score) and the
|
||||||
|
* rack tile type.
|
||||||
|
*/
|
||||||
|
export type Decision<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);
|
||||||
|
}
|
||||||
+1057
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user