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 } 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, 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, 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 := d.edge.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 := d.edge.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 := d.edge.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 := d.edge.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 "" }