R2: load-test harness + contour resource observability
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
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.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// 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"
|
||||
}
|
||||
Reference in New Issue
Block a user