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/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/nats-io/nats-server/v2 v2.12.4
|
github.com/nats-io/nats-server/v2 v2.12.4
|
||||||
github.com/nats-io/nats.go v1.49.0
|
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 (
|
require (
|
||||||
|
|
@ -22,7 +24,6 @@ require (
|
||||||
github.com/nats-io/jwt/v2 v2.8.0 // indirect
|
github.com/nats-io/jwt/v2 v2.8.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // 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/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/time v0.14.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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY=
|
||||||
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
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=
|
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
|
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
|
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