aa137e3558
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest + 10000 durable accounts with pre-created sessions directly in Postgres (token hash matches backend/internal/session), drives virtual players through the edge protocol (real 2-4p games assembled via invitations, mid-ranked legal moves generated locally by the embedded scrabble-solver — the edge carries no board, so the client replays history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles, result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed move test runs under BACKEND_DICT_DIR. Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource baseline. CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE, project CLAUDE repo layout.
187 lines
6.2 KiB
Go
187 lines
6.2 KiB
Go
// 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"
|
|
}
|