- 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>
536 lines
15 KiB
Go
536 lines
15 KiB
Go
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"
|
|
}
|