04263a17ca
Each virtual player now builds its own edge.Client (its own h2c connection carrying both the Subscribe stream and the Execute calls), instead of every player multiplexing over a single shared http2.Transport. The R2 trip report traced the ~14% transport_error on game.state at 500 players to that single shared transport; per-player connections mirror real clients and isolate the artifact. The assembly burst and the gateway-hammer each get their own client. playTurn now reports when a game has finished so playerLoop drops it from the rotation (slices.DeleteFunc); once no active game remains the player idles while still holding its stream. This stops secondary ops from hammering game_finished on already-ended games (the other R2 harness finding).
172 lines
4.9 KiB
Go
172 lines
4.9 KiB
Go
package scenario
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"time"
|
|
|
|
"scrabble/loadtest/internal/edge"
|
|
"scrabble/loadtest/internal/moves"
|
|
"scrabble/loadtest/internal/seed"
|
|
)
|
|
|
|
// Game is one assembled match: its id, variant and members in seat order (Members[0]
|
|
// is the inviter, seat 0).
|
|
type Game struct {
|
|
ID string
|
|
Variant string
|
|
Members []seed.Account
|
|
}
|
|
|
|
// seatOf returns the seat index of accountID in the game, or -1.
|
|
func (g *Game) seatOf(accountID string) int {
|
|
for i, m := range g.Members {
|
|
if m.ID.String() == accountID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// assembleCohort forms games among a cohort of active players via the invitation
|
|
// flow, aiming for gamesPerPlayer (3-5) concurrent games per player with 2-4 players
|
|
// each. It returns the games it managed to start. Failures are logged and skipped so
|
|
// a partial assembly still drives load.
|
|
func (d *Driver) assembleCohort(ctx context.Context, cohort []seed.Account, gamesPerPlayer int, rng *rand.Rand) []*Game {
|
|
if len(cohort) < 2 {
|
|
return nil
|
|
}
|
|
c := edge.New(d.gateway) // one client for the assembly burst; players play on their own
|
|
gamesOf := make(map[string]int, len(cohort))
|
|
var games []*Game
|
|
for i := range cohort {
|
|
inviter := cohort[i]
|
|
target := 3 + rng.Intn(3) // 3..5
|
|
if gamesPerPlayer > 0 {
|
|
target = gamesPerPlayer
|
|
}
|
|
for gamesOf[inviter.ID.String()] < target {
|
|
members := pickMembers(cohort, inviter, rng)
|
|
if len(members) < 2 {
|
|
break
|
|
}
|
|
variant := moves.Variants()[rng.Intn(len(moves.Variants()))]
|
|
g, err := d.assemble(ctx, c, members, variant)
|
|
if err != nil {
|
|
d.log.Debug("assemble game", "err", err)
|
|
break
|
|
}
|
|
games = append(games, g)
|
|
for _, m := range members {
|
|
gamesOf[m.ID.String()]++
|
|
}
|
|
}
|
|
}
|
|
return games
|
|
}
|
|
|
|
// pickMembers builds a 2-4 player group led by inviter, drawing distinct others from
|
|
// the cohort at random.
|
|
func pickMembers(cohort []seed.Account, inviter seed.Account, rng *rand.Rand) []seed.Account {
|
|
size := 2 + rng.Intn(3) // 2..4
|
|
members := []seed.Account{inviter}
|
|
seen := map[string]bool{inviter.ID.String(): true}
|
|
for attempts := 0; len(members) < size && attempts < 4*size; attempts++ {
|
|
cand := cohort[rng.Intn(len(cohort))]
|
|
if seen[cand.ID.String()] {
|
|
continue
|
|
}
|
|
seen[cand.ID.String()] = true
|
|
members = append(members, cand)
|
|
}
|
|
return members
|
|
}
|
|
|
|
// assemble runs the invitation flow for one game: the inviter (members[0]) invites
|
|
// the rest, each invitee accepts the pending invitation, and the completing accept
|
|
// starts the game, which is then located in the inviter's game list.
|
|
func (d *Driver) assemble(ctx context.Context, c *edge.Client, members []seed.Account, variant string) (*Game, error) {
|
|
inviter := members[0]
|
|
inviteeIDs := make([]string, len(members)-1)
|
|
for i, m := range members[1:] {
|
|
inviteeIDs[i] = m.ID.String()
|
|
}
|
|
|
|
t0 := time.Now()
|
|
code, err := c.CreateInvitation(ctx, inviter.Token, inviteeIDs, variant)
|
|
d.rec.Record("invitation.create", code, time.Since(t0))
|
|
if err != nil || code != "ok" {
|
|
return nil, fmt.Errorf("invitation.create: %s", code)
|
|
}
|
|
|
|
for _, invitee := range members[1:] {
|
|
t0 = time.Now()
|
|
list, lc, err := c.ListInvitations(ctx, invitee.Token)
|
|
d.rec.Record("invitation.list", lc, time.Since(t0))
|
|
if err != nil || lc != "ok" {
|
|
return nil, fmt.Errorf("invitation.list: %s", lc)
|
|
}
|
|
invID := findPending(list, inviter.ID.String())
|
|
if invID == "" {
|
|
return nil, fmt.Errorf("no pending invitation from %s", inviter.ID)
|
|
}
|
|
t0 = time.Now()
|
|
ac, err := c.AcceptInvitation(ctx, invitee.Token, invID)
|
|
d.rec.Record("invitation.accept", ac, time.Since(t0))
|
|
if err != nil || ac != "ok" {
|
|
return nil, fmt.Errorf("invitation.accept: %s", ac)
|
|
}
|
|
}
|
|
|
|
t0 = time.Now()
|
|
games, gc, err := c.GamesList(ctx, inviter.Token)
|
|
d.rec.Record("games.list", gc, time.Since(t0))
|
|
if err != nil || gc != "ok" {
|
|
return nil, fmt.Errorf("games.list: %s", gc)
|
|
}
|
|
ids := make([]string, len(members))
|
|
for i, m := range members {
|
|
ids[i] = m.ID.String()
|
|
}
|
|
gameID := findGame(games, ids)
|
|
if gameID == "" {
|
|
return nil, fmt.Errorf("started game not found for %d members", len(members))
|
|
}
|
|
return &Game{ID: gameID, Variant: variant, Members: members}, nil
|
|
}
|
|
|
|
// findPending returns the id of a pending invitation from inviterID, or "".
|
|
func findPending(list []edge.Invitation, inviterID string) string {
|
|
for _, inv := range list {
|
|
if inv.InviterID == inviterID && inv.Status == "pending" {
|
|
return inv.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// findGame returns the id of the active game whose seat set equals memberIDs, or "".
|
|
func findGame(games []edge.Game, memberIDs []string) string {
|
|
want := make(map[string]bool, len(memberIDs))
|
|
for _, id := range memberIDs {
|
|
want[id] = true
|
|
}
|
|
for _, g := range games {
|
|
if !g.Active() || len(g.Seats) != len(memberIDs) {
|
|
continue
|
|
}
|
|
match := true
|
|
for _, s := range g.Seats {
|
|
if !want[s] {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return g.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|