// Package moves turns a game's public history and the caller's private rack into a // legal turn, by reconstructing the board and running the embedded scrabble-solver // locally (the edge protocol carries no board — the client replays history). It // picks a mid-ranked move so games progress realistically rather than optimally. package moves import ( "fmt" "math/rand" "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" "scrabble/loadtest/internal/edge" ) // blankIndex is the rack/exchange sentinel for a blank tile on the wire (Stage 13). const blankIndex = 255 // variantSpec maps an edge variant label to its ruleset constructor and committed // DAWG filename (the descriptive names kept by R1). type variantSpec struct { ruleset func() *rules.Ruleset dawg string } var specs = map[string]variantSpec{ "scrabble_en": {rules.English, "en_sowpods.dawg"}, "scrabble_ru": {rules.RussianScrabble, "ru_scrabble.dawg"}, "erudit_ru": {rules.Erudit, "ru_erudit.dawg"}, } // Variants returns the edge variant labels the harness drives, in catalogue order. func Variants() []string { return []string{"scrabble_en", "scrabble_ru", "erudit_ru"} } // engine is one loaded variant: its ruleset and a solver over its DAWG. type engine struct { rs *rules.Ruleset finder dawg.Finder solver *scrabble.Solver } // Registry holds a solver per variant, built from the committed DAWGs in dir. It is // safe for concurrent use: every Pick builds its own board and rack, and the solver // holds only read-only state (the same way the backend shares one solver per variant // across concurrent games). type Registry struct { engines map[string]*engine } // Open loads every variant's DAWG from dir and builds a solver over each. dir holds // the committed dawg files (the sibling scrabble-solver checkout's dawg/, or the // dictionary release artifact). func Open(dir string) (*Registry, error) { r := &Registry{engines: make(map[string]*engine)} for label, spec := range specs { rs := spec.ruleset() finder, err := dawg.Load(filepath.Join(dir, spec.dawg)) if err != nil { r.Close() return nil, fmt.Errorf("moves: load %s dawg %s from %s: %w", label, spec.dawg, dir, err) } r.engines[label] = &engine{rs: rs, finder: finder, solver: scrabble.NewSolver(rs, finder)} } return r, nil } // Close releases every loaded DAWG. func (r *Registry) Close() { for _, e := range r.engines { if e.finder != nil { _ = e.finder.Close() } } } // Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir // ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap). type Action struct { Kind string Dir string Tiles []edge.PlayTile Exchange []byte } // Pick reconstructs the board for variant from history, builds the rack from the // alphabet-index rack, generates the legal plays and returns a mid-ranked one. With // no legal play it exchanges (when the bag holds a full rack) or passes. rng makes // the choice deterministic per caller; pass each virtual player its own *rand.Rand // (rand.Rand is not safe for concurrent use). func (r *Registry) Pick(variant string, history []edge.Move, rackIdx []byte, bagLen int, rng *rand.Rand) (Action, error) { e, ok := r.engines[variant] if !ok { return Action{}, fmt.Errorf("moves: unknown variant %q", variant) } b, err := replayBoard(e.rs, history) if err != nil { return Action{}, err } legal := e.solver.GenerateMoves(b, buildRack(e.rs, rackIdx), scrabble.Both) if len(legal) == 0 { return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil } m := midRanked(legal, rng) return Action{Kind: "play", Dir: dirString(m.Dir), Tiles: toPlayTiles(m.Tiles)}, nil } // toPlayTiles maps the solver's newly-placed tiles to the edge submit-play tiles // (addressed by alphabet index, carrying the blank flag). func toPlayTiles(placements []scrabble.Placement) []edge.PlayTile { tiles := make([]edge.PlayTile, len(placements)) for i, p := range placements { tiles[i] = edge.PlayTile{Row: p.Row, Col: p.Col, Letter: p.Letter, Blank: p.Blank} } return tiles } // replayBoard mirrors backend engine.ReplayBoard using only the solver's public API: // each play record's letters are re-indexed through the alphabet and applied to an // empty board. Non-play records are ignored. func replayBoard(rs *rules.Ruleset, history []edge.Move) (*board.Board, error) { b := board.New(rs.Rows, rs.Cols) for _, rec := range history { if rec.Action != "play" { continue } ps := make([]scrabble.Placement, len(rec.Tiles)) for i, t := range rec.Tiles { idx, err := rs.Alphabet.Index(t.Letter) if err != nil { return nil, fmt.Errorf("moves: replay letter %q at (%d,%d): %w", t.Letter, t.Row, t.Col, err) } ps[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank} } scrabble.Apply(b, scrabble.Move{Tiles: ps}) } return b, nil } // buildRack turns the alphabet-index rack (255 a blank) into a solver Rack. func buildRack(rs *rules.Ruleset, rackIdx []byte) rack.Rack { rk := rack.New(rs.Alphabet.Size()) for _, idx := range rackIdx { if idx == blankIndex { rk.AddBlank() } else { rk.Add(idx) } } return rk } // midRanked returns a move from the middle third of the score-ranked list // (GenerateMoves returns highest-first), spreading the pick within that band with // rng. A tiny list yields its lowest-scoring move. func midRanked(moves []scrabble.Move, rng *rand.Rand) scrabble.Move { n := len(moves) if n <= 2 { return moves[n-1] } lo, hi := n/3, 2*n/3 if hi <= lo { hi = lo + 1 } return moves[lo+rng.Intn(hi-lo)] } // noPlay chooses an exchange (when the bag can refill a full rack) or a pass. func noPlay(rackIdx []byte, canExchange bool) Action { if canExchange && len(rackIdx) > 0 { return Action{Kind: "exchange", Exchange: append([]byte(nil), rackIdx...)} } return Action{Kind: "pass"} } // dirString renders a solver direction as the "H"/"V" the edge submit-play expects. func dirString(d scrabble.Direction) string { if d == scrabble.Vertical { return "V" } return "H" }