- TournamentService with create-from-template, start, pause, resume, end, cancel - Auto-close when 1 player remains, with CheckAutoClose hook - TournamentState aggregation for WebSocket full-state snapshot - ActivityEntry feed converting audit entries to human-readable items - MultiManager with ListActiveTournaments for lobby view (MULTI-01/02) - ICM calculator: exact Malmuth-Harville for <=10, Monte Carlo for 11+ (FIN-11) - ChopEngine with ICM, chip-chop, even-chop, custom, and partial-chop deals - DealProposal workflow: propose, confirm, cancel with audit trail - Tournament API routes for lifecycle, state, activity, and deal endpoints - deal_proposals migration (007) for storing chop proposals Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
323 lines
9.2 KiB
Go
323 lines
9.2 KiB
Go
package routes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/felt-app/felt/internal/financial"
|
|
"github.com/felt-app/felt/internal/server/middleware"
|
|
"github.com/felt-app/felt/internal/tournament"
|
|
)
|
|
|
|
// TournamentHandler handles tournament lifecycle, state, and deal API routes.
|
|
type TournamentHandler struct {
|
|
service *tournament.Service
|
|
multi *tournament.MultiManager
|
|
chop *financial.ChopEngine
|
|
}
|
|
|
|
// NewTournamentHandler creates a new tournament route handler.
|
|
func NewTournamentHandler(
|
|
service *tournament.Service,
|
|
multi *tournament.MultiManager,
|
|
chop *financial.ChopEngine,
|
|
) *TournamentHandler {
|
|
return &TournamentHandler{
|
|
service: service,
|
|
multi: multi,
|
|
chop: chop,
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers tournament routes on the given router.
|
|
func (h *TournamentHandler) RegisterRoutes(r chi.Router) {
|
|
r.Route("/tournaments", func(r chi.Router) {
|
|
// Read-only (any authenticated user)
|
|
r.Get("/", h.handleListTournaments)
|
|
r.Get("/active", h.handleListActive)
|
|
|
|
// Mutations (admin role for create)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireRole(middleware.RoleAdmin))
|
|
r.Post("/", h.handleCreateFromTemplate)
|
|
r.Post("/manual", h.handleCreateManual)
|
|
})
|
|
|
|
// Tournament-scoped routes
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
// Read-only
|
|
r.Get("/", h.handleGetTournament)
|
|
r.Get("/state", h.handleGetState)
|
|
r.Get("/activity", h.handleGetActivity)
|
|
|
|
// Deal/chop read-only
|
|
r.Get("/deal/proposals", h.handleListDealProposals)
|
|
|
|
// Mutations (floor or admin)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireRole(middleware.RoleFloor))
|
|
r.Post("/start", h.handleStart)
|
|
r.Post("/pause", h.handlePause)
|
|
r.Post("/resume", h.handleResume)
|
|
r.Post("/cancel", h.handleCancel)
|
|
|
|
// Deal/chop mutations
|
|
r.Post("/deal/propose", h.handleProposeDeal)
|
|
r.Post("/deal/proposals/{proposalId}/confirm", h.handleConfirmDeal)
|
|
r.Post("/deal/proposals/{proposalId}/cancel", h.handleCancelDeal)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
// ---------- Tournament CRUD ----------
|
|
|
|
type createFromTemplateRequest struct {
|
|
TemplateID int64 `json:"template_id"`
|
|
Name string `json:"name,omitempty"`
|
|
Overrides tournament.TournamentOverrides `json:"overrides"`
|
|
}
|
|
|
|
func (h *TournamentHandler) handleCreateFromTemplate(w http.ResponseWriter, r *http.Request) {
|
|
var req createFromTemplateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if req.TemplateID == 0 {
|
|
writeError(w, http.StatusBadRequest, "template_id is required")
|
|
return
|
|
}
|
|
|
|
// Allow name at top level or in overrides
|
|
if req.Name != "" && req.Overrides.Name == "" {
|
|
req.Overrides.Name = req.Name
|
|
}
|
|
|
|
t, err := h.service.CreateFromTemplate(r.Context(), req.TemplateID, req.Overrides)
|
|
if err != nil {
|
|
status := http.StatusBadRequest
|
|
if err == tournament.ErrTemplateNotFound {
|
|
status = http.StatusNotFound
|
|
}
|
|
writeError(w, status, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, t)
|
|
}
|
|
|
|
func (h *TournamentHandler) handleCreateManual(w http.ResponseWriter, r *http.Request) {
|
|
var config tournament.TournamentConfig
|
|
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
|
|
t, err := h.service.CreateManual(r.Context(), config)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, t)
|
|
}
|
|
|
|
func (h *TournamentHandler) handleListTournaments(w http.ResponseWriter, r *http.Request) {
|
|
summaries, err := h.multi.ListAllTournaments(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, summaries)
|
|
}
|
|
|
|
func (h *TournamentHandler) handleListActive(w http.ResponseWriter, r *http.Request) {
|
|
summaries, err := h.multi.ListActiveTournaments(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, summaries)
|
|
}
|
|
|
|
func (h *TournamentHandler) handleGetTournament(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
detail, err := h.service.GetTournament(r.Context(), id)
|
|
if err != nil {
|
|
if err == tournament.ErrTournamentNotFound {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, detail)
|
|
}
|
|
|
|
func (h *TournamentHandler) handleGetState(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
state, err := h.service.GetTournamentState(r.Context(), id)
|
|
if err != nil {
|
|
if err == tournament.ErrTournamentNotFound {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, state)
|
|
}
|
|
|
|
func (h *TournamentHandler) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 20
|
|
if limitStr != "" {
|
|
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 100 {
|
|
limit = v
|
|
}
|
|
}
|
|
|
|
activity := h.service.BuildActivityFeed(r.Context(), id, limit)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"activity": activity,
|
|
})
|
|
}
|
|
|
|
// ---------- Tournament Lifecycle ----------
|
|
|
|
func (h *TournamentHandler) handleStart(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.service.StartTournament(r.Context(), id); err != nil {
|
|
status := http.StatusBadRequest
|
|
if err == tournament.ErrTournamentNotFound {
|
|
status = http.StatusNotFound
|
|
}
|
|
writeError(w, status, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
|
}
|
|
|
|
func (h *TournamentHandler) handlePause(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.service.PauseTournament(r.Context(), id); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "paused"})
|
|
}
|
|
|
|
func (h *TournamentHandler) handleResume(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.service.ResumeTournament(r.Context(), id); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"})
|
|
}
|
|
|
|
func (h *TournamentHandler) handleCancel(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.service.CancelTournament(r.Context(), id); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
|
|
}
|
|
|
|
// ---------- Deal/Chop Routes ----------
|
|
|
|
type proposeDealRequest struct {
|
|
Type string `json:"type"` // icm, chip_chop, even_chop, custom, partial_chop
|
|
PlayerStacks map[string]int64 `json:"stacks,omitempty"`
|
|
CustomAmounts map[string]int64 `json:"custom_amounts,omitempty"`
|
|
PartialPool int64 `json:"partial_pool,omitempty"`
|
|
RemainingPool int64 `json:"remaining_pool,omitempty"`
|
|
}
|
|
|
|
func (h *TournamentHandler) handleProposeDeal(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
var req proposeDealRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if h.chop == nil {
|
|
writeError(w, http.StatusInternalServerError, "chop engine not available")
|
|
return
|
|
}
|
|
|
|
params := financial.DealParams{
|
|
PlayerStacks: req.PlayerStacks,
|
|
CustomAmounts: req.CustomAmounts,
|
|
PartialPool: req.PartialPool,
|
|
RemainingPool: req.RemainingPool,
|
|
}
|
|
|
|
proposal, err := h.chop.ProposeDeal(r.Context(), id, req.Type, params)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, proposal)
|
|
}
|
|
|
|
func (h *TournamentHandler) handleConfirmDeal(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
proposalID := chi.URLParam(r, "proposalId")
|
|
|
|
if h.chop == nil {
|
|
writeError(w, http.StatusInternalServerError, "chop engine not available")
|
|
return
|
|
}
|
|
|
|
if err := h.chop.ConfirmDeal(r.Context(), id, proposalID); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "confirmed"})
|
|
}
|
|
|
|
func (h *TournamentHandler) handleCancelDeal(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
proposalID := chi.URLParam(r, "proposalId")
|
|
|
|
if h.chop == nil {
|
|
writeError(w, http.StatusInternalServerError, "chop engine not available")
|
|
return
|
|
}
|
|
|
|
if err := h.chop.CancelDeal(r.Context(), id, proposalID); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
|
|
}
|
|
|
|
func (h *TournamentHandler) handleListDealProposals(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
if h.chop == nil {
|
|
writeJSON(w, http.StatusOK, []interface{}{})
|
|
return
|
|
}
|
|
|
|
proposals, err := h.chop.ListProposals(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, proposals)
|
|
}
|