felt/internal/player/player.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

1014 lines
30 KiB
Go

// Package player provides player management for the Felt tournament engine.
// It handles CRUD operations, FTS5 typeahead search, duplicate merge, CSV
// import, tournament player operations (bust, undo, re-entry), and QR code
// generation. All monetary values use int64 cents. All mutations record audit
// entries and broadcast via WebSocket.
package player
import (
"context"
"crypto/rand"
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"time"
"unicode/utf8"
"github.com/felt-app/felt/internal/audit"
"github.com/felt-app/felt/internal/financial"
"github.com/felt-app/felt/internal/server/middleware"
"github.com/felt-app/felt/internal/server/ws"
)
// CSV import safety limits.
const (
MaxCSVRows = 10_000
MaxCSVColumns = 20
MaxCSVFieldLen = 1_000
)
// Errors returned by the player service.
var (
ErrPlayerNotFound = fmt.Errorf("player: not found")
ErrPlayerAlreadyActive = fmt.Errorf("player: already active in tournament")
ErrPlayerNotActive = fmt.Errorf("player: not active in tournament")
ErrPlayerNotBusted = fmt.Errorf("player: not busted")
ErrTournamentFull = fmt.Errorf("player: tournament is full")
ErrTournamentNotFound = fmt.Errorf("player: tournament not found")
ErrHitmanRequired = fmt.Errorf("player: hitman is required for PKO tournament")
ErrCSVTooManyRows = fmt.Errorf("player: CSV exceeds maximum of 10,000 rows")
ErrCSVTooManyColumns = fmt.Errorf("player: CSV exceeds maximum of 20 columns")
ErrCSVFieldTooLong = fmt.Errorf("player: CSV field exceeds maximum of 1,000 characters")
ErrCSVMissingNameColumn = fmt.Errorf("player: CSV must have a 'name' column")
)
// Player represents a venue-level player record.
type Player struct {
ID string `json:"id"`
Name string `json:"name"`
Nickname *string `json:"nickname,omitempty"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
PhotoURL *string `json:"photo_url,omitempty"`
Notes *string `json:"notes,omitempty"`
CustomFields *json.RawMessage `json:"custom_fields,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// TournamentPlayer represents a player's state within a tournament.
type TournamentPlayer struct {
ID int64 `json:"id"`
TournamentID string `json:"tournament_id"`
PlayerID string `json:"player_id"`
Status string `json:"status"`
SeatTableID *int `json:"seat_table_id,omitempty"`
SeatPosition *int `json:"seat_position,omitempty"`
BuyInAt *int64 `json:"buy_in_at,omitempty"`
BustOutAt *int64 `json:"bust_out_at,omitempty"`
BustOutOrder *int `json:"bust_out_order,omitempty"`
FinishingPosition *int `json:"finishing_position,omitempty"`
CurrentChips int64 `json:"current_chips"`
Rebuys int `json:"rebuys"`
Addons int `json:"addons"`
Reentries int `json:"reentries"`
BountyValue int64 `json:"bounty_value"`
BountiesCollected int `json:"bounties_collected"`
PrizeAmount int64 `json:"prize_amount"`
PointsAwarded int64 `json:"points_awarded"`
HitmanPlayerID *string `json:"hitman_player_id,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// TournamentPlayerDetail combines player info with tournament-specific data.
type TournamentPlayerDetail struct {
PlayerID string `json:"player_id"`
PlayerName string `json:"player_name"`
PlayerNickname *string `json:"player_nickname,omitempty"`
Status string `json:"status"`
SeatTableID *int `json:"seat_table_id,omitempty"`
SeatPosition *int `json:"seat_position,omitempty"`
SeatTableName *string `json:"seat_table_name,omitempty"`
BuyInAt *int64 `json:"buy_in_at,omitempty"`
BustOutAt *int64 `json:"bust_out_at,omitempty"`
BustOutOrder *int `json:"bust_out_order,omitempty"`
FinishingPosition *int `json:"finishing_position,omitempty"`
CurrentChips int64 `json:"current_chips"`
Rebuys int `json:"rebuys"`
Addons int `json:"addons"`
Reentries int `json:"reentries"`
BountyValue int64 `json:"bounty_value"`
BountiesCollected int `json:"bounties_collected"`
PrizeAmount int64 `json:"prize_amount"`
PointsAwarded int64 `json:"points_awarded"`
HitmanPlayerID *string `json:"hitman_player_id,omitempty"`
HitmanPlayerName *string `json:"hitman_player_name,omitempty"`
TotalInvestment int64 `json:"total_investment"`
NetResult int64 `json:"net_result"`
// Action history
Transactions []financial.Transaction `json:"transactions,omitempty"`
}
// ImportResult holds the outcome of a CSV import.
type ImportResult struct {
Created int `json:"created"`
Duplicates int `json:"duplicates"`
Errors int `json:"errors"`
DuplicateDetails []DuplicateEntry `json:"duplicate_details,omitempty"`
ErrorDetails []string `json:"error_details,omitempty"`
}
// DuplicateEntry describes a potential duplicate found during import.
type DuplicateEntry struct {
Row int `json:"row"`
ImportedName string `json:"imported_name"`
ExistingID string `json:"existing_id"`
ExistingName string `json:"existing_name"`
}
// Service provides player management operations.
type Service struct {
db *sql.DB
trail *audit.Trail
financial *financial.Engine
hub *ws.Hub
}
// NewService creates a new player service.
func NewService(db *sql.DB, trail *audit.Trail, fin *financial.Engine, hub *ws.Hub) *Service {
return &Service{
db: db,
trail: trail,
financial: fin,
hub: hub,
}
}
// ---------- Player CRUD ----------
// CreatePlayer creates a new player in the venue database.
func (s *Service) CreatePlayer(ctx context.Context, p Player) (*Player, error) {
p.ID = generateUUID()
now := time.Now().Unix()
p.CreatedAt = now
p.UpdatedAt = now
_, err := s.db.ExecContext(ctx,
`INSERT INTO players (id, name, nickname, email, phone, photo_url, notes, custom_fields, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.Nickname, p.Email, p.Phone, p.PhotoURL, p.Notes,
nullableJSON(p.CustomFields), p.CreatedAt, p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("player: create: %w", err)
}
// Audit
s.recordAudit(ctx, nil, "player.create", "player", p.ID, nil, &p)
return &p, nil
}
// GetPlayer retrieves a player by ID.
func (s *Service) GetPlayer(ctx context.Context, id string) (*Player, error) {
p := &Player{}
var nickname, email, phone, photoURL, notes, customFields sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, name, nickname, email, phone, photo_url, notes, custom_fields, created_at, updated_at
FROM players WHERE id = ?`, id,
).Scan(
&p.ID, &p.Name, &nickname, &email, &phone, &photoURL, &notes, &customFields,
&p.CreatedAt, &p.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrPlayerNotFound
}
if err != nil {
return nil, fmt.Errorf("player: get: %w", err)
}
p.Nickname = nullStringPtr(nickname)
p.Email = nullStringPtr(email)
p.Phone = nullStringPtr(phone)
p.PhotoURL = nullStringPtr(photoURL)
p.Notes = nullStringPtr(notes)
if customFields.Valid {
raw := json.RawMessage(customFields.String)
p.CustomFields = &raw
}
return p, nil
}
// UpdatePlayer updates a player's fields.
func (s *Service) UpdatePlayer(ctx context.Context, p Player) error {
p.UpdatedAt = time.Now().Unix()
result, err := s.db.ExecContext(ctx,
`UPDATE players SET name = ?, nickname = ?, email = ?, phone = ?, photo_url = ?,
notes = ?, custom_fields = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.Nickname, p.Email, p.Phone, p.PhotoURL, p.Notes,
nullableJSON(p.CustomFields), p.UpdatedAt, p.ID,
)
if err != nil {
return fmt.Errorf("player: update: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return ErrPlayerNotFound
}
s.recordAudit(ctx, nil, "player.update", "player", p.ID, nil, &p)
return nil
}
// SearchPlayers searches players using FTS5 prefix matching for typeahead.
func (s *Service) SearchPlayers(ctx context.Context, query string, limit int) ([]Player, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
// If query is empty, return most recently updated players
if strings.TrimSpace(query) == "" {
return s.ListPlayers(ctx, limit, 0)
}
// Build FTS5 query with prefix matching
terms := strings.Fields(query)
var ftsTerms []string
for _, t := range terms {
// Escape special FTS5 characters and append * for prefix matching
escaped := strings.ReplaceAll(t, `"`, `""`)
ftsTerms = append(ftsTerms, `"`+escaped+`"*`)
}
ftsQuery := strings.Join(ftsTerms, " ")
rows, err := s.db.QueryContext(ctx,
`SELECT p.id, p.name, p.nickname, p.email, p.phone, p.photo_url, p.notes,
p.custom_fields, p.created_at, p.updated_at
FROM players p
JOIN players_fts f ON p.rowid = f.rowid
WHERE players_fts MATCH ?
ORDER BY rank
LIMIT ?`,
ftsQuery, limit,
)
if err != nil {
return nil, fmt.Errorf("player: search: %w", err)
}
defer rows.Close()
return scanPlayers(rows)
}
// ListPlayers returns a paginated list of players.
func (s *Service) ListPlayers(ctx context.Context, limit, offset int) ([]Player, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, nickname, email, phone, photo_url, notes,
custom_fields, created_at, updated_at
FROM players
ORDER BY updated_at DESC
LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("player: list: %w", err)
}
defer rows.Close()
return scanPlayers(rows)
}
// MergePlayers merges mergeID into keepID. Destructive, requires admin role.
func (s *Service) MergePlayers(ctx context.Context, keepID, mergeID string) error {
// Verify admin role
role := middleware.OperatorRoleFromCtx(ctx)
if role != middleware.RoleAdmin {
return fmt.Errorf("player: merge requires admin role")
}
// Verify both players exist
keepPlayer, err := s.GetPlayer(ctx, keepID)
if err != nil {
return fmt.Errorf("player: keep player: %w", err)
}
mergePlayer, err := s.GetPlayer(ctx, mergeID)
if err != nil {
return fmt.Errorf("player: merge player: %w", err)
}
// Move tournament_players from mergeID to keepID
_, err = s.db.ExecContext(ctx,
`UPDATE tournament_players SET player_id = ?, updated_at = unixepoch()
WHERE player_id = ?`,
keepID, mergeID,
)
if err != nil {
return fmt.Errorf("player: merge tournament_players: %w", err)
}
// Move transactions from mergeID to keepID
_, err = s.db.ExecContext(ctx,
`UPDATE transactions SET player_id = ? WHERE player_id = ?`,
keepID, mergeID,
)
if err != nil {
return fmt.Errorf("player: merge transactions: %w", err)
}
// Merge non-empty fields from mergePlayer into keepPlayer
if keepPlayer.Nickname == nil && mergePlayer.Nickname != nil {
keepPlayer.Nickname = mergePlayer.Nickname
}
if keepPlayer.Email == nil && mergePlayer.Email != nil {
keepPlayer.Email = mergePlayer.Email
}
if keepPlayer.Phone == nil && mergePlayer.Phone != nil {
keepPlayer.Phone = mergePlayer.Phone
}
if keepPlayer.PhotoURL == nil && mergePlayer.PhotoURL != nil {
keepPlayer.PhotoURL = mergePlayer.PhotoURL
}
if keepPlayer.Notes == nil && mergePlayer.Notes != nil {
keepPlayer.Notes = mergePlayer.Notes
}
// Update keep player with merged fields
if err := s.UpdatePlayer(ctx, *keepPlayer); err != nil {
return fmt.Errorf("player: merge update: %w", err)
}
// Delete merge player
_, err = s.db.ExecContext(ctx, `DELETE FROM players WHERE id = ?`, mergeID)
if err != nil {
return fmt.Errorf("player: merge delete: %w", err)
}
// Audit
mergeDetails := map[string]interface{}{
"keep_id": keepID,
"merge_id": mergeID,
"merged_name": mergePlayer.Name,
}
s.recordAudit(ctx, nil, "player.merge", "player", keepID, nil, mergeDetails)
return nil
}
// ImportFromCSV imports players from a CSV reader.
// Returns an ImportResult with counts of created, duplicate, and error records.
func (s *Service) ImportFromCSV(ctx context.Context, reader io.Reader) (*ImportResult, error) {
// Verify admin role
role := middleware.OperatorRoleFromCtx(ctx)
if role != middleware.RoleAdmin {
return nil, fmt.Errorf("player: import requires admin role")
}
csvReader := csv.NewReader(reader)
csvReader.TrimLeadingSpace = true
// Read header
header, err := csvReader.Read()
if err != nil {
return nil, fmt.Errorf("player: CSV read header: %w", err)
}
// Check column count
if len(header) > MaxCSVColumns {
return nil, ErrCSVTooManyColumns
}
// Map header to column indices
colMap := make(map[string]int)
for i, h := range header {
colMap[strings.ToLower(strings.TrimSpace(h))] = i
}
if _, ok := colMap["name"]; !ok {
return nil, ErrCSVMissingNameColumn
}
result := &ImportResult{}
rowNum := 0
for {
record, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
result.Errors++
result.ErrorDetails = append(result.ErrorDetails, fmt.Sprintf("row %d: %v", rowNum+2, err))
rowNum++
continue
}
rowNum++
if rowNum > MaxCSVRows {
return nil, ErrCSVTooManyRows
}
// Check field lengths
tooLong := false
for _, field := range record {
if utf8.RuneCountInString(field) > MaxCSVFieldLen {
tooLong = true
break
}
}
if tooLong {
return nil, ErrCSVFieldTooLong
}
// Extract fields from record
name := getField(record, colMap, "name")
if name == "" {
result.Errors++
result.ErrorDetails = append(result.ErrorDetails, fmt.Sprintf("row %d: name is required", rowNum+1))
continue
}
// Check for duplicate by exact name match
existing, _ := s.SearchPlayers(ctx, name, 5)
var exactMatch *Player
for i, p := range existing {
if strings.EqualFold(p.Name, name) {
exactMatch = &existing[i]
break
}
}
if exactMatch != nil {
result.Duplicates++
result.DuplicateDetails = append(result.DuplicateDetails, DuplicateEntry{
Row: rowNum + 1,
ImportedName: name,
ExistingID: exactMatch.ID,
ExistingName: exactMatch.Name,
})
continue
}
// Create new player
p := Player{Name: name}
if v := getField(record, colMap, "nickname"); v != "" {
p.Nickname = &v
}
if v := getField(record, colMap, "email"); v != "" {
p.Email = &v
}
if v := getField(record, colMap, "phone"); v != "" {
p.Phone = &v
}
if v := getField(record, colMap, "notes"); v != "" {
p.Notes = &v
}
if _, err := s.CreatePlayer(ctx, p); err != nil {
result.Errors++
result.ErrorDetails = append(result.ErrorDetails, fmt.Sprintf("row %d: %v", rowNum+1, err))
continue
}
result.Created++
}
// Audit
s.recordAudit(ctx, nil, "player.import", "players", "csv", nil, result)
return result, nil
}
// ---------- Tournament Player Operations ----------
// RegisterPlayer registers a player for a tournament.
func (s *Service) RegisterPlayer(ctx context.Context, tournamentID, playerID string) (*TournamentPlayer, error) {
// Check tournament exists and get max_players
var maxPlayers sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT max_players FROM tournaments WHERE id = ?`, tournamentID,
).Scan(&maxPlayers)
if err == sql.ErrNoRows {
return nil, ErrTournamentNotFound
}
if err != nil {
return nil, fmt.Errorf("player: check tournament: %w", err)
}
// Check tournament isn't full
if maxPlayers.Valid {
var count int
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tournamentID,
).Scan(&count)
if err != nil {
return nil, fmt.Errorf("player: count players: %w", err)
}
if count >= int(maxPlayers.Int64) {
return nil, ErrTournamentFull
}
}
// Create tournament_players record
now := time.Now().Unix()
result, err := s.db.ExecContext(ctx,
`INSERT INTO tournament_players (tournament_id, player_id, status, created_at, updated_at)
VALUES (?, ?, 'registered', ?, ?)`,
tournamentID, playerID, now, now,
)
if err != nil {
return nil, fmt.Errorf("player: register: %w", err)
}
id, _ := result.LastInsertId()
tp := &TournamentPlayer{
ID: id,
TournamentID: tournamentID,
PlayerID: playerID,
Status: "registered",
CreatedAt: now,
UpdatedAt: now,
}
s.recordAudit(ctx, &tournamentID, "player.register", "player", playerID, nil, tp)
s.broadcast(tournamentID, "player.registered", tp)
return tp, nil
}
// GetTournamentPlayers returns all players in a tournament with computed fields.
func (s *Service) GetTournamentPlayers(ctx context.Context, tournamentID string) ([]TournamentPlayerDetail, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT tp.player_id, p.name, p.nickname, tp.status,
tp.seat_table_id, tp.seat_position, t.name,
tp.buy_in_at, tp.bust_out_at, tp.bust_out_order, tp.finishing_position,
tp.current_chips, tp.rebuys, tp.addons, tp.reentries,
tp.bounty_value, tp.bounties_collected, tp.prize_amount, tp.points_awarded,
tp.hitman_player_id, hp.name
FROM tournament_players tp
JOIN players p ON p.id = tp.player_id
LEFT JOIN tables t ON t.id = tp.seat_table_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 'registered' THEN 2
WHEN 'busted' THEN 3
WHEN 'deal' THEN 4
END,
p.name`,
tournamentID,
)
if err != nil {
return nil, fmt.Errorf("player: list tournament players: %w", err)
}
defer rows.Close()
var players []TournamentPlayerDetail
for rows.Next() {
var d TournamentPlayerDetail
var nickname, tableName, hitmanID, hitmanName sql.NullString
var tableID, seatPos sql.NullInt64
var buyInAt, bustOutAt sql.NullInt64
var bustOutOrder, finishingPos sql.NullInt64
if err := rows.Scan(
&d.PlayerID, &d.PlayerName, &nickname, &d.Status,
&tableID, &seatPos, &tableName,
&buyInAt, &bustOutAt, &bustOutOrder, &finishingPos,
&d.CurrentChips, &d.Rebuys, &d.Addons, &d.Reentries,
&d.BountyValue, &d.BountiesCollected, &d.PrizeAmount, &d.PointsAwarded,
&hitmanID, &hitmanName,
); err != nil {
return nil, fmt.Errorf("player: scan tournament player: %w", err)
}
d.PlayerNickname = nullStringPtr(nickname)
if tableID.Valid {
v := int(tableID.Int64)
d.SeatTableID = &v
}
if seatPos.Valid {
v := int(seatPos.Int64)
d.SeatPosition = &v
}
d.SeatTableName = nullStringPtr(tableName)
if buyInAt.Valid {
d.BuyInAt = &buyInAt.Int64
}
if bustOutAt.Valid {
d.BustOutAt = &bustOutAt.Int64
}
if bustOutOrder.Valid {
v := int(bustOutOrder.Int64)
d.BustOutOrder = &v
}
if finishingPos.Valid {
v := int(finishingPos.Int64)
d.FinishingPosition = &v
}
d.HitmanPlayerID = nullStringPtr(hitmanID)
d.HitmanPlayerName = nullStringPtr(hitmanName)
// Compute total investment from non-undone transactions
d.TotalInvestment = s.computeInvestment(ctx, tournamentID, d.PlayerID)
d.NetResult = d.PrizeAmount - d.TotalInvestment
players = append(players, d)
}
return players, rows.Err()
}
// GetTournamentPlayer returns a single player's full detail within a tournament.
func (s *Service) GetTournamentPlayer(ctx context.Context, tournamentID, playerID string) (*TournamentPlayerDetail, error) {
var d TournamentPlayerDetail
var nickname, tableName, hitmanID, hitmanName sql.NullString
var tableID, seatPos sql.NullInt64
var buyInAt, bustOutAt sql.NullInt64
var bustOutOrder, finishingPos sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT tp.player_id, p.name, p.nickname, tp.status,
tp.seat_table_id, tp.seat_position, t.name,
tp.buy_in_at, tp.bust_out_at, tp.bust_out_order, tp.finishing_position,
tp.current_chips, tp.rebuys, tp.addons, tp.reentries,
tp.bounty_value, tp.bounties_collected, tp.prize_amount, tp.points_awarded,
tp.hitman_player_id, hp.name
FROM tournament_players tp
JOIN players p ON p.id = tp.player_id
LEFT JOIN tables t ON t.id = tp.seat_table_id
LEFT JOIN players hp ON hp.id = tp.hitman_player_id
WHERE tp.tournament_id = ? AND tp.player_id = ?`,
tournamentID, playerID,
).Scan(
&d.PlayerID, &d.PlayerName, &nickname, &d.Status,
&tableID, &seatPos, &tableName,
&buyInAt, &bustOutAt, &bustOutOrder, &finishingPos,
&d.CurrentChips, &d.Rebuys, &d.Addons, &d.Reentries,
&d.BountyValue, &d.BountiesCollected, &d.PrizeAmount, &d.PointsAwarded,
&hitmanID, &hitmanName,
)
if err == sql.ErrNoRows {
return nil, ErrPlayerNotFound
}
if err != nil {
return nil, fmt.Errorf("player: get tournament player: %w", err)
}
d.PlayerNickname = nullStringPtr(nickname)
if tableID.Valid {
v := int(tableID.Int64)
d.SeatTableID = &v
}
if seatPos.Valid {
v := int(seatPos.Int64)
d.SeatPosition = &v
}
d.SeatTableName = nullStringPtr(tableName)
if buyInAt.Valid {
d.BuyInAt = &buyInAt.Int64
}
if bustOutAt.Valid {
d.BustOutAt = &bustOutAt.Int64
}
if bustOutOrder.Valid {
v := int(bustOutOrder.Int64)
d.BustOutOrder = &v
}
if finishingPos.Valid {
v := int(finishingPos.Int64)
d.FinishingPosition = &v
}
d.HitmanPlayerID = nullStringPtr(hitmanID)
d.HitmanPlayerName = nullStringPtr(hitmanName)
// Compute total investment
d.TotalInvestment = s.computeInvestment(ctx, tournamentID, playerID)
d.NetResult = d.PrizeAmount - d.TotalInvestment
// Load transaction history
txs, err := s.financial.GetPlayerTransactions(ctx, tournamentID, playerID)
if err != nil {
log.Printf("player: load transactions for %s: %v", playerID, err)
} else {
d.Transactions = txs
}
return &d, nil
}
// BustPlayer busts a player out of the tournament.
func (s *Service) BustPlayer(ctx context.Context, tournamentID, playerID string, hitmanPlayerID *string) error {
// Check if PKO
var isPKO int
err := s.db.QueryRowContext(ctx,
`SELECT is_pko FROM tournaments WHERE id = ?`, tournamentID,
).Scan(&isPKO)
if err == sql.ErrNoRows {
return ErrTournamentNotFound
}
if err != nil {
return fmt.Errorf("player: check tournament: %w", err)
}
if isPKO != 0 && (hitmanPlayerID == nil || *hitmanPlayerID == "") {
return ErrHitmanRequired
}
// Validate player is active
var currentStatus string
var prevTableID, prevSeatPos sql.NullInt64
var prevChips int64
err = s.db.QueryRowContext(ctx,
`SELECT status, seat_table_id, seat_position, current_chips
FROM tournament_players
WHERE tournament_id = ? AND player_id = ?`,
tournamentID, playerID,
).Scan(&currentStatus, &prevTableID, &prevSeatPos, &prevChips)
if err == sql.ErrNoRows {
return ErrPlayerNotFound
}
if err != nil {
return fmt.Errorf("player: bust check: %w", err)
}
if currentStatus != "active" {
return ErrPlayerNotActive
}
// Calculate bust_out_order
var bustOrder int
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players
WHERE tournament_id = ? AND status = 'busted'`,
tournamentID,
).Scan(&bustOrder)
if err != nil {
return fmt.Errorf("player: count busted: %w", err)
}
bustOrder++ // 1-based
now := time.Now().Unix()
// Update player status
_, err = s.db.ExecContext(ctx,
`UPDATE tournament_players SET
status = 'busted',
bust_out_at = ?,
bust_out_order = ?,
current_chips = 0,
seat_table_id = NULL,
seat_position = NULL,
hitman_player_id = ?,
updated_at = ?
WHERE tournament_id = ? AND player_id = ?`,
now, bustOrder, hitmanPlayerID, now, tournamentID, playerID,
)
if err != nil {
return fmt.Errorf("player: bust update: %w", err)
}
// PKO bounty transfer
if hitmanPlayerID != nil && *hitmanPlayerID != "" {
if err := s.financial.ProcessBountyTransfer(ctx, tournamentID, playerID, *hitmanPlayerID); err != nil {
log.Printf("player: bounty transfer for bust %s: %v", playerID, err)
// Non-fatal for non-PKO tournaments that don't have bounties
}
}
// Audit with previous state for undo
prevState := map[string]interface{}{
"status": currentStatus,
"current_chips": prevChips,
}
if prevTableID.Valid {
prevState["seat_table_id"] = prevTableID.Int64
}
if prevSeatPos.Valid {
prevState["seat_position"] = prevSeatPos.Int64
}
newState := map[string]interface{}{
"status": "busted",
"bust_out_at": now,
"bust_out_order": bustOrder,
"hitman": hitmanPlayerID,
}
s.recordAudit(ctx, &tournamentID, audit.ActionPlayerBust, "player", playerID, prevState, newState)
// Broadcast
s.broadcast(tournamentID, "player.busted", map[string]interface{}{
"player_id": playerID,
"bust_out_order": bustOrder,
"hitman_player_id": hitmanPlayerID,
})
// Check if tournament should auto-close (1 player remaining)
var activeCount int
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players
WHERE tournament_id = ? AND status = 'active'`,
tournamentID,
).Scan(&activeCount)
if err == nil && activeCount <= 1 {
s.broadcast(tournamentID, "tournament.auto_close", map[string]interface{}{
"remaining_players": activeCount,
})
}
return nil
}
// UndoBust restores a busted player to active status with full re-ranking.
func (s *Service) UndoBust(ctx context.Context, tournamentID, playerID string) error {
// Get player's current bust state
var status string
var bustOutAt, bustOutOrder sql.NullInt64
var hitmanPlayerID sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT status, bust_out_at, bust_out_order, hitman_player_id
FROM tournament_players
WHERE tournament_id = ? AND player_id = ?`,
tournamentID, playerID,
).Scan(&status, &bustOutAt, &bustOutOrder, &hitmanPlayerID)
if err == sql.ErrNoRows {
return ErrPlayerNotFound
}
if err != nil {
return fmt.Errorf("player: undo bust check: %w", err)
}
if status != "busted" {
return ErrPlayerNotBusted
}
// Restore player to active
now := time.Now().Unix()
_, err = s.db.ExecContext(ctx,
`UPDATE tournament_players SET
status = 'active',
bust_out_at = NULL,
bust_out_order = NULL,
finishing_position = NULL,
hitman_player_id = NULL,
updated_at = ?
WHERE tournament_id = ? AND player_id = ?`,
now, tournamentID, playerID,
)
if err != nil {
return fmt.Errorf("player: undo bust update: %w", err)
}
// Trigger full re-ranking
rankingEngine := NewRankingEngine(s.db, s.hub)
if err := rankingEngine.RecalculateAllRankings(ctx, tournamentID); err != nil {
log.Printf("player: re-ranking after undo bust: %v", err)
}
// Audit
prevState := map[string]interface{}{
"status": "busted",
"bust_out_order": bustOutOrder.Int64,
}
newState := map[string]interface{}{
"status": "active",
}
s.recordAudit(ctx, &tournamentID, "undo."+audit.ActionPlayerBust, "player", playerID, prevState, newState)
// Broadcast
s.broadcast(tournamentID, "player.undo_bust", map[string]interface{}{
"player_id": playerID,
})
return nil
}
// ---------- Helpers ----------
func (s *Service) computeInvestment(ctx context.Context, tournamentID, playerID string) int64 {
var total int64
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(amount), 0) FROM transactions
WHERE tournament_id = ? AND player_id = ?
AND type IN ('buyin', 'rebuy', 'addon', 'reentry')
AND undone = 0`,
tournamentID, playerID,
).Scan(&total)
if err != nil {
return 0
}
return total
}
func (s *Service) recordAudit(ctx context.Context, tournamentID *string, action, targetType, targetID string, prevState, newState interface{}) {
if s.trail == nil {
return
}
var prev, next json.RawMessage
if prevState != nil {
prev, _ = json.Marshal(prevState)
}
if newState != nil {
next, _ = json.Marshal(newState)
}
_, err := s.trail.Record(ctx, audit.AuditEntry{
TournamentID: tournamentID,
Action: action,
TargetType: targetType,
TargetID: targetID,
PreviousState: prev,
NewState: next,
})
if err != nil {
log.Printf("player: audit record failed: %v", err)
}
}
func (s *Service) broadcast(tournamentID, eventType string, data interface{}) {
if s.hub == nil {
return
}
payload, err := json.Marshal(data)
if err != nil {
log.Printf("player: broadcast marshal error: %v", err)
return
}
s.hub.Broadcast(tournamentID, eventType, payload)
}
func scanPlayers(rows *sql.Rows) ([]Player, error) {
var players []Player
for rows.Next() {
var p Player
var nickname, email, phone, photoURL, notes, customFields sql.NullString
if err := rows.Scan(
&p.ID, &p.Name, &nickname, &email, &phone, &photoURL, &notes,
&customFields, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("player: scan: %w", err)
}
p.Nickname = nullStringPtr(nickname)
p.Email = nullStringPtr(email)
p.Phone = nullStringPtr(phone)
p.PhotoURL = nullStringPtr(photoURL)
p.Notes = nullStringPtr(notes)
if customFields.Valid {
raw := json.RawMessage(customFields.String)
p.CustomFields = &raw
}
players = append(players, p)
}
return players, rows.Err()
}
func getField(record []string, colMap map[string]int, column string) string {
idx, ok := colMap[column]
if !ok || idx >= len(record) {
return ""
}
return strings.TrimSpace(record[idx])
}
func nullStringPtr(ns sql.NullString) *string {
if ns.Valid {
return &ns.String
}
return nil
}
func nullableJSON(data *json.RawMessage) sql.NullString {
if data == nil || len(*data) == 0 || string(*data) == "null" {
return sql.NullString{}
}
return sql.NullString{String: string(*data), Valid: true}
}
// generateUUID generates a v4 UUID.
func generateUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
b[8] = (b[8] & 0x3f) | 0x80 // Variant 1
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}