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:
Mikkel Georgsen 2026-03-01 04:24:52 +01:00
parent 3b571c36dd
commit 2d3cb0ac9e
6 changed files with 2286 additions and 0 deletions

View file

@ -1 +1,675 @@
package seating 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)
}

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

View file

@ -1 +1,288 @@
package seating 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)
}

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

View file

@ -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 ---------- // ---------- Table CRUD ----------
// CreateTable creates a new table for a tournament. // CreateTable creates a new table for a tournament.

View 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"
}