422bd14b53
- display-name marker: letters-only 'Zzloadtest' (the editable-name validator forbids digits/colons), so profile.update resends the seeded name successfully. - draft.save: rack_order is a string in the backend draft DTO (was sent as []), fixing the bad_request. Both confirmed ok against the contour. chat_not_your_turn / nudge_own_turn are by-design turn gates (backend/internal/social/chat.go), correctly exercised.
244 lines
7.7 KiB
Go
244 lines
7.7 KiB
Go
// Package scenario drives virtual players against the gateway edge protocol: it
|
|
// assembles real games through the invitation flow, then runs each player's turn
|
|
// loop (poll state, replay history, generate a legal move with the embedded solver,
|
|
// submit it) plus a fraction of secondary operations. It exposes the moderate
|
|
// realistic ramp agreed for the R2 early pass and a separate gateway-hammer.
|
|
package scenario
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"math/rand"
|
|
"sync"
|
|
"time"
|
|
|
|
"scrabble/loadtest/internal/edge"
|
|
"scrabble/loadtest/internal/moves"
|
|
"scrabble/loadtest/internal/report"
|
|
"scrabble/loadtest/internal/seed"
|
|
)
|
|
|
|
// Driver ties the edge client, the local move generator and the run recorder
|
|
// together. All three are safe for concurrent use by many player goroutines.
|
|
type Driver struct {
|
|
edge *edge.Client
|
|
moves *moves.Registry
|
|
rec *report.Recorder
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewDriver builds a Driver.
|
|
func NewDriver(c *edge.Client, m *moves.Registry, rec *report.Recorder, log *slog.Logger) *Driver {
|
|
return &Driver{edge: c, moves: m, rec: rec, log: log}
|
|
}
|
|
|
|
// RealisticConfig parameterises the under-the-limit ramp.
|
|
type RealisticConfig struct {
|
|
Steps []int // concurrent active players per step (cumulative)
|
|
StepDur time.Duration // hold time per step
|
|
GamesPerPlayer int // target concurrent games per player; 0 => random 3..5
|
|
Tick time.Duration // per-player operation cadence (keeps a player under the per-user limit)
|
|
SecondaryProb float64 // chance per tick of a non-move operation
|
|
}
|
|
|
|
// DefaultRealistic returns the moderate ramp agreed for the R2 early pass: 50 -> 200
|
|
// -> 500 concurrent players, ~12 minutes per step, ~1 op/s per player.
|
|
func DefaultRealistic() RealisticConfig {
|
|
return RealisticConfig{
|
|
Steps: []int{50, 200, 500},
|
|
StepDur: 12 * time.Minute,
|
|
Tick: 800 * time.Millisecond,
|
|
SecondaryProb: 0.08,
|
|
}
|
|
}
|
|
|
|
// RunRealistic runs the staged ramp. Each step activates more players (drawn from the
|
|
// seeded pool), assembles a cohort of games for them and starts their turn loops; the
|
|
// loops run until the whole ramp ends. Players from earlier steps keep playing, so
|
|
// load is cumulative.
|
|
func (d *Driver) RunRealistic(ctx context.Context, pool *seed.Pool, cfg RealisticConfig) error {
|
|
players := shuffledPool(pool)
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
var wg sync.WaitGroup
|
|
activated := 0
|
|
for si, target := range cfg.Steps {
|
|
if target > len(players) {
|
|
target = len(players)
|
|
}
|
|
cohort := players[activated:target]
|
|
activated = target
|
|
if len(cohort) >= 2 {
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(si)))
|
|
games := d.assembleCohort(runCtx, cohort, cfg.GamesPerPlayer, rng)
|
|
byPlayer := gamesByPlayer(games)
|
|
d.log.Info("ramp step", "step", si+1, "active", activated, "cohort", len(cohort), "games", len(games))
|
|
for pi := range cohort {
|
|
p := cohort[pi]
|
|
wg.Add(1)
|
|
go func(p seed.Account, pg []*Game, sd int64) {
|
|
defer wg.Done()
|
|
d.playerLoop(runCtx, p, pg, cfg, rand.New(rand.NewSource(sd)))
|
|
}(p, byPlayer[p.ID.String()], time.Now().UnixNano()+int64(pi))
|
|
}
|
|
} else {
|
|
d.log.Warn("ramp step skipped: cohort too small", "step", si+1, "cohort", len(cohort))
|
|
}
|
|
select {
|
|
case <-time.After(cfg.StepDur):
|
|
case <-ctx.Done():
|
|
cancel()
|
|
wg.Wait()
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
cancel()
|
|
wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
// playerLoop runs one virtual player: a live-event subscription (loads the push hub,
|
|
// counts events) plus a round-robin turn loop over the player's games.
|
|
func (d *Driver) playerLoop(ctx context.Context, p seed.Account, games []*Game, cfg RealisticConfig, rng *rand.Rand) {
|
|
go d.subscribeLoop(ctx, p)
|
|
if len(games) == 0 {
|
|
<-ctx.Done()
|
|
return
|
|
}
|
|
ticker := time.NewTicker(cfg.Tick)
|
|
defer ticker.Stop()
|
|
gi := 0
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
g := games[gi%len(games)]
|
|
gi++
|
|
if rng.Float64() < cfg.SecondaryProb {
|
|
d.secondaryOp(ctx, p, g, rng)
|
|
continue
|
|
}
|
|
d.playTurn(ctx, p, g, rng)
|
|
}
|
|
}
|
|
}
|
|
|
|
// subscribeLoop holds the player's live-event stream open, counting events and
|
|
// reconnecting with a brief backoff after a drop, until the run ends.
|
|
func (d *Driver) subscribeLoop(ctx context.Context, p seed.Account) {
|
|
for ctx.Err() == nil {
|
|
err := d.edge.Subscribe(ctx, p.Token, func(e edge.Event) { d.rec.Event(e.Kind) })
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if err != nil {
|
|
d.rec.StreamErr()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
// playTurn plays one turn in g when it is the player's move: fetch state, replay
|
|
// history, pick a legal move and submit it (or exchange / pass).
|
|
func (d *Driver) playTurn(ctx context.Context, p seed.Account, g *Game, rng *rand.Rand) {
|
|
seat := g.seatOf(p.ID.String())
|
|
if seat < 0 {
|
|
return
|
|
}
|
|
t0 := time.Now()
|
|
st, code, err := d.edge.State(ctx, p.Token, g.ID)
|
|
d.rec.Record("game.state", code, time.Since(t0))
|
|
if err != nil || code != "ok" || !st.Game.Active() || st.Game.ToMove != seat {
|
|
return
|
|
}
|
|
|
|
t0 = time.Now()
|
|
hist, hc, err := d.edge.History(ctx, p.Token, g.ID)
|
|
d.rec.Record("game.history", hc, time.Since(t0))
|
|
if err != nil || hc != "ok" {
|
|
return
|
|
}
|
|
|
|
action, err := d.moves.Pick(g.Variant, hist, st.Rack, st.BagLen, rng)
|
|
if err != nil {
|
|
d.log.Debug("pick move", "variant", g.Variant, "err", err)
|
|
return
|
|
}
|
|
switch action.Kind {
|
|
case "play":
|
|
t0 = time.Now()
|
|
_, c, _ := d.edge.SubmitPlay(ctx, p.Token, g.ID, action.Dir, action.Tiles)
|
|
d.rec.Record("game.submit_play", c, time.Since(t0))
|
|
case "exchange":
|
|
t0 = time.Now()
|
|
_, c, _ := d.edge.Exchange(ctx, p.Token, g.ID, action.Exchange)
|
|
d.rec.Record("game.exchange", c, time.Since(t0))
|
|
default:
|
|
t0 = time.Now()
|
|
_, c, _ := d.edge.Pass(ctx, p.Token, g.ID)
|
|
d.rec.Record("game.pass", c, time.Since(t0))
|
|
}
|
|
}
|
|
|
|
// secondaryOp exercises one of the non-move edge operations the plan calls out, so
|
|
// the run touches nudge / chat / check-word / draft / profile / stats too.
|
|
func (d *Driver) secondaryOp(ctx context.Context, p seed.Account, g *Game, rng *rand.Rand) {
|
|
t0 := time.Now()
|
|
switch rng.Intn(7) {
|
|
case 0:
|
|
c, _ := d.edge.Nudge(ctx, p.Token, g.ID)
|
|
d.rec.Record("chat.nudge", c, time.Since(t0))
|
|
case 1:
|
|
c, _ := d.edge.ChatPost(ctx, p.Token, g.ID, "gg")
|
|
d.rec.Record("chat.post", c, time.Since(t0))
|
|
case 2:
|
|
c, _ := d.edge.CheckWord(ctx, p.Token, g.ID, []byte{0, 1, 2})
|
|
d.rec.Record("game.check_word", c, time.Since(t0))
|
|
case 3:
|
|
// rack_order is an opaque string and board_tiles a (here empty) array, per the
|
|
// backend draft DTO; a malformed shape is rejected as bad_request.
|
|
c, _ := d.edge.DraftSave(ctx, p.Token, g.ID, `{"rack_order":"","board_tiles":[]}`)
|
|
d.rec.Record("draft.save", c, time.Since(t0))
|
|
case 4:
|
|
c, _ := d.edge.DraftGet(ctx, p.Token, g.ID)
|
|
d.rec.Record("draft.get", c, time.Since(t0))
|
|
case 5:
|
|
lang := "en"
|
|
if rng.Intn(2) == 1 {
|
|
lang = "ru"
|
|
}
|
|
c, _ := d.edge.ProfileUpdate(ctx, p.Token, p.Name, lang)
|
|
d.rec.Record("profile.update", c, time.Since(t0))
|
|
default:
|
|
c, _ := d.edge.Stats(ctx, p.Token)
|
|
d.rec.Record("stats.get", c, time.Since(t0))
|
|
}
|
|
}
|
|
|
|
// shuffledPool returns every seeded account in random order, so an active set is a
|
|
// representative mix of durable and guest accounts.
|
|
func shuffledPool(pool *seed.Pool) []seed.Account {
|
|
all := pool.All()
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
rng.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] })
|
|
return all
|
|
}
|
|
|
|
// gamesByPlayer indexes the assembled games by each member's account id.
|
|
func gamesByPlayer(games []*Game) map[string][]*Game {
|
|
m := make(map[string][]*Game)
|
|
for _, g := range games {
|
|
for _, mem := range g.Members {
|
|
id := mem.ID.String()
|
|
m[id] = append(m[id], g)
|
|
}
|
|
}
|
|
return m
|
|
}
|