Files
scrabble-game/loadtest/internal/moves/moves.go
T
Ilia Denisov 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
R2: load-test harness + contour resource observability
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.
2026-06-09 23:45:24 +02:00

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"
}