felt/internal/blind/structure.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

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)
}