felt/internal/blind/wizard.go
Mikkel Georgsen 99545bd128 feat(01-05): implement building block CRUD and API routes
- ChipSetService with full CRUD, duplication, builtin protection
- BlindStructure service with level validation and CRUD
- PayoutStructure service with bracket/tier nesting and 100% sum validation
- BuyinConfig service with rake split validation and all rebuy/addon fields
- TournamentTemplate service with FK validation and expanded view
- WizardService generates blind structures from high-level inputs
- API routes: /chip-sets, /blind-structures, /payout-structures, /buyin-configs, /tournament-templates
- All mutations require admin role, reads require floor+
- Wired template routes into server protected group

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:55:47 +01:00

302 lines
7.5 KiB
Go

package blind
import (
"context"
"database/sql"
"fmt"
"math"
"sort"
)
// WizardInput defines the inputs for the blind structure wizard.
type WizardInput struct {
PlayerCount int `json:"player_count"`
StartingChips int64 `json:"starting_chips"`
TargetDurationMinutes int `json:"target_duration_minutes"`
ChipSetID int64 `json:"chip_set_id"`
}
// WizardService generates blind structures from high-level inputs.
type WizardService struct {
db *sql.DB
}
// NewWizardService creates a new WizardService.
func NewWizardService(db *sql.DB) *WizardService {
return &WizardService{db: db}
}
// Generate produces a blind level array from wizard inputs.
// The algorithm:
// 1. Calculate target number of levels from duration / level duration
// 2. Calculate target final BB from total chips / ~10 BB
// 3. Generate geometric blind progression
// 4. Snap blinds to chip denominations
// 5. Insert breaks and chip-up markers
//
// The result is NOT saved -- it is a preview for the TD to review and save.
func (ws *WizardService) Generate(ctx context.Context, input WizardInput) ([]BlindLevel, error) {
if input.PlayerCount < 2 {
return nil, fmt.Errorf("player_count must be at least 2")
}
if input.StartingChips <= 0 {
return nil, fmt.Errorf("starting_chips must be positive")
}
if input.TargetDurationMinutes < 30 {
return nil, fmt.Errorf("target_duration_minutes must be at least 30")
}
// Load chip denominations for snapping
denoms, err := ws.loadDenominations(ctx, input.ChipSetID)
if err != nil {
return nil, err
}
if len(denoms) == 0 {
return nil, fmt.Errorf("chip set %d has no denominations", input.ChipSetID)
}
sort.Slice(denoms, func(i, j int) bool { return denoms[i] < denoms[j] })
// Step 1: Determine level duration and count
levelDurationMinutes := determineLevelDuration(input.TargetDurationMinutes)
levelDurationSeconds := levelDurationMinutes * 60
numRoundLevels := input.TargetDurationMinutes / levelDurationMinutes
if numRoundLevels < 5 {
numRoundLevels = 5
}
if numRoundLevels > 30 {
numRoundLevels = 30
}
// Step 2: Calculate target final big blind
// At the end, average stack ~= 10 BB
totalChips := input.StartingChips * int64(input.PlayerCount)
targetFinalBB := totalChips / 10
// Step 3: Calculate initial BB (smallest denomination * 2 or smallest * 4)
initialBB := denoms[0] * 2
if initialBB < denoms[0] {
initialBB = denoms[0]
}
// Step 4: Generate geometric blind progression
if targetFinalBB <= initialBB {
targetFinalBB = initialBB * int64(numRoundLevels)
}
ratio := math.Pow(float64(targetFinalBB)/float64(initialBB), 1.0/float64(numRoundLevels-1))
if ratio < 1.1 {
ratio = 1.1
}
if ratio > 3.0 {
ratio = 3.0
}
// Step 5: Generate levels, snapping to denominations
var levels []BlindLevel
position := 0
breakInterval := determineBreakInterval(numRoundLevels)
breakDuration := 10 * 60 // 10 minutes
roundsSinceBreak := 0
anteStartLevel := 4 // Antes start at level 4-5
prevBB := int64(0)
for i := 0; i < numRoundLevels; i++ {
// Insert break if needed
if roundsSinceBreak >= breakInterval && i > 0 {
breakLevel := BlindLevel{
Position: position,
LevelType: "break",
GameType: "nlhe",
DurationSeconds: breakDuration,
}
// Check if a chip-up is needed: find the smallest denomination
// still in play. If all blinds/antes from here on are >= next denom,
// mark chip-up.
chipUpValue := determineChipUp(denoms, prevBB)
if chipUpValue != nil {
breakLevel.ChipUpDenominationValue = chipUpValue
}
levels = append(levels, breakLevel)
position++
roundsSinceBreak = 0
}
// Calculate raw BB for this level
rawBB := float64(initialBB) * math.Pow(ratio, float64(i))
bb := snapToDenomination(int64(math.Round(rawBB)), denoms)
// Ensure BB is strictly increasing
if bb <= prevBB && prevBB > 0 {
bb = nextDenomination(prevBB, denoms)
}
// SB = BB/2, snapped to nearest denomination
sb := snapToDenomination(bb/2, denoms)
if sb >= bb {
sb = denoms[0]
}
if sb == 0 {
sb = denoms[0]
}
// Ante: starts at anteStartLevel, equals BB at higher levels
var ante int64
if i >= anteStartLevel {
ante = snapToDenomination(bb, denoms)
}
level := BlindLevel{
Position: position,
LevelType: "round",
GameType: "nlhe",
SmallBlind: sb,
BigBlind: bb,
Ante: ante,
DurationSeconds: levelDurationSeconds,
}
levels = append(levels, level)
prevBB = bb
position++
roundsSinceBreak++
}
return levels, nil
}
// loadDenominations loads chip denomination values for a chip set.
func (ws *WizardService) loadDenominations(ctx context.Context, chipSetID int64) ([]int64, error) {
rows, err := ws.db.QueryContext(ctx,
"SELECT value FROM chip_denominations WHERE chip_set_id = ? ORDER BY value", chipSetID,
)
if err != nil {
return nil, fmt.Errorf("load denominations: %w", err)
}
defer rows.Close()
var denoms []int64
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("scan denomination: %w", err)
}
denoms = append(denoms, v)
}
return denoms, rows.Err()
}
// determineLevelDuration chooses level duration based on target tournament length.
func determineLevelDuration(targetMinutes int) int {
switch {
case targetMinutes <= 90:
return 10 // Turbo
case targetMinutes <= 180:
return 15
case targetMinutes <= 300:
return 20 // Standard
case targetMinutes <= 420:
return 30 // Deep
default:
return 60 // WSOP-style
}
}
// determineBreakInterval chooses how many rounds between breaks.
func determineBreakInterval(numLevels int) int {
switch {
case numLevels <= 8:
return 4
case numLevels <= 15:
return 5
default:
return 6
}
}
// snapToDenomination snaps a value to the nearest chip denomination.
func snapToDenomination(value int64, denoms []int64) int64 {
if len(denoms) == 0 || value <= 0 {
return value
}
best := denoms[0]
bestDiff := abs64(value - best)
for _, d := range denoms[1:] {
diff := abs64(value - d)
if diff < bestDiff {
best = d
bestDiff = diff
}
// Since denoms are sorted, if we start getting further away, stop
if d > value && diff > bestDiff {
break
}
}
// Also consider multiples of denominations
for _, d := range denoms {
if d > value {
break
}
multiple := (value / d) * d
diff := abs64(value - multiple)
if diff < bestDiff {
best = multiple
bestDiff = diff
}
// Round up too
multipleUp := multiple + d
diffUp := abs64(value - multipleUp)
if diffUp < bestDiff {
best = multipleUp
bestDiff = diffUp
}
}
if best <= 0 {
return denoms[0]
}
return best
}
// nextDenomination returns the next denomination value above the given value.
func nextDenomination(value int64, denoms []int64) int64 {
// Try exact denominations first
for _, d := range denoms {
if d > value {
return d
}
}
// Use multiples of the largest denomination
largest := denoms[len(denoms)-1]
multiple := ((value / largest) + 1) * largest
return multiple
}
// determineChipUp checks if the smallest denomination can be removed.
func determineChipUp(denoms []int64, currentBB int64) *int64 {
if len(denoms) < 2 {
return nil
}
smallest := denoms[0]
nextSmallest := denoms[1]
// If the current BB is at least 4x the next smallest denomination,
// the smallest is no longer needed
if currentBB >= nextSmallest*4 {
return &smallest
}
return nil
}
// abs64 returns the absolute value of an int64.
func abs64(x int64) int64 {
if x < 0 {
return -x
}
return x
}