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 }