felt/internal/player/ranking.go
Mikkel Georgsen 93736287ae feat(01-07): implement player CRUD, search, merge, CSV import, QR codes
- PlayerService with CRUD, FTS5 typeahead search, duplicate merge, CSV import
- CSV import safety limits: 10K rows, 20 columns, 1K chars/field
- QR code generation per player using skip2/go-qrcode library
- Tournament player operations: register, bust, undo bust
- TournamentPlayerDetail with computed investment, net result, action history
- RankingEngine derives positions from ordered bust-out list (never stored)
- RecalculateAllRankings for undo consistency
- All mutations record audit entries and broadcast via WebSocket

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:32:13 +01:00

230 lines
6.7 KiB
Go

package player
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/felt-app/felt/internal/server/ws"
)
// PlayerRanking represents a player's current ranking position.
type PlayerRanking struct {
Position int `json:"position"`
PlayerID string `json:"player_id"`
PlayerName string `json:"player_name"`
Status string `json:"status"` // active, busted, deal
ChipCount int64 `json:"chip_count"`
BustOutTime *int64 `json:"bust_out_time,omitempty"`
HitmanName *string `json:"hitman_name,omitempty"`
BountiesCollected int `json:"bounties_collected"`
Prize int64 `json:"prize"`
Points int64 `json:"points"`
}
// RankingEngine derives rankings from the ordered bust-out list.
// Rankings are never stored independently -- they are always computed.
type RankingEngine struct {
db *sql.DB
hub *ws.Hub
}
// NewRankingEngine creates a new ranking engine.
func NewRankingEngine(db *sql.DB, hub *ws.Hub) *RankingEngine {
return &RankingEngine{db: db, hub: hub}
}
// CalculateRankings computes current rankings for a tournament.
// Active players share the same "current position" = remaining player count.
// Busted players are ranked in reverse bust order (last busted = highest, first busted = last).
func (r *RankingEngine) CalculateRankings(ctx context.Context, tournamentID string) ([]PlayerRanking, error) {
// Count total unique entries (for finishing_position calculation)
var totalEntries int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`,
tournamentID,
).Scan(&totalEntries)
if err != nil {
return nil, fmt.Errorf("ranking: count entries: %w", err)
}
// Load all players with ranking-relevant data
rows, err := r.db.QueryContext(ctx,
`SELECT tp.player_id, p.name, tp.status, tp.current_chips,
tp.bust_out_at, tp.bust_out_order, tp.finishing_position,
tp.bounties_collected, tp.prize_amount, tp.points_awarded,
hp.name
FROM tournament_players tp
JOIN players p ON p.id = tp.player_id
LEFT JOIN players hp ON hp.id = tp.hitman_player_id
WHERE tp.tournament_id = ?
ORDER BY
CASE tp.status
WHEN 'active' THEN 1
WHEN 'deal' THEN 2
WHEN 'busted' THEN 3
WHEN 'registered' THEN 4
END,
-- Active players by chip count desc, then name
CASE WHEN tp.status = 'active' THEN -tp.current_chips ELSE 0 END,
-- Busted players by bust order desc (most recent first)
CASE WHEN tp.status = 'busted' THEN -COALESCE(tp.bust_out_order, 0) ELSE 0 END,
p.name`,
tournamentID,
)
if err != nil {
return nil, fmt.Errorf("ranking: query players: %w", err)
}
defer rows.Close()
// Count active players for position calculation
var activeCount int
err = r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players
WHERE tournament_id = ? AND status = 'active'`,
tournamentID,
).Scan(&activeCount)
if err != nil {
return nil, fmt.Errorf("ranking: count active: %w", err)
}
var rankings []PlayerRanking
pos := 1
for rows.Next() {
var pr PlayerRanking
var bustOutAt, bustOutOrder, finishingPos sql.NullInt64
var hitmanName sql.NullString
if err := rows.Scan(
&pr.PlayerID, &pr.PlayerName, &pr.Status, &pr.ChipCount,
&bustOutAt, &bustOutOrder, &finishingPos,
&pr.BountiesCollected, &pr.Prize, &pr.Points,
&hitmanName,
); err != nil {
return nil, fmt.Errorf("ranking: scan: %w", err)
}
if bustOutAt.Valid {
pr.BustOutTime = &bustOutAt.Int64
}
if hitmanName.Valid {
pr.HitmanName = &hitmanName.String
}
switch pr.Status {
case "active":
// All active players share the same position
pr.Position = 1
case "deal":
// Deal players get their manually assigned finishing position
if finishingPos.Valid {
pr.Position = int(finishingPos.Int64)
} else {
pr.Position = pos
}
case "busted":
// Busted: position = totalEntries - bustOutOrder + 1
if bustOutOrder.Valid {
pr.Position = totalEntries - int(bustOutOrder.Int64) + 1
} else {
pr.Position = pos
}
default:
// Registered (not yet playing)
pr.Position = 0
}
rankings = append(rankings, pr)
pos++
}
return rankings, rows.Err()
}
// RecalculateAllRankings recalculates ALL bust_out_order values from timestamps.
// Called after any undo operation to ensure consistency.
func (r *RankingEngine) RecalculateAllRankings(ctx context.Context, tournamentID string) error {
// Load all busted players ordered by bust_out_at timestamp
rows, err := r.db.QueryContext(ctx,
`SELECT player_id, bust_out_at
FROM tournament_players
WHERE tournament_id = ? AND status = 'busted' AND bust_out_at IS NOT NULL
ORDER BY bust_out_at ASC`,
tournamentID,
)
if err != nil {
return fmt.Errorf("ranking: query busted: %w", err)
}
defer rows.Close()
type bustedPlayer struct {
PlayerID string
BustOutAt int64
}
var busted []bustedPlayer
for rows.Next() {
var bp bustedPlayer
if err := rows.Scan(&bp.PlayerID, &bp.BustOutAt); err != nil {
return fmt.Errorf("ranking: scan busted: %w", err)
}
busted = append(busted, bp)
}
if err := rows.Err(); err != nil {
return err
}
// Count total entries for finishing_position calculation
var totalEntries int
err = r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`,
tournamentID,
).Scan(&totalEntries)
if err != nil {
return fmt.Errorf("ranking: count entries: %w", err)
}
// Update each busted player's bust_out_order and finishing_position
for i, bp := range busted {
bustOutOrder := i + 1 // 1-based, chronological
finishingPosition := totalEntries - bustOutOrder + 1
_, err := r.db.ExecContext(ctx,
`UPDATE tournament_players
SET bust_out_order = ?, finishing_position = ?, updated_at = unixepoch()
WHERE tournament_id = ? AND player_id = ?`,
bustOutOrder, finishingPosition, tournamentID, bp.PlayerID,
)
if err != nil {
return fmt.Errorf("ranking: update bust order for %s: %w", bp.PlayerID, err)
}
}
// Broadcast updated rankings
rankings, err := r.CalculateRankings(ctx, tournamentID)
if err != nil {
log.Printf("ranking: broadcast recalculated: %v", err)
} else {
r.broadcast(tournamentID, "rankings.updated", rankings)
}
return nil
}
// GetRankings returns current rankings for display.
func (r *RankingEngine) GetRankings(ctx context.Context, tournamentID string) ([]PlayerRanking, error) {
return r.CalculateRankings(ctx, tournamentID)
}
func (r *RankingEngine) broadcast(tournamentID, eventType string, data interface{}) {
if r.hub == nil {
return
}
payload, err := json.Marshal(data)
if err != nil {
log.Printf("ranking: broadcast marshal error: %v", err)
return
}
r.hub.Broadcast(tournamentID, eventType, payload)
}