felt/.planning/phases/01-tournament-engine/01-PLAN-G.md
Mikkel Georgsen 21ff95068e docs(01): create Phase 1 plans (A-N) with research and feedback
14 plans in 6 waves covering all 68 requirements for the Tournament
Engine phase. Includes research (go-libsql, NATS JetStream, Svelte 5
runes, ICM complexity), plan verification (2 iterations), and user
feedback (hand-for-hand UX, SEAT-06 reword, re-entry semantics,
integration test, DKK defaults, JWT 7-day expiry, clock tap safety).

Wave structure:
  1: A (scaffold), B (schema)
  2: C (auth/audit), D (clock), E (templates), J (frontend scaffold)
  3: F (financial), H (seating), M (layout shell)
  4: G (player management)
  5: I (tournament lifecycle)
  6: K (overview/financials), L (players), N (tables/more)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:58:22 +01:00

12 KiB

Plan G: Player Management


wave: 4 depends_on: [01-PLAN-C, 01-PLAN-E, 01-PLAN-F] files_modified:

  • internal/player/player.go
  • internal/player/ranking.go
  • internal/player/qrcode.go
  • internal/player/import.go
  • internal/server/routes/players.go
  • internal/player/ranking_test.go
  • internal/player/player_test.go autonomous: true requirements: [PLYR-02, PLYR-03, PLYR-04, PLYR-05, PLYR-06, PLYR-07]

Goal

The player database supports search with typeahead (FTS5), merge duplicates, and CSV import. The buy-in and bust-out flows are the core transaction paths. Rankings are derived from the ordered bust-out list (never stored independently). Undo on any player action triggers full re-ranking. Per-player tracking captures all stats. QR codes are generated per player for future self-check-in.

Context

  • Player database is persistent on Leaf (PLYR-01) — already in schema (Plan B)
  • FTS5 virtual table for typeahead — already created in Plan B
  • Financial engine for buy-in/rebuy/addon processing — Plan F
  • Audit trail + undo — Plan C
  • Auto-seating — Plan H (seating). Buy-in flow calls seating after financial processing
  • Rankings are derived, not stored — from 01-RESEARCH.md Pitfall 6: "Rankings should be derived from the ordered bust-out list, not stored as independent values"
  • See 01-RESEARCH.md Pitfall 6 (Undo Re-Rank)

User Decisions (from CONTEXT.md)

  • Buy-in flow: search/select player → auto-seat suggests optimal seat → TD can override → confirm → receipt
  • Bust-out flow: tap Bust → pick table → pick seat → verify name → confirm → select hitman (mandatory in PKO, optional otherwise) → done
  • Undo is critical — bust-outs, rebuys, add-ons, buy-ins can all be undone with full re-ranking
  • Bust-out flow must be as few taps as possible — TD is under time pressure

Tasks

**1. Player Service** (`internal/player/player.go`): - `PlayerService` struct with db, audit trail
  • CreatePlayer(ctx, player Player) (*Player, error):

    • Generate UUID
    • Insert into players table
    • FTS5 auto-syncs via trigger (Plan B)
    • Record audit entry
    • Return player
  • GetPlayer(ctx, id string) (*Player, error)

  • UpdatePlayer(ctx, player Player) error:

    • Update fields, record audit entry
    • FTS5 auto-syncs via trigger
  • SearchPlayers(ctx, query string, limit int) ([]Player, error) — PLYR-02:

    • Use FTS5: SELECT p.* FROM players p JOIN players_fts f ON p.rowid = f.rowid WHERE players_fts MATCH ? ORDER BY rank LIMIT ?
    • Support prefix matching for typeahead: append * to query terms
    • Return results ordered by relevance (FTS5 rank)
    • If query is empty, return most recently active players (for quick access)
  • ListPlayers(ctx, limit, offset int) ([]Player, error) — paginated list

  • MergePlayers(ctx, keepID, mergeID string) error — PLYR-02:

    • Merge player mergeID into keepID:
      • Move all tournament_players records from mergeID to keepID
      • Move all transactions from mergeID to keepID
      • Merge any non-empty fields from mergeID into keepID (name takes keepID, fill in blanks)
      • Delete mergeID player record
    • Record audit entry with full merge details
    • This is destructive — require admin role
  • ImportFromCSV(ctx, reader io.Reader) (ImportResult, error) — PLYR-02:

    • Parse CSV with headers: name, nickname, email, phone, notes (minimum: name required)
    • For each row:
      • Check for potential duplicates (FTS5 search on name)
      • If exact name match exists, flag as duplicate (don't auto-merge)
      • Otherwise create new player
    • Return ImportResult: created count, duplicate count, error count, duplicate details
    • Require admin role

2. Tournament Player Operations:

  • RegisterPlayer(ctx, tournamentID, playerID string) (*TournamentPlayer, error):

    • Create tournament_players record with status "registered"
    • Check tournament isn't full (max_players)
    • Return tournament player record
  • GetTournamentPlayers(ctx, tournamentID string) ([]TournamentPlayerDetail, error):

    • Join tournament_players with players table
    • Include computed fields: total investment, net result, action count
    • Sort by status (active first), then by name
  • GetTournamentPlayer(ctx, tournamentID, playerID string) (*TournamentPlayerDetail, error):

    • Full player detail within tournament context (PLYR-07):
      • Current chips, seat (table + position), playing time
      • Rebuys count, add-ons count, re-entries count
      • Bounties collected, bounty value (PKO)
      • Prize amount, points awarded
      • Net take (prize - total investment)
      • Full action history (from audit trail + transactions)
  • BustPlayer(ctx, tournamentID, playerID string, hitmanPlayerID *string) error — PLYR-05:

    • Validate player is active
    • Set status to "busted", bust_out_at to now
    • Calculate bust_out_order (count of currently busted players + 1, from the end)
    • If PKO: hitman is mandatory, process bounty transfer (call financial engine)
    • If not PKO: hitman is optional (for tracking who eliminated whom)
    • Set hitman_player_id
    • Clear seat assignment (remove from table)
    • Record audit entry with previous state (for undo)
    • Trigger balance check (notify if tables are unbalanced — seating engine, Plan H)
    • Broadcast player bust event
    • Check if tournament should auto-close (1 player remaining → tournament.end)
  • UndoBust(ctx, tournamentID, playerID string) error — PLYR-06:

    • Restore player to active status
    • Clear bust_out_at, bust_out_order, finishing_position, hitman_player_id
    • If PKO: reverse bounty transfer
    • Re-seat player (needs a seat — auto-assign or put back in original seat from audit trail)
    • Trigger full re-ranking
    • Record audit entry
    • Broadcast

3. QR Code Generation (internal/player/qrcode.go) — PLYR-03:

  • GenerateQRCode(ctx, playerID string) ([]byte, error):
    • Generate QR code encoding player UUID
    • Return PNG image bytes
    • QR code URL format: felt://player/{uuid} (for future PWA self-check-in)
  • API endpoint: GET /api/v1/players/{id}/qrcode — returns PNG image

Verification:

  • Player CRUD works via API
  • FTS5 typeahead search returns results with prefix matching
  • CSV import creates players and flags duplicates
  • Merge combines two player records correctly
  • QR code generates valid PNG image
**1. Ranking Engine** (`internal/player/ranking.go`): - Rankings are **derived from the ordered bust-out list**, never stored independently - `CalculateRankings(ctx, tournamentID string) ([]PlayerRanking, error)`: - Load all tournament_players ordered by bust_out_at (nulls = still active) - Active players: no ranking yet (still playing) — all share the same "current position" = remaining player count - Busted players: ranked in reverse bust order (last busted = highest remaining, first busted = last place) - Finishing position = total_unique_entries - bust_order + 1 - Handle re-entries: a re-entered player's previous bust is "cancelled" — they only have the final bust (or are still active) - Handle deals: players who took a deal have status "deal" with manually assigned finishing positions
  • RecalculateAllRankings(ctx, tournamentID string) error:

    • Called after any undo operation
    • Recalculates ALL bust_out_order values from the bust_out_at timestamps
    • Updates finishing_position for all busted players
    • This ensures consistency even after undoing busts in the middle of the sequence
    • Broadcast updated rankings
  • GetRankings(ctx, tournamentID string) ([]PlayerRanking, error):

    • Returns current rankings for display:
      type PlayerRanking struct {
          Position      int    // Current ranking position
          PlayerID      string
          PlayerName    string
          Status        string // active, busted, deal
          ChipCount     int64
          BustOutTime   *int64
          HitmanName    *string
          BountiesCollected int
          Prize         int64
          Points        int64
      }
      
    • Active players sorted by chip count (if available), then alphabetically
    • Busted players sorted by bust order (most recent first)

2. Player API Routes (internal/server/routes/players.go):

Player database (venue-level):

  • GET /api/v1/players — list all players (paginated)
  • GET /api/v1/players/search?q=john — typeahead search (PLYR-02)
  • POST /api/v1/players — create player
  • GET /api/v1/players/{id} — get player
  • PUT /api/v1/players/{id} — update player
  • GET /api/v1/players/{id}/qrcode — QR code PNG (PLYR-03)
  • POST /api/v1/players/merge — body: {"keep_id": "...", "merge_id": "..."} (admin only)
  • POST /api/v1/players/import — multipart CSV upload (admin only)

Tournament players:

  • GET /api/v1/tournaments/{id}/players — all players in tournament with stats
  • GET /api/v1/tournaments/{id}/players/{playerId} — player detail with full tracking (PLYR-07)
  • POST /api/v1/tournaments/{id}/players/{playerId}/buyin — buy-in flow (PLYR-04):
    • Calls financial engine (Plan F) for transaction
    • Calls seating engine (Plan H) for auto-seat
    • Returns: transaction, seat assignment, receipt
  • POST /api/v1/tournaments/{id}/players/{playerId}/bust — bust-out flow (PLYR-05):
    • Body: {"hitman_player_id": "..."} (required for PKO, optional otherwise)
    • Calls financial engine for bounty transfer
    • Calls ranking engine for re-ranking
    • Calls seating engine for balance check
    • Returns: updated rankings
  • POST /api/v1/tournaments/{id}/players/{playerId}/rebuy — rebuy (delegates to financial engine)
  • POST /api/v1/tournaments/{id}/players/{playerId}/addon — add-on (delegates to financial engine)
  • POST /api/v1/tournaments/{id}/players/{playerId}/reentry — re-entry (delegates to financial engine + seating)
  • POST /api/v1/tournaments/{id}/players/{playerId}/undo-bust — undo bust (PLYR-06)
  • POST /api/v1/tournaments/{id}/transactions/{txId}/undo — undo any transaction (PLYR-06)

Rankings:

  • GET /api/v1/tournaments/{id}/rankings — current rankings

All mutation endpoints record audit entries and broadcast via WebSocket.

3. Tests (internal/player/ranking_test.go):

  • Test rankings derived correctly from bust order
  • Test undo bust triggers re-ranking of all subsequent positions
  • Test undo early bust (not the most recent) re-ranks correctly
  • Test re-entry doesn't count as new entry for ranking purposes
  • Test deal players get manually assigned positions
  • Test auto-close when 1 player remains
  • Test concurrent busts (order preserved by timestamp)

Verification:

  • Buy-in flow creates player entry, transaction, and seat assignment
  • Bust-out flow busts player, processes bounty, re-ranks, checks balance
  • Undo bust restores player and re-ranks all subsequent busts
  • Rankings are always consistent with bust order
  • Player detail shows complete tracking data (PLYR-07)
  • Search returns results with typeahead behavior

Verification Criteria

  1. Player search with typeahead returns results via FTS5 prefix matching
  2. Duplicate merge combines records correctly (admin only)
  3. CSV import creates players and flags duplicates
  4. QR code generates valid PNG with player UUID
  5. Buy-in flow: search → financial transaction → auto-seat → receipt
  6. Bust-out flow: select → hitman → bounty → rank → balance check
  7. Undo bust restores player with full re-ranking of all positions
  8. Undo buy-in removes player from tournament
  9. Per-player tracking shows all stats (PLYR-07)
  10. Rankings are always derived from bust-out list, never stored independently
  11. Tournament auto-closes when one player remains

Must-Haves (Goal-Backward)

  • Typeahead search on player names using FTS5
  • Buy-in flow produces transaction + seat assignment + receipt
  • Bust-out flow with hitman selection and bounty transfer (PKO)
  • Undo capability for bust-out, rebuy, add-on, buy-in with full re-ranking
  • Rankings derived from ordered bust-out list (not stored independently)
  • Per-player tracking: chips, time, seat, moves, rebuys, add-ons, bounties, prize, points, net, history
  • QR code generation per player