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>
254 lines
12 KiB
Markdown
254 lines
12 KiB
Markdown
# 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
|
|
|
|
<task id="G1" title="Implement player CRUD, search, merge, import, and QR codes">
|
|
**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
|
|
</task>
|
|
|
|
<task id="G2" title="Implement ranking engine and player API routes">
|
|
**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:
|
|
```go
|
|
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
|
|
</task>
|
|
|
|
## 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
|