felt/internal/server/routes/tournaments.go
Mikkel Georgsen 75ccb6f735 feat(01-09): implement tournament lifecycle, multi-tournament, ICM, and chop/deal
- 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>
2026-03-01 07:58:11 +01:00

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