- 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>
230 lines
6.7 KiB
Go
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)
|
|
}
|