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>
This commit is contained in:
parent
66d7894c24
commit
93736287ae
5 changed files with 1276 additions and 1 deletions
3
go.mod
3
go.mod
|
|
@ -10,6 +10,8 @@ require (
|
|||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/nats-io/nats-server/v2 v2.12.4
|
||||
github.com/nats-io/nats.go v1.49.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/crypto v0.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -22,7 +24,6 @@ require (
|
|||
github.com/nats-io/jwt/v2 v2.8.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -30,6 +30,8 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
|||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +1,31 @@
|
|||
package player
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// QRCodeSize is the default QR code image size in pixels.
|
||||
const QRCodeSize = 256
|
||||
|
||||
// GenerateQRCode generates a QR code PNG image encoding the player UUID.
|
||||
// The QR code encodes a URL in the format: felt://player/{uuid}
|
||||
// This is intended for future PWA self-check-in via camera scan.
|
||||
func (s *Service) GenerateQRCode(ctx context.Context, playerID string) ([]byte, error) {
|
||||
// Verify player exists
|
||||
_, err := s.GetPlayer(ctx, playerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate QR code with player URL
|
||||
url := fmt.Sprintf("felt://player/%s", playerID)
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, QRCodeSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("player: generate QR code: %w", err)
|
||||
}
|
||||
|
||||
return png, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,230 @@
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue