- 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>
675 lines
19 KiB
Go
675 lines
19 KiB
Go
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)
|
|
}
|