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