feat(game): official first-move tile draw + admin step-by-step replay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Decide who moves first by the official rule: each seated player draws one tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader remains. Each draw uses honest per-draw crypto/rand entropy (not the deterministic bag seed), so the recorded draw — not a seed — is the only account of the outcome. The leader takes seat 0, so the engine and journal replay are unchanged. The draw is recorded with the game (game_setup_draws, migration 00013) for future tournaments, designed as a discrete "player N draws a tile" step. Friend/AI games draw at creation. Auto-match draws when the game opens, against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are back-filled to the real opponent on join — so the opener's seat is fixed up front and the existing open-game pre-move is preserved with no reseating. Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with A-O/1-15 headers and highlighted premium squares, placed letters with their tile value as a subscript, rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) with the current player highlighted, and a per-move log with the tiles drawn and the bag remainder. Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user