- 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>
306 lines
9.6 KiB
Go
306 lines
9.6 KiB
Go
// Package blind provides blind structure management: CRUD operations,
|
|
// the structure wizard algorithm, and built-in template definitions.
|
|
package blind
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// BlindStructure represents a reusable blind level progression.
|
|
type BlindStructure struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
IsBuiltin bool `json:"is_builtin"`
|
|
GameTypeDefault string `json:"game_type_default"`
|
|
Notes string `json:"notes"`
|
|
Levels []BlindLevel `json:"levels,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// BlindLevel represents a single level in a blind structure.
|
|
type BlindLevel struct {
|
|
ID int64 `json:"id,omitempty"`
|
|
StructureID int64 `json:"structure_id,omitempty"`
|
|
Position int `json:"position"`
|
|
LevelType string `json:"level_type"`
|
|
GameType string `json:"game_type"`
|
|
SmallBlind int64 `json:"small_blind"`
|
|
BigBlind int64 `json:"big_blind"`
|
|
Ante int64 `json:"ante"`
|
|
BBAnte int64 `json:"bb_ante"`
|
|
DurationSeconds int `json:"duration_seconds"`
|
|
ChipUpDenominationValue *int64 `json:"chip_up_denomination_value,omitempty"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
// StructureService provides CRUD operations for blind structures.
|
|
type StructureService struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewStructureService creates a new StructureService.
|
|
func NewStructureService(db *sql.DB) *StructureService {
|
|
return &StructureService{db: db}
|
|
}
|
|
|
|
// validateLevels checks that levels meet all requirements.
|
|
func validateLevels(levels []BlindLevel) error {
|
|
if len(levels) == 0 {
|
|
return fmt.Errorf("at least one level is required")
|
|
}
|
|
|
|
hasRound := false
|
|
for i, l := range levels {
|
|
if l.Position != i {
|
|
return fmt.Errorf("positions must be contiguous starting from 0, got %d at index %d", l.Position, i)
|
|
}
|
|
if l.DurationSeconds <= 0 {
|
|
return fmt.Errorf("level %d: duration must be positive", i)
|
|
}
|
|
if l.LevelType == "round" {
|
|
hasRound = true
|
|
if l.SmallBlind >= l.BigBlind && l.BigBlind > 0 {
|
|
return fmt.Errorf("level %d: small blind (%d) must be less than big blind (%d)", i, l.SmallBlind, l.BigBlind)
|
|
}
|
|
}
|
|
if l.LevelType != "round" && l.LevelType != "break" {
|
|
return fmt.Errorf("level %d: invalid level type %q (must be 'round' or 'break')", i, l.LevelType)
|
|
}
|
|
}
|
|
|
|
if !hasRound {
|
|
return fmt.Errorf("at least one round level is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateStructure creates a new blind structure with levels.
|
|
func (s *StructureService) CreateStructure(ctx context.Context, name string, levels []BlindLevel) (*BlindStructure, error) {
|
|
if name == "" {
|
|
return nil, fmt.Errorf("structure name is required")
|
|
}
|
|
if err := validateLevels(levels); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
now := time.Now().Unix()
|
|
gameTypeDefault := "nlhe"
|
|
if len(levels) > 0 && levels[0].GameType != "" {
|
|
gameTypeDefault = levels[0].GameType
|
|
}
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
"INSERT INTO blind_structures (name, is_builtin, game_type_default, notes, created_at, updated_at) VALUES (?, 0, ?, '', ?, ?)",
|
|
name, gameTypeDefault, now, now,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert structure: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get structure id: %w", err)
|
|
}
|
|
|
|
if err := insertLevels(ctx, tx, id, levels); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit: %w", err)
|
|
}
|
|
|
|
return s.GetStructure(ctx, id)
|
|
}
|
|
|
|
// insertLevels inserts levels into the blind_levels table within a transaction.
|
|
func insertLevels(ctx context.Context, tx *sql.Tx, structureID int64, levels []BlindLevel) error {
|
|
for i, l := range levels {
|
|
gameType := l.GameType
|
|
if gameType == "" {
|
|
gameType = "nlhe"
|
|
}
|
|
levelType := l.LevelType
|
|
if levelType == "" {
|
|
levelType = "round"
|
|
}
|
|
|
|
_, err := tx.ExecContext(ctx,
|
|
`INSERT INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, chip_up_denomination_value, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
structureID, l.Position, levelType, gameType,
|
|
l.SmallBlind, l.BigBlind, l.Ante, l.BBAnte,
|
|
l.DurationSeconds, l.ChipUpDenominationValue, l.Notes,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert level %d: %w", i, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetStructure retrieves a blind structure by ID, including levels ordered by position.
|
|
func (s *StructureService) GetStructure(ctx context.Context, id int64) (*BlindStructure, error) {
|
|
bs := &BlindStructure{}
|
|
var isBuiltin int
|
|
var createdAt, updatedAt int64
|
|
|
|
err := s.db.QueryRowContext(ctx,
|
|
"SELECT id, name, is_builtin, game_type_default, notes, created_at, updated_at FROM blind_structures WHERE id = ?", id,
|
|
).Scan(&bs.ID, &bs.Name, &isBuiltin, &bs.GameTypeDefault, &bs.Notes, &createdAt, &updatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("blind structure not found: %d", id)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get structure: %w", err)
|
|
}
|
|
bs.IsBuiltin = isBuiltin != 0
|
|
bs.CreatedAt = time.Unix(createdAt, 0)
|
|
bs.UpdatedAt = time.Unix(updatedAt, 0)
|
|
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, chip_up_denomination_value, notes
|
|
FROM blind_levels WHERE structure_id = ? ORDER BY position`, id,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get levels: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var l BlindLevel
|
|
if err := rows.Scan(&l.ID, &l.StructureID, &l.Position, &l.LevelType, &l.GameType,
|
|
&l.SmallBlind, &l.BigBlind, &l.Ante, &l.BBAnte,
|
|
&l.DurationSeconds, &l.ChipUpDenominationValue, &l.Notes); err != nil {
|
|
return nil, fmt.Errorf("scan level: %w", err)
|
|
}
|
|
bs.Levels = append(bs.Levels, l)
|
|
}
|
|
return bs, rows.Err()
|
|
}
|
|
|
|
// ListStructures returns all blind structures (without levels).
|
|
func (s *StructureService) ListStructures(ctx context.Context) ([]BlindStructure, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
"SELECT id, name, is_builtin, game_type_default, notes, created_at, updated_at FROM blind_structures ORDER BY is_builtin DESC, name",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list structures: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var structs []BlindStructure
|
|
for rows.Next() {
|
|
var bs BlindStructure
|
|
var isBuiltin int
|
|
var createdAt, updatedAt int64
|
|
if err := rows.Scan(&bs.ID, &bs.Name, &isBuiltin, &bs.GameTypeDefault, &bs.Notes, &createdAt, &updatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan structure: %w", err)
|
|
}
|
|
bs.IsBuiltin = isBuiltin != 0
|
|
bs.CreatedAt = time.Unix(createdAt, 0)
|
|
bs.UpdatedAt = time.Unix(updatedAt, 0)
|
|
structs = append(structs, bs)
|
|
}
|
|
return structs, rows.Err()
|
|
}
|
|
|
|
// UpdateStructure updates a blind structure name and replaces its levels.
|
|
func (s *StructureService) UpdateStructure(ctx context.Context, id int64, name string, levels []BlindLevel) error {
|
|
if name == "" {
|
|
return fmt.Errorf("structure name is required")
|
|
}
|
|
if err := validateLevels(levels); err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
now := time.Now().Unix()
|
|
res, err := tx.ExecContext(ctx,
|
|
"UPDATE blind_structures SET name = ?, updated_at = ? WHERE id = ?",
|
|
name, now, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update structure: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("blind structure not found: %d", id)
|
|
}
|
|
|
|
// Replace levels
|
|
if _, err := tx.ExecContext(ctx, "DELETE FROM blind_levels WHERE structure_id = ?", id); err != nil {
|
|
return fmt.Errorf("delete old levels: %w", err)
|
|
}
|
|
if err := insertLevels(ctx, tx, id, levels); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DeleteStructure deletes a blind structure. Built-in structures cannot be deleted.
|
|
func (s *StructureService) DeleteStructure(ctx context.Context, id int64) error {
|
|
var isBuiltin int
|
|
err := s.db.QueryRowContext(ctx, "SELECT is_builtin FROM blind_structures WHERE id = ?", id).Scan(&isBuiltin)
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("blind structure not found: %d", id)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("check structure: %w", err)
|
|
}
|
|
if isBuiltin != 0 {
|
|
return fmt.Errorf("cannot delete built-in blind structure")
|
|
}
|
|
|
|
// Check if referenced by active tournaments
|
|
var refCount int
|
|
err = s.db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM tournaments WHERE blind_structure_id = ? AND status NOT IN ('completed', 'cancelled')", id,
|
|
).Scan(&refCount)
|
|
if err != nil {
|
|
return fmt.Errorf("check references: %w", err)
|
|
}
|
|
if refCount > 0 {
|
|
return fmt.Errorf("blind structure is referenced by %d active tournament(s)", refCount)
|
|
}
|
|
|
|
res, err := s.db.ExecContext(ctx, "DELETE FROM blind_structures WHERE id = ?", id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete structure: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("blind structure not found: %d", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DuplicateStructure creates an independent copy of a blind structure.
|
|
func (s *StructureService) DuplicateStructure(ctx context.Context, id int64, newName string) (*BlindStructure, error) {
|
|
original, err := s.GetStructure(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get original: %w", err)
|
|
}
|
|
|
|
if newName == "" {
|
|
newName = original.Name + " (Copy)"
|
|
}
|
|
|
|
return s.CreateStructure(ctx, newName, original.Levels)
|
|
}
|