- 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>
1014 lines
30 KiB
Go
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, ¬es, &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(¤tStatus, &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, ¬es,
|
|
&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])
|
|
}
|