feat(01-08): implement balance engine, break table, and seating API routes
- TDA-compliant balance engine with live-adaptive suggestions - Break table distributes players evenly across remaining tables - Stale suggestion detection and invalidation on state changes - Full REST API for tables, seating, balancing, blueprints, hand-for-hand - 15 tests covering balance, break table, auto-seat, and dealer button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b571c36dd
commit
2d3cb0ac9e
6 changed files with 2286 additions and 0 deletions
|
|
@ -1 +1,675 @@
|
|||
package seating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/felt-app/felt/internal/audit"
|
||||
"github.com/felt-app/felt/internal/server/ws"
|
||||
)
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
// BalanceStatus describes whether tables are balanced and what moves are needed.
|
||||
type BalanceStatus struct {
|
||||
IsBalanced bool `json:"is_balanced"`
|
||||
MaxDifference int `json:"max_difference"`
|
||||
Tables []TableCount `json:"tables"`
|
||||
NeedsMoves int `json:"needs_moves"`
|
||||
}
|
||||
|
||||
// TableCount is a summary of one table's player count.
|
||||
type TableCount struct {
|
||||
TableID int `json:"table_id"`
|
||||
TableName string `json:"table_name"`
|
||||
PlayerCount int `json:"player_count"`
|
||||
}
|
||||
|
||||
// BalanceSuggestion is a proposed move to balance tables.
|
||||
type BalanceSuggestion struct {
|
||||
ID int `json:"id"`
|
||||
FromTableID int `json:"from_table_id"`
|
||||
FromTableName string `json:"from_table_name"`
|
||||
ToTableID int `json:"to_table_id"`
|
||||
ToTableName string `json:"to_table_name"`
|
||||
PlayerID *string `json:"player_id,omitempty"`
|
||||
PlayerName *string `json:"player_name,omitempty"`
|
||||
Status string `json:"status"` // pending, accepted, cancelled, expired
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// ---------- Service ----------
|
||||
|
||||
// BalanceEngine manages table balancing suggestions per TDA rules.
|
||||
type BalanceEngine struct {
|
||||
db *sql.DB
|
||||
audit *audit.Trail
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewBalanceEngine creates a new BalanceEngine.
|
||||
func NewBalanceEngine(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub) *BalanceEngine {
|
||||
return &BalanceEngine{
|
||||
db: db,
|
||||
audit: auditTrail,
|
||||
hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Balance Check ----------
|
||||
|
||||
// CheckBalance checks whether tables in a tournament are balanced.
|
||||
// TDA rule: tables are unbalanced if the difference between the largest
|
||||
// and smallest table exceeds 1.
|
||||
func (e *BalanceEngine) CheckBalance(ctx context.Context, tournamentID string) (*BalanceStatus, error) {
|
||||
counts, err := e.getTableCounts(ctx, tournamentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(counts) < 2 {
|
||||
return &BalanceStatus{
|
||||
IsBalanced: true,
|
||||
Tables: counts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Find min and max player counts
|
||||
minCount := counts[0].PlayerCount
|
||||
maxCount := counts[0].PlayerCount
|
||||
for _, tc := range counts[1:] {
|
||||
if tc.PlayerCount < minCount {
|
||||
minCount = tc.PlayerCount
|
||||
}
|
||||
if tc.PlayerCount > maxCount {
|
||||
maxCount = tc.PlayerCount
|
||||
}
|
||||
}
|
||||
|
||||
diff := maxCount - minCount
|
||||
balanced := diff <= 1
|
||||
|
||||
// Calculate how many moves are needed to balance
|
||||
needsMoves := 0
|
||||
if !balanced {
|
||||
// Target: all tables should have either floor(total/n) or ceil(total/n) players
|
||||
total := 0
|
||||
for _, tc := range counts {
|
||||
total += tc.PlayerCount
|
||||
}
|
||||
n := len(counts)
|
||||
base := total / n
|
||||
extra := total % n
|
||||
|
||||
// Sort by player count descending to count surplus
|
||||
sorted := make([]TableCount, len(counts))
|
||||
copy(sorted, counts)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].PlayerCount > sorted[j].PlayerCount
|
||||
})
|
||||
|
||||
for i, tc := range sorted {
|
||||
target := base
|
||||
if i < extra {
|
||||
target = base + 1
|
||||
}
|
||||
if tc.PlayerCount > target {
|
||||
needsMoves += tc.PlayerCount - target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &BalanceStatus{
|
||||
IsBalanced: balanced,
|
||||
MaxDifference: diff,
|
||||
Tables: counts,
|
||||
NeedsMoves: needsMoves,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- Suggestions ----------
|
||||
|
||||
// SuggestMoves generates balance move suggestions. Suggestions are proposals
|
||||
// that must be accepted by the TD -- never auto-applied.
|
||||
func (e *BalanceEngine) SuggestMoves(ctx context.Context, tournamentID string) ([]BalanceSuggestion, error) {
|
||||
status, err := e.CheckBalance(ctx, tournamentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if status.IsBalanced {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build table name map for later use
|
||||
tableNames := make(map[int]string)
|
||||
for _, tc := range status.Tables {
|
||||
tableNames[tc.TableID] = tc.TableName
|
||||
}
|
||||
|
||||
// Calculate targets: each table should have floor(total/n) or ceil(total/n)
|
||||
total := 0
|
||||
for _, tc := range status.Tables {
|
||||
total += tc.PlayerCount
|
||||
}
|
||||
n := len(status.Tables)
|
||||
base := total / n
|
||||
extra := total % n
|
||||
|
||||
// Sort tables by player count descending
|
||||
sorted := make([]TableCount, len(status.Tables))
|
||||
copy(sorted, status.Tables)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].PlayerCount > sorted[j].PlayerCount
|
||||
})
|
||||
|
||||
// Assign targets: top 'extra' tables get base+1, rest get base
|
||||
targets := make(map[int]int)
|
||||
for i, tc := range sorted {
|
||||
if i < extra {
|
||||
targets[tc.TableID] = base + 1
|
||||
} else {
|
||||
targets[tc.TableID] = base
|
||||
}
|
||||
}
|
||||
|
||||
// Generate moves: from tables with surplus to tables with deficit
|
||||
type surplus struct {
|
||||
tableID int
|
||||
excess int
|
||||
}
|
||||
type deficit struct {
|
||||
tableID int
|
||||
need int
|
||||
}
|
||||
|
||||
var surplusTables []surplus
|
||||
var deficitTables []deficit
|
||||
|
||||
for _, tc := range sorted {
|
||||
target := targets[tc.TableID]
|
||||
if tc.PlayerCount > target {
|
||||
surplusTables = append(surplusTables, surplus{tc.TableID, tc.PlayerCount - target})
|
||||
} else if tc.PlayerCount < target {
|
||||
deficitTables = append(deficitTables, deficit{tc.TableID, target - tc.PlayerCount})
|
||||
}
|
||||
}
|
||||
|
||||
var suggestions []BalanceSuggestion
|
||||
|
||||
si, di := 0, 0
|
||||
for si < len(surplusTables) && di < len(deficitTables) {
|
||||
s := &surplusTables[si]
|
||||
d := &deficitTables[di]
|
||||
|
||||
// Pick a player from the surplus table (prefer player farthest from button for fairness)
|
||||
playerID, playerName := e.pickPlayerToMove(ctx, tournamentID, s.tableID)
|
||||
|
||||
// Insert suggestion into DB
|
||||
result, err := e.db.ExecContext(ctx,
|
||||
`INSERT INTO balance_suggestions
|
||||
(tournament_id, from_table_id, to_table_id, player_id, status, reason)
|
||||
VALUES (?, ?, ?, ?, 'pending', 'balance')`,
|
||||
tournamentID, s.tableID, d.tableID, playerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert suggestion: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
var createdAt int64
|
||||
_ = e.db.QueryRowContext(ctx,
|
||||
`SELECT created_at FROM balance_suggestions WHERE id = ?`, id,
|
||||
).Scan(&createdAt)
|
||||
|
||||
sugg := BalanceSuggestion{
|
||||
ID: int(id),
|
||||
FromTableID: s.tableID,
|
||||
FromTableName: tableNames[s.tableID],
|
||||
ToTableID: d.tableID,
|
||||
ToTableName: tableNames[d.tableID],
|
||||
PlayerID: playerID,
|
||||
PlayerName: playerName,
|
||||
Status: "pending",
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
suggestions = append(suggestions, sugg)
|
||||
|
||||
s.excess--
|
||||
d.need--
|
||||
if s.excess == 0 {
|
||||
si++
|
||||
}
|
||||
if d.need == 0 {
|
||||
di++
|
||||
}
|
||||
}
|
||||
|
||||
e.recordAudit(ctx, tournamentID, audit.ActionSeatBalance, "balance", tournamentID, nil,
|
||||
map[string]interface{}{"suggestions": len(suggestions)})
|
||||
e.broadcast(tournamentID, "balance.suggestions", suggestions)
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
// AcceptSuggestion re-validates and executes a balance suggestion.
|
||||
// If the suggestion is stale (state changed), it is cancelled and an error is returned.
|
||||
func (e *BalanceEngine) AcceptSuggestion(ctx context.Context, tournamentID string, suggestionID, fromSeat, toSeat int) error {
|
||||
// Load suggestion
|
||||
var sugg BalanceSuggestion
|
||||
var playerID sql.NullString
|
||||
err := e.db.QueryRowContext(ctx,
|
||||
`SELECT id, from_table_id, to_table_id, player_id, status
|
||||
FROM balance_suggestions
|
||||
WHERE id = ? AND tournament_id = ?`,
|
||||
suggestionID, tournamentID,
|
||||
).Scan(&sugg.ID, &sugg.FromTableID, &sugg.ToTableID, &playerID, &sugg.Status)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("suggestion not found")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load suggestion: %w", err)
|
||||
}
|
||||
|
||||
if sugg.Status != "pending" {
|
||||
return fmt.Errorf("suggestion is %s, not pending", sugg.Status)
|
||||
}
|
||||
|
||||
// Re-validate: is the source table still larger than destination?
|
||||
var fromCount, toCount int
|
||||
err = e.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM tournament_players
|
||||
WHERE tournament_id = ? AND seat_table_id = ? AND status IN ('registered', 'active')`,
|
||||
tournamentID, sugg.FromTableID,
|
||||
).Scan(&fromCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count from table: %w", err)
|
||||
}
|
||||
|
||||
err = e.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM tournament_players
|
||||
WHERE tournament_id = ? AND seat_table_id = ? AND status IN ('registered', 'active')`,
|
||||
tournamentID, sugg.ToTableID,
|
||||
).Scan(&toCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count to table: %w", err)
|
||||
}
|
||||
|
||||
// Stale check: if source is not larger than destination by at least 2, suggestion is stale
|
||||
if fromCount-toCount < 2 {
|
||||
// Cancel the stale suggestion
|
||||
_, _ = e.db.ExecContext(ctx,
|
||||
`UPDATE balance_suggestions SET status = 'expired', resolved_at = unixepoch()
|
||||
WHERE id = ?`, suggestionID)
|
||||
e.broadcast(tournamentID, "balance.suggestion_expired", map[string]interface{}{
|
||||
"suggestion_id": suggestionID, "reason": "stale",
|
||||
})
|
||||
return fmt.Errorf("suggestion is stale: table counts no longer justify this move (from=%d, to=%d)", fromCount, toCount)
|
||||
}
|
||||
|
||||
// Determine which player to move
|
||||
pid := ""
|
||||
if playerID.Valid {
|
||||
pid = playerID.String
|
||||
}
|
||||
if pid == "" {
|
||||
return fmt.Errorf("no player specified in suggestion")
|
||||
}
|
||||
|
||||
// Verify player is still at the source table
|
||||
var playerTable sql.NullInt64
|
||||
err = e.db.QueryRowContext(ctx,
|
||||
`SELECT seat_table_id FROM tournament_players
|
||||
WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`,
|
||||
tournamentID, pid,
|
||||
).Scan(&playerTable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check player location: %w", err)
|
||||
}
|
||||
if !playerTable.Valid || int(playerTable.Int64) != sugg.FromTableID {
|
||||
_, _ = e.db.ExecContext(ctx,
|
||||
`UPDATE balance_suggestions SET status = 'expired', resolved_at = unixepoch()
|
||||
WHERE id = ?`, suggestionID)
|
||||
return fmt.Errorf("suggestion is stale: player no longer at source table")
|
||||
}
|
||||
|
||||
// Verify destination seat is empty
|
||||
var occCount int
|
||||
err = e.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM tournament_players
|
||||
WHERE tournament_id = ? AND seat_table_id = ? AND seat_position = ?
|
||||
AND status IN ('registered', 'active')`,
|
||||
tournamentID, sugg.ToTableID, toSeat,
|
||||
).Scan(&occCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check destination seat: %w", err)
|
||||
}
|
||||
if occCount > 0 {
|
||||
return fmt.Errorf("destination seat %d is already occupied", toSeat)
|
||||
}
|
||||
|
||||
// Execute the move
|
||||
_, err = e.db.ExecContext(ctx,
|
||||
`UPDATE tournament_players SET seat_table_id = ?, seat_position = ?, updated_at = unixepoch()
|
||||
WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`,
|
||||
sugg.ToTableID, toSeat, tournamentID, pid,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("move player: %w", err)
|
||||
}
|
||||
|
||||
// Mark suggestion as accepted
|
||||
_, err = e.db.ExecContext(ctx,
|
||||
`UPDATE balance_suggestions SET status = 'accepted', from_seat = ?, to_seat = ?, resolved_at = unixepoch()
|
||||
WHERE id = ?`,
|
||||
fromSeat, toSeat, suggestionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark accepted: %w", err)
|
||||
}
|
||||
|
||||
e.recordAudit(ctx, tournamentID, audit.ActionSeatBalance, "player", pid,
|
||||
map[string]interface{}{"table_id": sugg.FromTableID, "seat": fromSeat},
|
||||
map[string]interface{}{"table_id": sugg.ToTableID, "seat": toSeat})
|
||||
e.broadcast(tournamentID, "balance.accepted", map[string]interface{}{
|
||||
"suggestion_id": suggestionID, "player_id": pid,
|
||||
"from_table_id": sugg.FromTableID, "to_table_id": sugg.ToTableID,
|
||||
"from_seat": fromSeat, "to_seat": toSeat,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelSuggestion cancels a pending balance suggestion.
|
||||
func (e *BalanceEngine) CancelSuggestion(ctx context.Context, tournamentID string, suggestionID int) error {
|
||||
result, err := e.db.ExecContext(ctx,
|
||||
`UPDATE balance_suggestions SET status = 'cancelled', resolved_at = unixepoch()
|
||||
WHERE id = ? AND tournament_id = ? AND status = 'pending'`,
|
||||
suggestionID, tournamentID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancel suggestion: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("suggestion not found or not pending")
|
||||
}
|
||||
|
||||
e.recordAudit(ctx, tournamentID, audit.ActionSeatBalance, "suggestion", fmt.Sprintf("%d", suggestionID), nil,
|
||||
map[string]interface{}{"status": "cancelled"})
|
||||
e.broadcast(tournamentID, "balance.cancelled", map[string]interface{}{
|
||||
"suggestion_id": suggestionID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateStaleSuggestions checks all pending suggestions and cancels those
|
||||
// that are no longer valid due to state changes. This implements the "live and
|
||||
// adaptive" behavior from CONTEXT.md.
|
||||
func (e *BalanceEngine) InvalidateStaleSuggestions(ctx context.Context, tournamentID string) error {
|
||||
rows, err := e.db.QueryContext(ctx,
|
||||
`SELECT id, from_table_id, to_table_id, player_id
|
||||
FROM balance_suggestions
|
||||
WHERE tournament_id = ? AND status = 'pending'`,
|
||||
tournamentID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query pending suggestions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pending struct {
|
||||
id int
|
||||
fromTableID int
|
||||
toTableID int
|
||||
playerID sql.NullString
|
||||
}
|
||||
|
||||
var pendings []pending
|
||||
for rows.Next() {
|
||||
var p pending
|
||||
if err := rows.Scan(&p.id, &p.fromTableID, &p.toTableID, &p.playerID); err != nil {
|
||||
return fmt.Errorf("scan suggestion: %w", err)
|
||||
}
|
||||
pendings = append(pendings, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current table counts
|
||||
counts, err := e.getTableCounts(ctx, tournamentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
countMap := make(map[int]int)
|
||||
for _, tc := range counts {
|
||||
countMap[tc.TableID] = tc.PlayerCount
|
||||
}
|
||||
|
||||
for _, p := range pendings {
|
||||
fromCount := countMap[p.fromTableID]
|
||||
toCount := countMap[p.toTableID]
|
||||
|
||||
stale := false
|
||||
|
||||
// Check: does this move still make sense?
|
||||
if fromCount-toCount < 2 {
|
||||
stale = true
|
||||
}
|
||||
|
||||
// Check: is the suggested player still at the source table?
|
||||
if !stale && p.playerID.Valid {
|
||||
var playerTableID sql.NullInt64
|
||||
err := e.db.QueryRowContext(ctx,
|
||||
`SELECT seat_table_id FROM tournament_players
|
||||
WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`,
|
||||
tournamentID, p.playerID.String,
|
||||
).Scan(&playerTableID)
|
||||
if err != nil || !playerTableID.Valid || int(playerTableID.Int64) != p.fromTableID {
|
||||
stale = true
|
||||
}
|
||||
}
|
||||
|
||||
if stale {
|
||||
_, _ = e.db.ExecContext(ctx,
|
||||
`UPDATE balance_suggestions SET status = 'expired', resolved_at = unixepoch()
|
||||
WHERE id = ?`, p.id)
|
||||
e.broadcast(tournamentID, "balance.suggestion_expired", map[string]interface{}{
|
||||
"suggestion_id": p.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPendingSuggestions returns all pending balance suggestions for a tournament.
|
||||
func (e *BalanceEngine) GetPendingSuggestions(ctx context.Context, tournamentID string) ([]BalanceSuggestion, error) {
|
||||
rows, err := e.db.QueryContext(ctx,
|
||||
`SELECT bs.id, bs.from_table_id, ft.name, bs.to_table_id, tt.name,
|
||||
bs.player_id, p.name, bs.status, bs.created_at
|
||||
FROM balance_suggestions bs
|
||||
JOIN tables ft ON ft.id = bs.from_table_id
|
||||
JOIN tables tt ON tt.id = bs.to_table_id
|
||||
LEFT JOIN players p ON p.id = bs.player_id
|
||||
WHERE bs.tournament_id = ? AND bs.status = 'pending'
|
||||
ORDER BY bs.created_at`,
|
||||
tournamentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query suggestions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var suggestions []BalanceSuggestion
|
||||
for rows.Next() {
|
||||
var s BalanceSuggestion
|
||||
var pid, pname sql.NullString
|
||||
if err := rows.Scan(&s.ID, &s.FromTableID, &s.FromTableName,
|
||||
&s.ToTableID, &s.ToTableName, &pid, &pname, &s.Status, &s.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan suggestion: %w", err)
|
||||
}
|
||||
if pid.Valid {
|
||||
s.PlayerID = &pid.String
|
||||
}
|
||||
if pname.Valid {
|
||||
s.PlayerName = &pname.String
|
||||
}
|
||||
suggestions = append(suggestions, s)
|
||||
}
|
||||
return suggestions, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
// getTableCounts returns player counts per active table.
|
||||
func (e *BalanceEngine) getTableCounts(ctx context.Context, tournamentID string) ([]TableCount, error) {
|
||||
rows, err := e.db.QueryContext(ctx,
|
||||
`SELECT t.id, t.name,
|
||||
(SELECT COUNT(*) FROM tournament_players tp
|
||||
WHERE tp.seat_table_id = t.id AND tp.tournament_id = t.tournament_id
|
||||
AND tp.status IN ('registered', 'active')) AS player_count
|
||||
FROM tables t
|
||||
WHERE t.tournament_id = ? AND t.is_active = 1
|
||||
ORDER BY t.name`,
|
||||
tournamentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get table counts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var counts []TableCount
|
||||
for rows.Next() {
|
||||
var tc TableCount
|
||||
if err := rows.Scan(&tc.TableID, &tc.TableName, &tc.PlayerCount); err != nil {
|
||||
return nil, fmt.Errorf("scan table count: %w", err)
|
||||
}
|
||||
counts = append(counts, tc)
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// pickPlayerToMove selects a player to move from a table for balancing.
|
||||
// Prefers the player in the worst position relative to the dealer button
|
||||
// (fairness per TDA rules).
|
||||
func (e *BalanceEngine) pickPlayerToMove(ctx context.Context, tournamentID string, tableID int) (*string, *string) {
|
||||
// Get dealer button position
|
||||
var btnPos sql.NullInt64
|
||||
var seatCount int
|
||||
_ = e.db.QueryRowContext(ctx,
|
||||
`SELECT dealer_button_position, seat_count FROM tables
|
||||
WHERE id = ? AND tournament_id = ?`,
|
||||
tableID, tournamentID,
|
||||
).Scan(&btnPos, &seatCount)
|
||||
|
||||
// Get all players at this table
|
||||
rows, err := e.db.QueryContext(ctx,
|
||||
`SELECT tp.player_id, p.name, tp.seat_position
|
||||
FROM tournament_players tp
|
||||
JOIN players p ON p.id = tp.player_id
|
||||
WHERE tp.tournament_id = ? AND tp.seat_table_id = ?
|
||||
AND tp.status IN ('registered', 'active') AND tp.seat_position IS NOT NULL
|
||||
ORDER BY tp.seat_position`,
|
||||
tournamentID, tableID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type playerSeat struct {
|
||||
id string
|
||||
name string
|
||||
position int
|
||||
}
|
||||
var players []playerSeat
|
||||
for rows.Next() {
|
||||
var ps playerSeat
|
||||
if err := rows.Scan(&ps.id, &ps.name, &ps.position); err != nil {
|
||||
continue
|
||||
}
|
||||
players = append(players, ps)
|
||||
}
|
||||
|
||||
if len(players) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If no button set, pick the last player (arbitrary but deterministic)
|
||||
if !btnPos.Valid {
|
||||
p := players[len(players)-1]
|
||||
return &p.id, &p.name
|
||||
}
|
||||
|
||||
btn := int(btnPos.Int64)
|
||||
|
||||
// Pick the player farthest from the button in clockwise direction
|
||||
// (i.e. the player who will wait longest for the button)
|
||||
// The player immediately after the button (SB) waits the longest for
|
||||
// their next button.
|
||||
// We want to move the player who is "worst off" = closest clockwise AFTER
|
||||
// the button (big blind or later positions are less penalized by a move).
|
||||
//
|
||||
// Simplification: pick the player in the first position clockwise after
|
||||
// the button (the small blind position) — that player benefits least from
|
||||
// staying.
|
||||
best := players[0]
|
||||
bestDist := clockwiseDist(btn, players[0].position, seatCount)
|
||||
for _, ps := range players[1:] {
|
||||
d := clockwiseDist(btn, ps.position, seatCount)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
best = ps
|
||||
}
|
||||
}
|
||||
|
||||
return &best.id, &best.name
|
||||
}
|
||||
|
||||
// clockwiseDist returns the clockwise distance from 'from' to 'to' on a
|
||||
// table with seatCount seats.
|
||||
func clockwiseDist(from, to, seatCount int) int {
|
||||
d := to - from
|
||||
if d <= 0 {
|
||||
d += seatCount
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (e *BalanceEngine) recordAudit(ctx context.Context, tournamentID, action, targetType, targetID string, prevState, newState interface{}) {
|
||||
if e.audit == nil {
|
||||
return
|
||||
}
|
||||
var prev, next json.RawMessage
|
||||
if prevState != nil {
|
||||
prev, _ = json.Marshal(prevState)
|
||||
}
|
||||
if newState != nil {
|
||||
next, _ = json.Marshal(newState)
|
||||
}
|
||||
tid := tournamentID
|
||||
e.audit.Record(ctx, audit.AuditEntry{
|
||||
TournamentID: &tid,
|
||||
Action: action,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
PreviousState: prev,
|
||||
NewState: next,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *BalanceEngine) broadcast(tournamentID, msgType string, data interface{}) {
|
||||
if e.hub == nil {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
e.hub.Broadcast(tournamentID, msgType, payload)
|
||||
}
|
||||
|
|
|
|||
576
internal/seating/balance_test.go
Normal file
576
internal/seating/balance_test.go
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
package seating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "github.com/tursodatabase/go-libsql"
|
||||
)
|
||||
|
||||
// testDB creates an in-memory SQLite database with the required schema.
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("libsql", "file::memory:?cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS tournaments (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
buyin_config_id INTEGER NOT NULL DEFAULT 1,
|
||||
chip_set_id INTEGER NOT NULL DEFAULT 1,
|
||||
blind_structure_id INTEGER NOT NULL DEFAULT 1,
|
||||
payout_structure_id INTEGER NOT NULL DEFAULT 1,
|
||||
min_players INTEGER NOT NULL DEFAULT 2,
|
||||
current_level INTEGER NOT NULL DEFAULT 0,
|
||||
clock_state TEXT NOT NULL DEFAULT 'stopped',
|
||||
clock_remaining_ns INTEGER NOT NULL DEFAULT 0,
|
||||
total_elapsed_ns INTEGER NOT NULL DEFAULT 0,
|
||||
hand_for_hand INTEGER NOT NULL DEFAULT 0,
|
||||
hand_for_hand_hand_number INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tables (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tournament_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
seat_count INTEGER NOT NULL DEFAULT 9,
|
||||
dealer_button_position INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
hand_completed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS players (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tournament_players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tournament_id TEXT NOT NULL,
|
||||
player_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'registered',
|
||||
seat_table_id INTEGER,
|
||||
seat_position INTEGER,
|
||||
current_chips INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(tournament_id, player_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS balance_suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tournament_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
from_table_id INTEGER NOT NULL,
|
||||
to_table_id INTEGER NOT NULL,
|
||||
player_id TEXT,
|
||||
from_seat INTEGER,
|
||||
to_seat INTEGER,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS table_blueprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
table_configs TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
tournament_id TEXT,
|
||||
timestamp INTEGER NOT NULL DEFAULT 0,
|
||||
operator_id TEXT NOT NULL DEFAULT 'system',
|
||||
action TEXT NOT NULL DEFAULT '',
|
||||
target_type TEXT NOT NULL DEFAULT '',
|
||||
target_id TEXT NOT NULL DEFAULT '',
|
||||
previous_state TEXT,
|
||||
new_state TEXT,
|
||||
metadata TEXT,
|
||||
undone_by TEXT
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("exec schema: %v: %s", err, stmt[:60])
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// seedTournament creates a test tournament.
|
||||
func seedTournament(t *testing.T, db *sql.DB, id string) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(`INSERT INTO tournaments (id, name) VALUES (?, ?)`, id, "Test Tournament")
|
||||
if err != nil {
|
||||
t.Fatalf("seed tournament: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTable creates a test table and returns its ID.
|
||||
func seedTable(t *testing.T, db *sql.DB, tournamentID, name string, seatCount int) int {
|
||||
t.Helper()
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO tables (tournament_id, name, seat_count, is_active) VALUES (?, ?, ?, 1)`,
|
||||
tournamentID, name, seatCount,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed table: %v", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return int(id)
|
||||
}
|
||||
|
||||
// seedPlayer creates a test player and returns their ID.
|
||||
func seedPlayer(t *testing.T, db *sql.DB, id, name string) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(`INSERT INTO players (id, name) VALUES (?, ?)`, id, name)
|
||||
if err != nil {
|
||||
t.Fatalf("seed player: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seatPlayer creates a tournament_players entry with a specific seat.
|
||||
func seatPlayer(t *testing.T, db *sql.DB, tournamentID, playerID string, tableID, seatPos int) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO tournament_players (tournament_id, player_id, status, seat_table_id, seat_position, current_chips)
|
||||
VALUES (?, ?, 'active', ?, ?, 10000)`,
|
||||
tournamentID, playerID, tableID, seatPos,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seat player: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Balance Tests ----------
|
||||
|
||||
func TestCheckBalance_TwoTables_Balanced(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
// 7 and 6 players: diff = 1, balanced
|
||||
for i := 1; i <= 7; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 6; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i+7))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
status, err := engine.CheckBalance(ctx, tournamentID)
|
||||
if err != nil {
|
||||
t.Fatalf("check balance: %v", err)
|
||||
}
|
||||
|
||||
if !status.IsBalanced {
|
||||
t.Errorf("expected balanced, got unbalanced with diff=%d", status.MaxDifference)
|
||||
}
|
||||
if status.MaxDifference != 1 {
|
||||
t.Errorf("expected max difference 1, got %d", status.MaxDifference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBalance_TwoTables_Unbalanced(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
// 8 and 6 players: diff = 2, unbalanced
|
||||
for i := 1; i <= 8; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 6; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i+8))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
status, err := engine.CheckBalance(ctx, tournamentID)
|
||||
if err != nil {
|
||||
t.Fatalf("check balance: %v", err)
|
||||
}
|
||||
|
||||
if status.IsBalanced {
|
||||
t.Error("expected unbalanced, got balanced")
|
||||
}
|
||||
if status.MaxDifference != 2 {
|
||||
t.Errorf("expected max difference 2, got %d", status.MaxDifference)
|
||||
}
|
||||
if status.NeedsMoves != 1 {
|
||||
t.Errorf("expected 1 move needed, got %d", status.NeedsMoves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBalance_SingleTable(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
seedPlayer(t, db, "p1", "Player 1")
|
||||
seatPlayer(t, db, tournamentID, "p1", tbl, 1)
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
status, err := engine.CheckBalance(ctx, tournamentID)
|
||||
if err != nil {
|
||||
t.Fatalf("check balance: %v", err)
|
||||
}
|
||||
|
||||
if !status.IsBalanced {
|
||||
t.Error("single table should always be balanced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMoves_PicksFromLargestTable(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
// 8 vs 6: need 1 move from tbl1 to tbl2
|
||||
for i := 1; i <= 8; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 6; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
suggestions, err := engine.SuggestMoves(ctx, tournamentID)
|
||||
if err != nil {
|
||||
t.Fatalf("suggest moves: %v", err)
|
||||
}
|
||||
|
||||
if len(suggestions) != 1 {
|
||||
t.Fatalf("expected 1 suggestion, got %d", len(suggestions))
|
||||
}
|
||||
|
||||
sugg := suggestions[0]
|
||||
if sugg.FromTableID != tbl1 {
|
||||
t.Errorf("expected move from table %d, got %d", tbl1, sugg.FromTableID)
|
||||
}
|
||||
if sugg.ToTableID != tbl2 {
|
||||
t.Errorf("expected move to table %d, got %d", tbl2, sugg.ToTableID)
|
||||
}
|
||||
if sugg.Status != "pending" {
|
||||
t.Errorf("expected status pending, got %s", sugg.Status)
|
||||
}
|
||||
if sugg.PlayerID == nil {
|
||||
t.Error("expected a player to be suggested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptSuggestion_StaleDetection(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
// Setup: 8 vs 6, generate suggestion
|
||||
for i := 1; i <= 8; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 6; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
suggestions, err := engine.SuggestMoves(ctx, tournamentID)
|
||||
if err != nil {
|
||||
t.Fatalf("suggest moves: %v", err)
|
||||
}
|
||||
if len(suggestions) == 0 {
|
||||
t.Fatal("expected at least 1 suggestion")
|
||||
}
|
||||
|
||||
suggID := suggestions[0].ID
|
||||
|
||||
// Simulate state change: move a player manually so tables are 7/7
|
||||
_, _ = db.Exec(
|
||||
`UPDATE tournament_players SET seat_table_id = ?, seat_position = 7
|
||||
WHERE tournament_id = ? AND player_id = 'p1_8'`,
|
||||
tbl2, tournamentID,
|
||||
)
|
||||
|
||||
// Accept should fail as stale
|
||||
err = engine.AcceptSuggestion(ctx, tournamentID, suggID, 8, 7)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stale suggestion")
|
||||
}
|
||||
|
||||
// Verify suggestion was expired
|
||||
var status string
|
||||
db.QueryRow(`SELECT status FROM balance_suggestions WHERE id = ?`, suggID).Scan(&status)
|
||||
if status != "expired" {
|
||||
t.Errorf("expected suggestion status 'expired', got '%s'", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateStaleSuggestions_BustDuringPending(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
// Setup: 8 vs 6
|
||||
for i := 1; i <= 8; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 6; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
suggestions, _ := engine.SuggestMoves(ctx, tournamentID)
|
||||
if len(suggestions) == 0 {
|
||||
t.Fatal("expected suggestions")
|
||||
}
|
||||
|
||||
// Simulate bust: player busts from tbl1, making it 7 vs 6 (balanced)
|
||||
_, _ = db.Exec(
|
||||
`UPDATE tournament_players SET status = 'busted', seat_table_id = NULL, seat_position = NULL
|
||||
WHERE tournament_id = ? AND player_id = 'p1_8'`,
|
||||
tournamentID,
|
||||
)
|
||||
|
||||
// Invalidate stale suggestions
|
||||
err := engine.InvalidateStaleSuggestions(ctx, tournamentID)
|
||||
if err != nil {
|
||||
t.Fatalf("invalidate: %v", err)
|
||||
}
|
||||
|
||||
// All pending suggestions should be expired
|
||||
var pendingCount int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM balance_suggestions WHERE tournament_id = ? AND status = 'pending'`,
|
||||
tournamentID).Scan(&pendingCount)
|
||||
if pendingCount != 0 {
|
||||
t.Errorf("expected 0 pending suggestions after invalidation, got %d", pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBalance_ThreeTables_NeedsTwoMoves(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
tbl3 := seedTable(t, db, tournamentID, "Table 3", 9)
|
||||
|
||||
// 9, 5, 4 players across 3 tables -> need to redistribute to ~6,6,6
|
||||
for i := 1; i <= 9; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
for i := 1; i <= 4; i++ {
|
||||
pid := fmt.Sprintf("p3_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 3-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl3, i)
|
||||
}
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
status, err := engine.CheckBalance(ctx, tournamentID)
|
||||
if err != nil {
|
||||
t.Fatalf("check balance: %v", err)
|
||||
}
|
||||
|
||||
if status.IsBalanced {
|
||||
t.Error("expected unbalanced")
|
||||
}
|
||||
if status.MaxDifference != 5 {
|
||||
t.Errorf("expected max difference 5, got %d", status.MaxDifference)
|
||||
}
|
||||
// 18 total / 3 tables = 6 each. Table 1 has 3 surplus.
|
||||
if status.NeedsMoves != 3 {
|
||||
t.Errorf("expected 3 moves needed, got %d", status.NeedsMoves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelSuggestion(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
for i := 1; i <= 8; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 6; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
|
||||
engine := NewBalanceEngine(db, nil, nil)
|
||||
suggestions, _ := engine.SuggestMoves(ctx, tournamentID)
|
||||
if len(suggestions) == 0 {
|
||||
t.Fatal("expected suggestions")
|
||||
}
|
||||
|
||||
err := engine.CancelSuggestion(ctx, tournamentID, suggestions[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel suggestion: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
db.QueryRow(`SELECT status FROM balance_suggestions WHERE id = ?`, suggestions[0].ID).Scan(&status)
|
||||
if status != "cancelled" {
|
||||
t.Errorf("expected status 'cancelled', got '%s'", status)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Auto-seat Tests ----------
|
||||
|
||||
func TestAutoSeat_FillsEvenly(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 6)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 6)
|
||||
|
||||
// Seat 3 at table 1, 1 at table 2
|
||||
for i := 1; i <= 3; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
seedPlayer(t, db, "p2_1", "Player 2-1")
|
||||
seatPlayer(t, db, tournamentID, "p2_1", tbl2, 1)
|
||||
|
||||
// Auto-seat should pick table 2 (fewest players)
|
||||
ts := NewTableService(db, nil, nil, nil)
|
||||
seedPlayer(t, db, "new_player", "New Player")
|
||||
_, _ = db.Exec(`INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) VALUES (?, ?, 'registered', 0)`,
|
||||
tournamentID, "new_player")
|
||||
|
||||
assignment, err := ts.AutoAssignSeat(ctx, tournamentID, "new_player")
|
||||
if err != nil {
|
||||
t.Fatalf("auto assign: %v", err)
|
||||
}
|
||||
|
||||
if assignment.TableID != tbl2 {
|
||||
t.Errorf("expected assignment to table %d (fewer players), got %d", tbl2, assignment.TableID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Dealer Button Tests ----------
|
||||
|
||||
func TestDealerButton_AdvancesSkippingEmpty(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
|
||||
// Seat players at positions 1, 3, 7 (skip 2, 4-6, 8-9)
|
||||
seedPlayer(t, db, "p1", "Player 1")
|
||||
seatPlayer(t, db, tournamentID, "p1", tbl, 1)
|
||||
seedPlayer(t, db, "p3", "Player 3")
|
||||
seatPlayer(t, db, tournamentID, "p3", tbl, 3)
|
||||
seedPlayer(t, db, "p7", "Player 7")
|
||||
seatPlayer(t, db, tournamentID, "p7", tbl, 7)
|
||||
|
||||
ts := NewTableService(db, nil, nil, nil)
|
||||
|
||||
// Set button at position 1
|
||||
if err := ts.SetDealerButton(ctx, tournamentID, tbl, 1); err != nil {
|
||||
t.Fatalf("set button: %v", err)
|
||||
}
|
||||
|
||||
// Advance should skip to position 3 (next occupied)
|
||||
if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil {
|
||||
t.Fatalf("advance button: %v", err)
|
||||
}
|
||||
|
||||
var btnPos sql.NullInt64
|
||||
db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos)
|
||||
if !btnPos.Valid || int(btnPos.Int64) != 3 {
|
||||
t.Errorf("expected button at position 3, got %v", btnPos)
|
||||
}
|
||||
|
||||
// Advance again: should skip to position 7
|
||||
if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil {
|
||||
t.Fatalf("advance button: %v", err)
|
||||
}
|
||||
|
||||
db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos)
|
||||
if !btnPos.Valid || int(btnPos.Int64) != 7 {
|
||||
t.Errorf("expected button at position 7, got %v", btnPos)
|
||||
}
|
||||
|
||||
// Advance again: should wrap to position 1
|
||||
if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil {
|
||||
t.Fatalf("advance button: %v", err)
|
||||
}
|
||||
|
||||
db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos)
|
||||
if !btnPos.Valid || int(btnPos.Int64) != 1 {
|
||||
t.Errorf("expected button at position 1 (wrap), got %v", btnPos)
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,288 @@
|
|||
package seating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
|
||||
"github.com/felt-app/felt/internal/audit"
|
||||
"github.com/felt-app/felt/internal/server/ws"
|
||||
)
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
// BreakTableResult shows all moves from breaking a table.
|
||||
type BreakTableResult struct {
|
||||
BrokenTableID int `json:"broken_table_id"`
|
||||
BrokenTableName string `json:"broken_table_name"`
|
||||
Moves []BreakTableMove `json:"moves"`
|
||||
}
|
||||
|
||||
// BreakTableMove describes one player's reassignment when a table is broken.
|
||||
type BreakTableMove struct {
|
||||
PlayerID string `json:"player_id"`
|
||||
PlayerName string `json:"player_name"`
|
||||
ToTableID int `json:"to_table_id"`
|
||||
ToTableName string `json:"to_table_name"`
|
||||
ToSeat int `json:"to_seat"`
|
||||
}
|
||||
|
||||
// ---------- Service ----------
|
||||
|
||||
// BreakTableService handles breaking a table and redistributing players.
|
||||
type BreakTableService struct {
|
||||
db *sql.DB
|
||||
audit *audit.Trail
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewBreakTableService creates a new BreakTableService.
|
||||
func NewBreakTableService(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub) *BreakTableService {
|
||||
return &BreakTableService{
|
||||
db: db,
|
||||
audit: auditTrail,
|
||||
hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Break Table ----------
|
||||
|
||||
// BreakTable dissolves a table and distributes its players evenly across
|
||||
// remaining active tables. This is fully automatic per CONTEXT.md -- the
|
||||
// moves are applied immediately and the result is informational.
|
||||
func (s *BreakTableService) BreakTable(ctx context.Context, tournamentID string, tableID int) (*BreakTableResult, error) {
|
||||
// Load the table being broken
|
||||
var tableName string
|
||||
var isActive int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT name, is_active FROM tables WHERE id = ? AND tournament_id = ?`,
|
||||
tableID, tournamentID,
|
||||
).Scan(&tableName, &isActive)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("table not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load table: %w", err)
|
||||
}
|
||||
if isActive == 0 {
|
||||
return nil, fmt.Errorf("table is already inactive")
|
||||
}
|
||||
|
||||
// Load all players at the table being broken
|
||||
type playerInfo struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
playerRows, err := s.db.QueryContext(ctx,
|
||||
`SELECT tp.player_id, p.name
|
||||
FROM tournament_players tp
|
||||
JOIN players p ON p.id = tp.player_id
|
||||
WHERE tp.tournament_id = ? AND tp.seat_table_id = ?
|
||||
AND tp.status IN ('registered', 'active')
|
||||
ORDER BY tp.seat_position`,
|
||||
tournamentID, tableID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load players: %w", err)
|
||||
}
|
||||
defer playerRows.Close()
|
||||
|
||||
var players []playerInfo
|
||||
for playerRows.Next() {
|
||||
var pi playerInfo
|
||||
if err := playerRows.Scan(&pi.id, &pi.name); err != nil {
|
||||
return nil, fmt.Errorf("scan player: %w", err)
|
||||
}
|
||||
players = append(players, pi)
|
||||
}
|
||||
if err := playerRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load remaining active tables (exclude the one being broken)
|
||||
type destTable struct {
|
||||
id int
|
||||
name string
|
||||
seatCount int
|
||||
players int
|
||||
}
|
||||
destRows, err := s.db.QueryContext(ctx,
|
||||
`SELECT t.id, t.name, t.seat_count,
|
||||
(SELECT COUNT(*) FROM tournament_players tp
|
||||
WHERE tp.seat_table_id = t.id AND tp.tournament_id = t.tournament_id
|
||||
AND tp.status IN ('registered', 'active')) AS player_count
|
||||
FROM tables t
|
||||
WHERE t.tournament_id = ? AND t.is_active = 1 AND t.id != ?
|
||||
ORDER BY player_count ASC, t.name ASC`,
|
||||
tournamentID, tableID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load destination tables: %w", err)
|
||||
}
|
||||
defer destRows.Close()
|
||||
|
||||
var dests []destTable
|
||||
for destRows.Next() {
|
||||
var dt destTable
|
||||
if err := destRows.Scan(&dt.id, &dt.name, &dt.seatCount, &dt.players); err != nil {
|
||||
return nil, fmt.Errorf("scan dest table: %w", err)
|
||||
}
|
||||
dests = append(dests, dt)
|
||||
}
|
||||
if err := destRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(dests) == 0 {
|
||||
return nil, fmt.Errorf("no remaining active tables to distribute players to")
|
||||
}
|
||||
|
||||
// Build occupied seat sets for destination tables
|
||||
occupiedSeats := make(map[int]map[int]bool)
|
||||
for _, dt := range dests {
|
||||
occupiedSeats[dt.id] = make(map[int]bool)
|
||||
seatRows, err := s.db.QueryContext(ctx,
|
||||
`SELECT seat_position FROM tournament_players
|
||||
WHERE tournament_id = ? AND seat_table_id = ? AND seat_position IS NOT NULL
|
||||
AND status IN ('registered', 'active')`,
|
||||
tournamentID, dt.id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get occupied seats for table %d: %w", dt.id, err)
|
||||
}
|
||||
for seatRows.Next() {
|
||||
var pos int
|
||||
seatRows.Scan(&pos)
|
||||
occupiedSeats[dt.id][pos] = true
|
||||
}
|
||||
seatRows.Close()
|
||||
}
|
||||
|
||||
// Distribute players evenly: for each player, assign to the table with fewest players
|
||||
var moves []BreakTableMove
|
||||
|
||||
for _, pi := range players {
|
||||
// Sort destinations by current player count (ascending)
|
||||
sort.Slice(dests, func(i, j int) bool {
|
||||
if dests[i].players == dests[j].players {
|
||||
return dests[i].id < dests[j].id
|
||||
}
|
||||
return dests[i].players < dests[j].players
|
||||
})
|
||||
|
||||
// Find a table with available seats
|
||||
assigned := false
|
||||
for idx := range dests {
|
||||
dt := &dests[idx]
|
||||
if dt.players >= dt.seatCount {
|
||||
continue // table is full
|
||||
}
|
||||
|
||||
// Pick a random empty seat
|
||||
var emptySeats []int
|
||||
for pos := 1; pos <= dt.seatCount; pos++ {
|
||||
if !occupiedSeats[dt.id][pos] {
|
||||
emptySeats = append(emptySeats, pos)
|
||||
}
|
||||
}
|
||||
if len(emptySeats) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seatIdx := 0
|
||||
if len(emptySeats) > 1 {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(emptySeats))))
|
||||
seatIdx = int(n.Int64())
|
||||
}
|
||||
seat := emptySeats[seatIdx]
|
||||
|
||||
// Execute the move
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE tournament_players SET seat_table_id = ?, seat_position = ?, updated_at = unixepoch()
|
||||
WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`,
|
||||
dt.id, seat, tournamentID, pi.id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("move player %s: %w", pi.id, err)
|
||||
}
|
||||
|
||||
occupiedSeats[dt.id][seat] = true
|
||||
dt.players++
|
||||
|
||||
moves = append(moves, BreakTableMove{
|
||||
PlayerID: pi.id,
|
||||
PlayerName: pi.name,
|
||||
ToTableID: dt.id,
|
||||
ToTableName: dt.name,
|
||||
ToSeat: seat,
|
||||
})
|
||||
|
||||
assigned = true
|
||||
break
|
||||
}
|
||||
|
||||
if !assigned {
|
||||
return nil, fmt.Errorf("no available seat for player %s (%s)", pi.id, pi.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Deactivate the broken table
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`UPDATE tables SET is_active = 0, updated_at = unixepoch()
|
||||
WHERE id = ? AND tournament_id = ?`,
|
||||
tableID, tournamentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deactivate table: %w", err)
|
||||
}
|
||||
|
||||
result := &BreakTableResult{
|
||||
BrokenTableID: tableID,
|
||||
BrokenTableName: tableName,
|
||||
Moves: moves,
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, tournamentID, audit.ActionSeatBreakTable, "table", fmt.Sprintf("%d", tableID), nil, result)
|
||||
s.broadcast(tournamentID, "table.broken", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
func (s *BreakTableService) recordAudit(ctx context.Context, tournamentID, action, targetType, targetID string, prevState, newState interface{}) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
var prev, next json.RawMessage
|
||||
if prevState != nil {
|
||||
prev, _ = json.Marshal(prevState)
|
||||
}
|
||||
if newState != nil {
|
||||
next, _ = json.Marshal(newState)
|
||||
}
|
||||
tid := tournamentID
|
||||
s.audit.Record(ctx, audit.AuditEntry{
|
||||
TournamentID: &tid,
|
||||
Action: action,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
PreviousState: prev,
|
||||
NewState: next,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BreakTableService) broadcast(tournamentID, msgType string, data interface{}) {
|
||||
if s.hub == nil {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.hub.Broadcast(tournamentID, msgType, payload)
|
||||
}
|
||||
|
|
|
|||
208
internal/seating/breaktable_test.go
Normal file
208
internal/seating/breaktable_test.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package seating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBreakTable_DistributesEvenly(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
tbl3 := seedTable(t, db, tournamentID, "Table 3", 9)
|
||||
|
||||
// Table 1: 6 players (will be broken)
|
||||
// Table 2: 5 players
|
||||
// Table 3: 5 players
|
||||
for i := 1; i <= 6; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
pid := fmt.Sprintf("p3_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 3-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl3, i)
|
||||
}
|
||||
|
||||
svc := NewBreakTableService(db, nil, nil)
|
||||
result, err := svc.BreakTable(ctx, tournamentID, tbl1)
|
||||
if err != nil {
|
||||
t.Fatalf("break table: %v", err)
|
||||
}
|
||||
|
||||
if result.BrokenTableID != tbl1 {
|
||||
t.Errorf("expected broken table id %d, got %d", tbl1, result.BrokenTableID)
|
||||
}
|
||||
if len(result.Moves) != 6 {
|
||||
t.Fatalf("expected 6 moves, got %d", len(result.Moves))
|
||||
}
|
||||
|
||||
// Count how many went to each table: should be 3 each (5+3=8, 5+3=8)
|
||||
movesToTable := make(map[int]int)
|
||||
for _, m := range result.Moves {
|
||||
movesToTable[m.ToTableID]++
|
||||
}
|
||||
|
||||
for tid, count := range movesToTable {
|
||||
if count < 2 || count > 4 {
|
||||
t.Errorf("table %d got %d players, expected roughly even distribution", tid, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify table 1 is deactivated
|
||||
var isActive int
|
||||
db.QueryRow(`SELECT is_active FROM tables WHERE id = ?`, tbl1).Scan(&isActive)
|
||||
if isActive != 0 {
|
||||
t.Error("broken table should be deactivated")
|
||||
}
|
||||
|
||||
// Verify all players are seated at other tables
|
||||
var unseatedCount int
|
||||
db.QueryRow(
|
||||
`SELECT COUNT(*) FROM tournament_players
|
||||
WHERE tournament_id = ? AND status = 'active' AND seat_table_id IS NULL`,
|
||||
tournamentID,
|
||||
).Scan(&unseatedCount)
|
||||
if unseatedCount != 0 {
|
||||
t.Errorf("expected 0 unseated players, got %d", unseatedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakTable_OddPlayerCount(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
tbl3 := seedTable(t, db, tournamentID, "Table 3", 9)
|
||||
|
||||
// Table 1: 5 players (will be broken)
|
||||
// Table 2: 4 players
|
||||
// Table 3: 3 players
|
||||
for i := 1; i <= 5; i++ {
|
||||
pid := fmt.Sprintf("p1_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl1, i)
|
||||
}
|
||||
for i := 1; i <= 4; i++ {
|
||||
pid := fmt.Sprintf("p2_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl2, i)
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
pid := fmt.Sprintf("p3_%d", i)
|
||||
seedPlayer(t, db, pid, fmt.Sprintf("Player 3-%d", i))
|
||||
seatPlayer(t, db, tournamentID, pid, tbl3, i)
|
||||
}
|
||||
|
||||
svc := NewBreakTableService(db, nil, nil)
|
||||
result, err := svc.BreakTable(ctx, tournamentID, tbl1)
|
||||
if err != nil {
|
||||
t.Fatalf("break table: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Moves) != 5 {
|
||||
t.Fatalf("expected 5 moves, got %d", len(result.Moves))
|
||||
}
|
||||
|
||||
// Count final player counts at tbl2 and tbl3
|
||||
var count2, count3 int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND seat_table_id = ? AND status = 'active'`,
|
||||
tournamentID, tbl2).Scan(&count2)
|
||||
db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND seat_table_id = ? AND status = 'active'`,
|
||||
tournamentID, tbl3).Scan(&count3)
|
||||
|
||||
// 12 total players across 2 tables -> 6 each
|
||||
if count2+count3 != 12 {
|
||||
t.Errorf("expected 12 total, got %d", count2+count3)
|
||||
}
|
||||
// Difference should be at most 1
|
||||
diff := count2 - count3
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 1 {
|
||||
t.Errorf("expected tables balanced to within 1, got diff=%d (table2=%d, table3=%d)", diff, count2, count3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakTable_DeactivatesTable(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl1 := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
tbl2 := seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
// 2 players at each table
|
||||
seedPlayer(t, db, "p1", "P1")
|
||||
seatPlayer(t, db, tournamentID, "p1", tbl1, 1)
|
||||
seedPlayer(t, db, "p2", "P2")
|
||||
seatPlayer(t, db, tournamentID, "p2", tbl1, 2)
|
||||
seedPlayer(t, db, "p3", "P3")
|
||||
seatPlayer(t, db, tournamentID, "p3", tbl2, 1)
|
||||
seedPlayer(t, db, "p4", "P4")
|
||||
seatPlayer(t, db, tournamentID, "p4", tbl2, 2)
|
||||
|
||||
svc := NewBreakTableService(db, nil, nil)
|
||||
_, err := svc.BreakTable(ctx, tournamentID, tbl1)
|
||||
if err != nil {
|
||||
t.Fatalf("break table: %v", err)
|
||||
}
|
||||
|
||||
var isActive int
|
||||
db.QueryRow(`SELECT is_active FROM tables WHERE id = ?`, tbl1).Scan(&isActive)
|
||||
if isActive != 0 {
|
||||
t.Error("broken table should be deactivated (is_active = 0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakTable_InactiveTableReturnsError(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
_ = seedTable(t, db, tournamentID, "Table 2", 9)
|
||||
|
||||
// Deactivate the table first
|
||||
db.Exec(`UPDATE tables SET is_active = 0 WHERE id = ?`, tbl)
|
||||
|
||||
svc := NewBreakTableService(db, nil, nil)
|
||||
_, err := svc.BreakTable(ctx, tournamentID, tbl)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for inactive table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakTable_NoDestinationTablesReturnsError(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
tournamentID := "t1"
|
||||
seedTournament(t, db, tournamentID)
|
||||
|
||||
tbl := seedTable(t, db, tournamentID, "Table 1", 9)
|
||||
seedPlayer(t, db, "p1", "P1")
|
||||
seatPlayer(t, db, tournamentID, "p1", tbl, 1)
|
||||
|
||||
svc := NewBreakTableService(db, nil, nil)
|
||||
_, err := svc.BreakTable(ctx, tournamentID, tbl)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no destination tables")
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,11 @@ func NewTableService(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub, clockRegi
|
|||
}
|
||||
}
|
||||
|
||||
// DB returns the underlying database connection for cross-service use.
|
||||
func (s *TableService) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// ---------- Table CRUD ----------
|
||||
|
||||
// CreateTable creates a new table for a tournament.
|
||||
|
|
|
|||
536
internal/server/routes/tables.go
Normal file
536
internal/server/routes/tables.go
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/felt-app/felt/internal/seating"
|
||||
"github.com/felt-app/felt/internal/server/middleware"
|
||||
)
|
||||
|
||||
// TableHandler handles table, seating, balancing, and break table API routes.
|
||||
type TableHandler struct {
|
||||
tables *seating.TableService
|
||||
balance *seating.BalanceEngine
|
||||
breakTable *seating.BreakTableService
|
||||
blueprints *seating.BlueprintService
|
||||
}
|
||||
|
||||
// NewTableHandler creates a new table route handler.
|
||||
func NewTableHandler(
|
||||
tables *seating.TableService,
|
||||
balance *seating.BalanceEngine,
|
||||
breakTable *seating.BreakTableService,
|
||||
blueprints *seating.BlueprintService,
|
||||
) *TableHandler {
|
||||
return &TableHandler{
|
||||
tables: tables,
|
||||
balance: balance,
|
||||
breakTable: breakTable,
|
||||
blueprints: blueprints,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers table and seating routes on the given router.
|
||||
func (h *TableHandler) RegisterRoutes(r chi.Router) {
|
||||
// Tournament-scoped routes
|
||||
r.Route("/tournaments/{id}", func(r chi.Router) {
|
||||
// Read-only (any authenticated user)
|
||||
r.Get("/tables", h.handleGetTables)
|
||||
r.Get("/balance", h.handleCheckBalance)
|
||||
|
||||
// Mutation routes (admin or floor)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireRole(middleware.RoleFloor))
|
||||
|
||||
// Table management
|
||||
r.Post("/tables", h.handleCreateTable)
|
||||
r.Post("/tables/from-blueprint", h.handleCreateFromBlueprint)
|
||||
r.Post("/tables/save-blueprint", h.handleSaveBlueprint)
|
||||
r.Put("/tables/{tableId}", h.handleUpdateTable)
|
||||
r.Delete("/tables/{tableId}", h.handleDeactivateTable)
|
||||
|
||||
// Seating
|
||||
r.Post("/players/{playerId}/auto-seat", h.handleAutoSeat)
|
||||
r.Post("/players/{playerId}/seat", h.handleAssignSeat)
|
||||
r.Post("/seats/swap", h.handleSwapSeats)
|
||||
|
||||
// Balancing
|
||||
r.Post("/balance/suggest", h.handleSuggestMoves)
|
||||
r.Post("/balance/suggestions/{suggId}/accept", h.handleAcceptSuggestion)
|
||||
r.Post("/balance/suggestions/{suggId}/cancel", h.handleCancelSuggestion)
|
||||
|
||||
// Break Table
|
||||
r.Post("/tables/{tableId}/break", h.handleBreakTable)
|
||||
|
||||
// Dealer Button
|
||||
r.Post("/tables/{tableId}/button", h.handleSetButton)
|
||||
r.Post("/tables/{tableId}/button/advance", h.handleAdvanceButton)
|
||||
|
||||
// Hand-for-Hand
|
||||
r.Post("/hand-for-hand", h.handleSetHandForHand)
|
||||
r.Post("/tables/{tableId}/hand-complete", h.handleTableHandComplete)
|
||||
})
|
||||
})
|
||||
|
||||
// Venue-level blueprint routes
|
||||
r.Route("/blueprints", func(r chi.Router) {
|
||||
r.Get("/", h.handleListBlueprints)
|
||||
r.Get("/{id}", h.handleGetBlueprint)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireRole(middleware.RoleAdmin))
|
||||
r.Post("/", h.handleCreateBlueprint)
|
||||
r.Put("/{id}", h.handleUpdateBlueprint)
|
||||
r.Delete("/{id}", h.handleDeleteBlueprint)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Table Handlers ----------
|
||||
|
||||
func (h *TableHandler) handleGetTables(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
tables, err := h.tables.GetTables(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tables)
|
||||
}
|
||||
|
||||
type createTableRequest struct {
|
||||
Name string `json:"name"`
|
||||
SeatCount int `json:"seat_count"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleCreateTable(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
|
||||
var req createTableRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
table, err := h.tables.CreateTable(r.Context(), tournamentID, req.Name, req.SeatCount)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, table)
|
||||
}
|
||||
|
||||
type fromBlueprintRequest struct {
|
||||
BlueprintID int `json:"blueprint_id"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleCreateFromBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
|
||||
var req fromBlueprintRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tables, err := h.blueprints.CreateTablesFromBlueprint(r.Context(), h.tables.DB(), tournamentID, req.BlueprintID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, tables)
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid table id")
|
||||
return
|
||||
}
|
||||
|
||||
var req createTableRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.UpdateTable(r.Context(), tournamentID, tableID, req.Name, req.SeatCount); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleDeactivateTable(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid table id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.DeactivateTable(r.Context(), tournamentID, tableID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deactivated"})
|
||||
}
|
||||
|
||||
// ---------- Seating Handlers ----------
|
||||
|
||||
func (h *TableHandler) handleAutoSeat(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
playerID := chi.URLParam(r, "playerId")
|
||||
|
||||
assignment, err := h.tables.AutoAssignSeat(r.Context(), tournamentID, playerID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, assignment)
|
||||
}
|
||||
|
||||
type assignSeatRequest struct {
|
||||
TableID int `json:"table_id"`
|
||||
SeatPosition int `json:"seat_position"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleAssignSeat(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
playerID := chi.URLParam(r, "playerId")
|
||||
|
||||
var req assignSeatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.AssignSeat(r.Context(), tournamentID, playerID, req.TableID, req.SeatPosition); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "seated"})
|
||||
}
|
||||
|
||||
type swapSeatsRequest struct {
|
||||
Player1ID string `json:"player1_id"`
|
||||
Player2ID string `json:"player2_id"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleSwapSeats(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
|
||||
var req swapSeatsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.SwapSeats(r.Context(), tournamentID, req.Player1ID, req.Player2ID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "swapped"})
|
||||
}
|
||||
|
||||
// ---------- Balance Handlers ----------
|
||||
|
||||
func (h *TableHandler) handleCheckBalance(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
status, err := h.balance.CheckBalance(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleSuggestMoves(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
suggestions, err := h.balance.SuggestMoves(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"suggestions": suggestions,
|
||||
})
|
||||
}
|
||||
|
||||
type acceptSuggestionRequest struct {
|
||||
FromSeat int `json:"from_seat"`
|
||||
ToSeat int `json:"to_seat"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleAcceptSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
suggID, err := strconv.Atoi(chi.URLParam(r, "suggId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid suggestion id")
|
||||
return
|
||||
}
|
||||
|
||||
var req acceptSuggestionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.balance.AcceptSuggestion(r.Context(), tournamentID, suggID, req.FromSeat, req.ToSeat); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"})
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleCancelSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
suggID, err := strconv.Atoi(chi.URLParam(r, "suggId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid suggestion id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.balance.CancelSuggestion(r.Context(), tournamentID, suggID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
|
||||
}
|
||||
|
||||
// ---------- Break Table Handler ----------
|
||||
|
||||
func (h *TableHandler) handleBreakTable(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid table id")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.breakTable.BreakTable(r.Context(), tournamentID, tableID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ---------- Dealer Button Handlers ----------
|
||||
|
||||
type setButtonRequest struct {
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleSetButton(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid table id")
|
||||
return
|
||||
}
|
||||
|
||||
var req setButtonRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.SetDealerButton(r.Context(), tournamentID, tableID, req.Position); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "button_set"})
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleAdvanceButton(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid table id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.AdvanceDealerButton(r.Context(), tournamentID, tableID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "button_advanced"})
|
||||
}
|
||||
|
||||
// ---------- Hand-for-Hand Handlers ----------
|
||||
|
||||
type handForHandRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleSetHandForHand(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
|
||||
var req handForHandRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.SetHandForHand(r.Context(), tournamentID, req.Enabled); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": fmt.Sprintf("hand_for_hand_%s", boolStr(req.Enabled)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleTableHandComplete(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid table id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tables.TableHandComplete(r.Context(), tournamentID, tableID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "hand_complete"})
|
||||
}
|
||||
|
||||
// ---------- Blueprint Handlers ----------
|
||||
|
||||
func (h *TableHandler) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
|
||||
blueprints, err := h.blueprints.ListBlueprints(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blueprints)
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleGetBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid blueprint id")
|
||||
return
|
||||
}
|
||||
|
||||
bp, err := h.blueprints.GetBlueprint(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bp)
|
||||
}
|
||||
|
||||
type createBlueprintRequest struct {
|
||||
Name string `json:"name"`
|
||||
TableConfigs []seating.BlueprintTableConfig `json:"table_configs"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleCreateBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
var req createBlueprintRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bp, err := h.blueprints.CreateBlueprint(r.Context(), req.Name, req.TableConfigs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, bp)
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleUpdateBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid blueprint id")
|
||||
return
|
||||
}
|
||||
|
||||
var req createBlueprintRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bp := seating.Blueprint{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
TableConfigs: req.TableConfigs,
|
||||
}
|
||||
if err := h.blueprints.UpdateBlueprint(r.Context(), bp); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid blueprint id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.blueprints.DeleteBlueprint(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
type saveBlueprintRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *TableHandler) handleSaveBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
|
||||
var req saveBlueprintRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bp, err := h.blueprints.SaveBlueprintFromTournament(r.Context(), h.tables.DB(), tournamentID, req.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, bp)
|
||||
}
|
||||
|
||||
// boolStr returns "enabled" or "disabled" for a bool value.
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "enabled"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue