package game import ( "crypto/rand" "fmt" "math/big" "github.com/google/uuid" "scrabble/backend/internal/engine" ) // maxSeedingRounds caps the first-move draw's tie re-draws. With a real bag a tie // breaks with positive probability each round, so this is reached only by a // degenerate (e.g. test) entropy source that ties forever; the cap turns that into // an error instead of an infinite loop. const maxSeedingRounds = 1000 // SetupDraw is one recorded tile draw of the first-move seeding — one row of // game_setup_draws (docs/ARCHITECTURE.md §6, §9): the round, the draw order within // it, the seated account that drew, and the decoded tile with its rank. It is // dictionary-independent: the letter, the blank flag and the numeric rank describe // the draw without any alphabet table. type SetupDraw struct { Round int PickNo int Account uuid.UUID Letter string Blank bool Rank int } // seedingResult is the outcome of the first-move seeding: the winning account, the // seated accounts rotated so the winner leads (seat 0), and the full draw log to // persist. type seedingResult struct { winner uuid.UUID order []uuid.UUID draws []SetupDraw } // drawIntn returns a uniformly random integer in [0, n) for n > 0. It is the // entropy seam of the first-move seeding: production uses crypto/rand (cryptoIntn), // so every draw is honestly random with no single seed; tests inject a // deterministic source. type drawIntn func(n int) (int, error) // cryptoIntn draws a uniform integer in [0, n) from crypto/rand — the honest, // seedless entropy the first-move draw requires. func cryptoIntn(n int) (int, error) { v, err := rand.Int(rand.Reader, big.NewInt(int64(n))) if err != nil { return 0, fmt.Errorf("game: first-move draw entropy: %w", err) } return int(v.Int64()), nil } // seedFirstMove runs the official first-move draw over accounts for variant v, // drawing tiles with the entropy source intn. Each round every contender draws one // tile (without replacement) from a fresh full bag; the tile closest to "A" wins, a // blank beating every letter; contenders tied for the best tile re-draw in the next // round until a single leader remains. It returns the leader, the rotation that // seats the leader first (preserving the others' seating order), and every draw for // the record. It performs no I/O beyond calling intn. func seedFirstMove(v engine.Variant, accounts []uuid.UUID, intn drawIntn) (seedingResult, error) { if len(accounts) < 2 { return seedingResult{}, fmt.Errorf("game: first-move seeding needs at least 2 accounts, got %d", len(accounts)) } full, err := engine.SetupBag(v) if err != nil { return seedingResult{}, err } contenders := append([]uuid.UUID(nil), accounts...) var draws []SetupDraw for round := 1; ; round++ { if round > maxSeedingRounds { return seedingResult{}, fmt.Errorf("game: first-move seeding unresolved after %d rounds", maxSeedingRounds) } bag := append([]engine.SetupTile(nil), full...) picks := make([]engine.SetupTile, len(contenders)) for i, acc := range contenders { tile, rest, err := drawSetupTile(bag, intn) if err != nil { return seedingResult{}, err } bag = rest picks[i] = tile draws = append(draws, SetupDraw{ Round: round, PickNo: i, Account: acc, Letter: tile.Letter, Blank: tile.Blank, Rank: tile.Rank, }) } winners := bestContenders(contenders, picks) if len(winners) == 1 { return seedingResult{winner: winners[0], order: rotateToFirst(accounts, winners[0]), draws: draws}, nil } contenders = winners } } // drawSetupTile removes one uniformly random tile from bag using the entropy source // intn and returns it with the shrunk bag (a fresh slice, leaving bag untouched). // It is the per-tile draw primitive — the seam a future manual "player N draws a // tile" tournament API will drive, one call per external request. func drawSetupTile(bag []engine.SetupTile, intn drawIntn) (engine.SetupTile, []engine.SetupTile, error) { if len(bag) == 0 { return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw from an empty bag") } i, err := intn(len(bag)) if err != nil { return engine.SetupTile{}, nil, err } if i < 0 || i >= len(bag) { return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw index %d out of range %d", i, len(bag)) } tile := bag[i] rest := make([]engine.SetupTile, 0, len(bag)-1) rest = append(rest, bag[:i]...) rest = append(rest, bag[i+1:]...) return tile, rest, nil } // bestContenders returns the contenders whose drawn tile has the lowest (best) // rank — the sole winner if one, else the tied set that re-draws. picks is aligned // with contenders by index. func bestContenders(contenders []uuid.UUID, picks []engine.SetupTile) []uuid.UUID { best := picks[0].Rank for _, p := range picks[1:] { if p.Rank < best { best = p.Rank } } var winners []uuid.UUID for i, p := range picks { if p.Rank == best { winners = append(winners, contenders[i]) } } return winners } // rotateToFirst returns accounts rotated cyclically so winner sits first (seat 0), // preserving the seating order of the rest (docs/ARCHITECTURE.md §6). winner must be // present in accounts. func rotateToFirst(accounts []uuid.UUID, winner uuid.UUID) []uuid.UUID { i := 0 for ; i < len(accounts); i++ { if accounts[i] == winner { break } } out := make([]uuid.UUID, 0, len(accounts)) out = append(out, accounts[i:]...) out = append(out, accounts[:i]...) return out }