- 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>
302 lines
7.5 KiB
Go
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
|
|
}
|