docs(01): research phase domain
This commit is contained in:
parent
0030d4d2c2
commit
5cdd7b0fa7
1 changed files with 903 additions and 0 deletions
903
.planning/phases/01-tournament-engine/01-RESEARCH.md
Normal file
903
.planning/phases/01-tournament-engine/01-RESEARCH.md
Normal file
|
|
@ -0,0 +1,903 @@
|
||||||
|
# Phase 1: Tournament Engine - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-03-01
|
||||||
|
**Domain:** Full-stack tournament management — Go backend (LibSQL, NATS JetStream, WebSocket), SvelteKit frontend (embedded via go:embed), poker domain logic (clock, blinds, financials, seating, ICM)
|
||||||
|
**Confidence:** HIGH (core stack verified via official sources and docs)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 1 is a greenfield full-stack build: a single Go binary embedding LibSQL (local SQLite fork), NATS JetStream (event durability), and a WebSocket hub, serving a SvelteKit SPA via `go:embed`. The binary runs in an x86 LXC container and provides a touch-friendly Tournament Director (TD) UI for managing a complete poker tournament lifecycle.
|
||||||
|
|
||||||
|
The Go backend stack is well-established. LibSQL provides a local-first SQLite-compatible database via `go-libsql` (CGO required, no tagged releases — pin to commit hash). NATS server embeds programmatically with JetStream enabled for append-only event durability (`sync_interval: always` is mandatory for single-node per the December 2025 Jepsen finding). The WebSocket hub uses `coder/websocket` (v1.8.14, formerly nhooyr.io/websocket) for idiomatic Go context support. HTTP routing uses `go-chi/chi` v5 for its lightweight middleware composition. Authentication is bcrypt-hashed PINs producing local JWTs via `golang-jwt/jwt` v5.
|
||||||
|
|
||||||
|
The SvelteKit frontend uses Svelte 5 with runes (the new reactivity model replacing stores) and `adapter-static` to prerender a SPA embedded in the Go binary. Catppuccin Mocha provides the dark theme via CSS custom properties from `@catppuccin/palette`. The operator UI follows a mobile-first bottom-tab layout with FAB quick actions, persistent clock header, and 48px touch targets.
|
||||||
|
|
||||||
|
The poker domain has several non-trivial algorithmic areas: ICM calculation (factorial complexity, needs Monte Carlo approximation beyond ~15 players), blind structure wizard (backward calculation from target duration to level progression), table balancing (TDA-compliant seat assignment with blind position awareness), and PKO bounty chain tracking (half bounty to hitman, half added to own bounty).
|
||||||
|
|
||||||
|
**Primary recommendation:** Build as a monolithic Go binary with clean internal package boundaries (clock, financial, seating, player, audit). Use event-sourced state changes written to both NATS JetStream (for real-time broadcast) and LibSQL (for persistence and query). All financial math uses int64 cents with a CI gate test proving zero-deviation payout sums.
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
- **Template-first creation** — TD picks from saved tournament templates, everything pre-fills, tweak what's needed for tonight, hit Start
|
||||||
|
- **Templates are compositions of reusable building blocks** — a template is NOT a monolithic config. It references: Chip set, Blind structure, Payout structure, Buy-in config, Points formula (venue-level, reused across seasons)
|
||||||
|
- **Local changes by default** — when creating a tournament from a template, the TD gets a copy. Edits only affect that tournament
|
||||||
|
- **Dedicated template management area** — create from scratch, duplicate/edit existing, save tournament config as new template
|
||||||
|
- **Built-in starter templates** ship with the app (Turbo, Standard, Deep Stack, WSOP-style)
|
||||||
|
- **Structure wizard** lives in template management — input player count, starting chips, duration, denominations -> generates a blind structure
|
||||||
|
- **Minimum player threshold** — configured in tournament metadata, Start button unavailable until met
|
||||||
|
- **Chip bonuses** — configurable per tournament: early signup bonus, punctuality bonus
|
||||||
|
- **Late registration soft lock with admin override** — when cutoff hits, registration locks but admin can push through (logged in audit trail)
|
||||||
|
- **Payout structure as standalone reusable building block** — entry-count brackets with tiered prizes
|
||||||
|
- **Entry count = unique entries only** — not rebuys or add-ons
|
||||||
|
- **Prize rounding** — round down to nearest venue-configured denomination (e.g. 50 DKK, EUR 5)
|
||||||
|
- **Bubble prize** — fast and prominent, "Add bubble prize" easily accessible, funded by shaving top prizes
|
||||||
|
- **Overview tab priority** — Clock > Time to break > Player count > Table balance > Financial summary > Activity feed
|
||||||
|
- **Bust-out flow** — tap Bust -> pick table -> pick seat -> verify name -> confirm -> select hitman -> done
|
||||||
|
- **PKO (Progressive Knockout)** — bounty transfer part of bust flow (half to hitman, half added to own bounty)
|
||||||
|
- **Buy-in flow** — search/select player -> auto-seat -> TD override option -> confirm -> receipt
|
||||||
|
- **Multi-tournament switching** — tabs at top (phone) or split view (tablet landscape)
|
||||||
|
- **Undo is critical** — bust-outs, rebuys, add-ons, buy-ins can all be undone with full re-ranking
|
||||||
|
- **Oval table view (default)** — top-down view with numbered seats, switchable to list view
|
||||||
|
- **Balancing is TD-driven, system-assisted** — system alerts, TD requests suggestion, TD announces, assistant reports, two-tap recording
|
||||||
|
- **Break Table is fully automatic** — system distributes evenly, TD sees result
|
||||||
|
- **No drag-and-drop in Phase 1** — tap-tap flow for all moves
|
||||||
|
- **Flexible chop/deal support** — ICM, custom split, partial chop, any number of players
|
||||||
|
- **Prize money and league positions are independent**
|
||||||
|
- **Tournament auto-closes** when one player remains
|
||||||
|
- **Receipts configurable per venue** — off / digital / print / both
|
||||||
|
- **Operator is the Tournament Director (TD)** — use this term consistently
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
|
||||||
|
- Loading skeleton and animation design
|
||||||
|
- Exact spacing, typography, and component sizing
|
||||||
|
- Error state handling and messaging
|
||||||
|
- Toast notification behavior and timing
|
||||||
|
- Activity feed formatting
|
||||||
|
- Thermal printer receipt layout
|
||||||
|
- Internal data structures and state management patterns
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
|
||||||
|
- **Drag-and-drop seat moves** — future Phase 1 enhancement or later
|
||||||
|
- **PWA seat move notifications** — Phase 2
|
||||||
|
- **"Keep apart" player marking** — evaluate for Phase 1 or later
|
||||||
|
- **Chip bonus for early signup/punctuality** — captured in decisions, evaluate complexity during planning
|
||||||
|
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| ARCH-01 | Single Go binary on x86 LXC with embedded LibSQL, NATS JetStream, WebSocket hub | go-libsql local-only mode (`sql.Open("libsql", "file:"+path)`), embedded nats-server with JetStream, coder/websocket hub pattern |
|
||||||
|
| ARCH-03 | All financial values stored as int64 cents — never float64 | Standard Go int64 arithmetic, CI gate test pattern documented |
|
||||||
|
| ARCH-04 | NATS JetStream embedded with `sync_interval: always` for durability | Jepsen 2.12.1 finding confirms mandatory for single-node; embedded server opts `JetStream: true` + sync config |
|
||||||
|
| ARCH-05 | WebSocket hub broadcasts within 100ms | coder/websocket concurrent writes, custom hub with connection registry pattern |
|
||||||
|
| ARCH-06 | SvelteKit frontend via `//go:embed` | adapter-static + `//go:embed all:build` + SPA fallback handler pattern |
|
||||||
|
| ARCH-07 | Leaf is sovereign — cloud never required | Local LibSQL file, embedded NATS, all logic in Go binary |
|
||||||
|
| ARCH-08 | Append-only audit trail for every state-changing action | Event-sourced audit table in LibSQL + NATS JetStream publish for real-time |
|
||||||
|
| AUTH-01 | Operator PIN login -> local JWT (bcrypt in LibSQL) | golang-jwt/jwt v5 + golang.org/x/crypto/bcrypt, offline-capable |
|
||||||
|
| AUTH-03 | Operator roles: Admin, Floor, Viewer | JWT claims with role field, middleware enforcement on chi routes |
|
||||||
|
| CLOCK-01 | Countdown timer per level, second-granularity display, ms-precision internal | Go time.Ticker + monotonic clock, server-authoritative time |
|
||||||
|
| CLOCK-02 | Separate break durations with distinct visual treatment | Level type enum (round/break), frontend conditional styling |
|
||||||
|
| CLOCK-03 | Pause/resume with visual indicator across all displays | Clock state machine (running/paused/stopped), WebSocket broadcast |
|
||||||
|
| CLOCK-04 | Manual advance forward/backward between levels | Clock engine level index navigation |
|
||||||
|
| CLOCK-05 | Jump to any level by number | Direct level index set on clock engine |
|
||||||
|
| CLOCK-06 | Total elapsed time display | Tracked from tournament start, pauses excluded |
|
||||||
|
| CLOCK-07 | Configurable warning thresholds with audio/visual | Threshold config per level, play_sound action on WebSocket, CSS animation triggers |
|
||||||
|
| CLOCK-08 | Clock state authoritative on Leaf; 1/sec normal, 10/sec final 10s | Server-side tick emitter with dynamic interval |
|
||||||
|
| CLOCK-09 | Reconnecting clients receive full clock state | WebSocket connection handler sends current snapshot on connect |
|
||||||
|
| BLIND-01 | Unlimited configurable levels (round/break, game type, SB/BB, ante, duration, chip-up, notes) | Level struct with all fields, array in blind structure entity |
|
||||||
|
| BLIND-02 | Big Blind Ante support alongside standard ante | Separate ante and bb_ante fields per level |
|
||||||
|
| BLIND-03 | Mixed game rotation (HORSE, 8-Game) | Game type per level, rotation sequence in structure |
|
||||||
|
| BLIND-04 | Save/load reusable blind structure templates | Template CRUD in LibSQL, building block pattern |
|
||||||
|
| BLIND-05 | Built-in templates (Turbo, Standard, Deep Stack, WSOP-style) | Seed data on first boot |
|
||||||
|
| BLIND-06 | Structure wizard | Algorithm: target duration -> final BB -> geometric curve -> nearest chip denominations |
|
||||||
|
| CHIP-01 | Define denominations with colors (hex) and values | Chip set entity (venue-level building block) |
|
||||||
|
| CHIP-02 | Chip-up tracking per break | Level flag + chip-up denomination reference |
|
||||||
|
| CHIP-03 | Total chips in play calculation | Sum of (active players * starting chips) + rebuys + add-ons |
|
||||||
|
| CHIP-04 | Average stack display | Total chips / remaining players |
|
||||||
|
| FIN-01 | Buy-in configuration (amount, chips, rake, bounty, points) | Buy-in config entity as building block, int64 cents |
|
||||||
|
| FIN-02 | Multiple rake categories (staff, league, house) | Rake split array in buy-in config |
|
||||||
|
| FIN-03 | Late registration cutoff (by level, by time, or both) | Cutoff config with dual-condition logic |
|
||||||
|
| FIN-04 | Re-entry support (distinct from rebuy) | Separate transaction type, new entry after bust |
|
||||||
|
| FIN-05 | Rebuy configuration (cost, chips, rake, points, limits, cutoff, chip threshold) | Rebuy config entity with all parameters |
|
||||||
|
| FIN-06 | Add-on configuration (cost, chips, rake, points, window) | Add-on config entity |
|
||||||
|
| FIN-07 | Fixed bounty system (cost, chip, hitman tracking, chain tracking, cash-out) | PKO bounty entity per player, half-split on elimination, chain tracked in audit trail |
|
||||||
|
| FIN-08 | Prize pool auto-calculation | Sum of all entries * entry amount - total rake, int64 cents |
|
||||||
|
| FIN-09 | Guaranteed pot support (house covers shortfall) | Guarantee config field, shortfall = guarantee - actual pool |
|
||||||
|
| FIN-10 | Payout structures (percentage, fixed, custom) with rounding | Payout building block, round-down to venue denomination, bracket selection by entry count |
|
||||||
|
| FIN-11 | Chop/deal support (ICM, chip-chop, even-chop, custom) | ICM: Malmuth-Harville algorithm (Monte Carlo for >15 players), custom split UI |
|
||||||
|
| FIN-12 | End-of-season withholding | Rake category marked as season reserve |
|
||||||
|
| FIN-13 | Every financial action generates receipt | Transaction log with receipt rendering |
|
||||||
|
| FIN-14 | Transaction editing with audit trail and receipt reprint | Undo/edit creates new audit entry referencing original |
|
||||||
|
| PLYR-01 | Player database persistent on Leaf (LibSQL) | LibSQL local file, player table with UUID PK |
|
||||||
|
| PLYR-02 | Search with typeahead, merge duplicates, import CSV | FTS5 (SQLite full-text search) for typeahead, merge logic, CSV import endpoint |
|
||||||
|
| PLYR-03 | QR code generation per player | Go QR library (e.g. skip2/go-qrcode), encodes player UUID |
|
||||||
|
| PLYR-04 | Buy-in flow (search -> confirm -> auto-seat -> receipt -> display update) | Multi-step transaction: financial entry + seat assignment + WebSocket broadcast |
|
||||||
|
| PLYR-05 | Bust-out flow (select -> hitman -> bounty -> rank -> rebalance -> display) | Multi-step: bust record + bounty transfer + re-rank + balance check + broadcast |
|
||||||
|
| PLYR-06 | Undo for bust-out, rebuy, add-on, buy-in with re-ranking | Reverse transaction in audit trail, recalculate rankings and balances |
|
||||||
|
| PLYR-07 | Per-player tracking (chips, time, seat, moves, rebuys, add-ons, bounties, prize, points, net, history) | Computed from transaction log and current state |
|
||||||
|
| SEAT-01 | Tables with configurable seat counts (6-max to 10-max) | Table entity with seat_count, name/label |
|
||||||
|
| SEAT-02 | Table blueprints (save venue layout) | Blueprint entity as venue-level template |
|
||||||
|
| SEAT-03 | Dealer button tracking | Button position field per table, advanced on bust/balance |
|
||||||
|
| SEAT-04 | Random initial seating on buy-in (fills evenly) | Random seat assignment algorithm: find table with fewest players, random empty seat |
|
||||||
|
| SEAT-05 | Auto balancing suggestions with operator confirmation | TDA-compliant algorithm: size difference threshold, move fairness, button awareness, dry-run preview |
|
||||||
|
| SEAT-06 | Drag-and-drop manual moves on touch interface | DEFERRED in Phase 1 — tap-tap flow instead |
|
||||||
|
| SEAT-07 | Break Table action (dissolve and distribute) | Distribute players from broken table to remaining tables evenly, respecting blind position |
|
||||||
|
| SEAT-08 | Visual top-down table layout, list view, movement screen | Oval SVG table component, list view alternative, move confirmation screen |
|
||||||
|
| SEAT-09 | Hand-for-hand mode | Clock pause + per-hand level decrement mode |
|
||||||
|
| MULTI-01 | Multiple simultaneous tournaments with independent state | Tournament-scoped state (clock, financials, players all keyed by tournament ID) |
|
||||||
|
| MULTI-02 | Tournament lobby view | List of active tournaments with status summary |
|
||||||
|
| UI-01 | Mobile-first bottom tab bar (Overview, Players, Tables, Financials, More) | SvelteKit layout with bottom nav component |
|
||||||
|
| UI-02 | FAB for quick actions (Bust, Buy In, Rebuy, Add-On, Pause/Resume) | Expandable FAB component with context-aware actions |
|
||||||
|
| UI-03 | Persistent header (clock, level, blinds, player count) | Fixed header component subscribing to clock WebSocket |
|
||||||
|
| UI-04 | Desktop/laptop sidebar navigation | Responsive layout: bottom tabs on mobile, sidebar on desktop |
|
||||||
|
| UI-05 | Catppuccin Mocha dark theme (default), Latte light theme | @catppuccin/palette CSS custom properties, theme toggle |
|
||||||
|
| UI-06 | 48px minimum touch targets, press-state animations, loading states | CSS touch-action, :active states, skeleton loading |
|
||||||
|
| UI-07 | Toast notifications (success, info, warning, error) | Svelte toast store/rune with auto-dismiss timer |
|
||||||
|
| UI-08 | Data tables with sort, sticky header, search/filter, swipe actions | Custom table component or thin wrapper, virtual scroll for large lists |
|
||||||
|
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| Go | 1.23+ | Backend language | Current stable, required for latest stdlib features |
|
||||||
|
| github.com/tursodatabase/go-libsql | commit pin (no tags) | Embedded LibSQL database | SQLite-compatible, local-first, CGO required, `sql.Open("libsql", "file:"+path)` |
|
||||||
|
| github.com/nats-io/nats-server/v2 | v2.12.4 | Embedded NATS server with JetStream | Event durability, append-only streams, embedded in Go process |
|
||||||
|
| github.com/nats-io/nats.go | latest (v1.49+) | NATS Go client + jetstream package | New JetStream API (replaces legacy JetStreamContext) |
|
||||||
|
| github.com/coder/websocket | v1.8.14 | WebSocket server | Minimal, idiomatic, context.Context support, concurrent writes, zero deps |
|
||||||
|
| github.com/go-chi/chi/v5 | v5.2.5 | HTTP router | Lightweight, stdlib-compatible middleware, route grouping |
|
||||||
|
| github.com/golang-jwt/jwt/v5 | v5.x (latest) | JWT tokens | Standard Go JWT library, 13K+ importers, HS256 signing |
|
||||||
|
| golang.org/x/crypto | latest | bcrypt for PIN hashing | Standard library extension, production-grade bcrypt |
|
||||||
|
| Svelte | 5.46+ | Frontend framework | Runes reactivity model, compiled output, small bundle |
|
||||||
|
| SvelteKit | 2.x | Frontend framework/router | adapter-static for SPA, file-based routing, SSR-capable |
|
||||||
|
| @sveltejs/adapter-static | latest | SPA build adapter | Prerenders all pages to static HTML for go:embed |
|
||||||
|
| @catppuccin/palette | latest | Theme colors | Official Catppuccin color palette as CSS/JS, Mocha + Latte flavors |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| github.com/skip2/go-qrcode | latest | QR code generation | PLYR-03: Player QR codes for self-check-in |
|
||||||
|
| github.com/google/uuid | latest | UUID generation | Player IDs, tournament IDs, all entity PKs |
|
||||||
|
| FTS5 (built into LibSQL/SQLite) | N/A | Full-text search | PLYR-02: Typeahead player search |
|
||||||
|
| @catppuccin/tailwindcss | latest | Tailwind theme plugin | Only if using Tailwind; otherwise use raw CSS vars from @catppuccin/palette |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| go-chi/chi v5 | Go 1.22+ stdlib ServeMux | Go 1.22 added method+path matching but chi has better middleware composition, route grouping, and ecosystem |
|
||||||
|
| coder/websocket | gorilla/websocket | gorilla is more battle-tested (6+ years) but coder/websocket is more idiomatic (context support, concurrent writes) and actively maintained by Coder |
|
||||||
|
| go-libsql | mattn/go-sqlite3 | go-sqlite3 is more mature with tagged releases but doesn't support libSQL extensions and eventual Turso sync in Phase 3 |
|
||||||
|
| SvelteKit adapter-static | Vite vanilla SPA | SvelteKit provides file-based routing, layouts, and SSR-capability for Phase 2+ |
|
||||||
|
|
||||||
|
**Installation (Go):**
|
||||||
|
```bash
|
||||||
|
# go.mod — pin go-libsql to specific commit
|
||||||
|
go get github.com/tursodatabase/go-libsql@<commit-hash>
|
||||||
|
go get github.com/nats-io/nats-server/v2@v2.12.4
|
||||||
|
go get github.com/nats-io/nats.go@latest
|
||||||
|
go get github.com/coder/websocket@v1.8.14
|
||||||
|
go get github.com/go-chi/chi/v5@v5.2.5
|
||||||
|
go get github.com/golang-jwt/jwt/v5@latest
|
||||||
|
go get golang.org/x/crypto@latest
|
||||||
|
go get github.com/skip2/go-qrcode@latest
|
||||||
|
go get github.com/google/uuid@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Installation (Frontend):**
|
||||||
|
```bash
|
||||||
|
npm create svelte@latest frontend -- --template skeleton
|
||||||
|
cd frontend
|
||||||
|
npm install @catppuccin/palette
|
||||||
|
npm install -D @sveltejs/adapter-static
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
felt/
|
||||||
|
├── cmd/
|
||||||
|
│ └── leaf/
|
||||||
|
│ └── main.go # Entry point: starts NATS, LibSQL, HTTP server
|
||||||
|
├── internal/
|
||||||
|
│ ├── server/
|
||||||
|
│ │ ├── server.go # HTTP server setup, chi router, middleware
|
||||||
|
│ │ ├── ws/
|
||||||
|
│ │ │ └── hub.go # WebSocket hub: connection registry, broadcast
|
||||||
|
│ │ └── middleware/
|
||||||
|
│ │ ├── auth.go # JWT validation middleware
|
||||||
|
│ │ └── role.go # Role-based access control
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── pin.go # PIN login, bcrypt verify, JWT issue
|
||||||
|
│ │ └── jwt.go # Token creation, validation, claims
|
||||||
|
│ ├── clock/
|
||||||
|
│ │ ├── engine.go # Clock state machine (running/paused/stopped)
|
||||||
|
│ │ ├── ticker.go # Server-side tick emitter (1/sec, 10/sec final 10s)
|
||||||
|
│ │ └── warnings.go # Threshold detection and alert emission
|
||||||
|
│ ├── tournament/
|
||||||
|
│ │ ├── tournament.go # Tournament lifecycle (create, start, close)
|
||||||
|
│ │ ├── state.go # Tournament state aggregation
|
||||||
|
│ │ └── multi.go # Multi-tournament management
|
||||||
|
│ ├── financial/
|
||||||
|
│ │ ├── engine.go # Buy-in, rebuy, add-on, bounty transactions
|
||||||
|
│ │ ├── payout.go # Prize pool calculation, payout distribution
|
||||||
|
│ │ ├── icm.go # ICM calculator (Malmuth-Harville + Monte Carlo)
|
||||||
|
│ │ ├── chop.go # Deal/chop flows (ICM, chip-chop, custom)
|
||||||
|
│ │ └── receipt.go # Receipt generation
|
||||||
|
│ ├── player/
|
||||||
|
│ │ ├── player.go # Player CRUD, search, merge
|
||||||
|
│ │ ├── ranking.go # Live rankings, re-ranking on undo
|
||||||
|
│ │ └── qrcode.go # QR code generation
|
||||||
|
│ ├── seating/
|
||||||
|
│ │ ├── table.go # Table management, seat assignment
|
||||||
|
│ │ ├── balance.go # Table balancing algorithm
|
||||||
|
│ │ ├── breaktable.go # Table break and redistribute
|
||||||
|
│ │ └── blueprint.go # Venue layout blueprints
|
||||||
|
│ ├── blind/
|
||||||
|
│ │ ├── structure.go # Blind structure definition and CRUD
|
||||||
|
│ │ ├── wizard.go # Structure wizard algorithm
|
||||||
|
│ │ └── templates.go # Built-in template seed data
|
||||||
|
│ ├── template/
|
||||||
|
│ │ ├── tournament.go # Tournament template (composition of blocks)
|
||||||
|
│ │ ├── chipset.go # Chip set building block
|
||||||
|
│ │ ├── payout.go # Payout structure building block
|
||||||
|
│ │ └── buyin.go # Buy-in config building block
|
||||||
|
│ ├── audit/
|
||||||
|
│ │ ├── trail.go # Append-only audit log
|
||||||
|
│ │ └── undo.go # Undo engine (reverse operations)
|
||||||
|
│ ├── nats/
|
||||||
|
│ │ ├── embedded.go # Embedded NATS server startup
|
||||||
|
│ │ └── publisher.go # Event publishing to JetStream
|
||||||
|
│ └── store/
|
||||||
|
│ ├── db.go # LibSQL connection setup
|
||||||
|
│ ├── migrations/ # SQL migration files
|
||||||
|
│ └── queries/ # SQL query files (or generated)
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ │ ├── ws.ts # WebSocket client with reconnect
|
||||||
|
│ │ │ ├── api.ts # HTTP API client
|
||||||
|
│ │ │ ├── stores/ # Svelte 5 runes-based state
|
||||||
|
│ │ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ │ └── theme/ # Catppuccin theme setup
|
||||||
|
│ │ ├── routes/
|
||||||
|
│ │ │ ├── +layout.svelte # Root layout (header, tabs, FAB)
|
||||||
|
│ │ │ ├── overview/ # Overview tab
|
||||||
|
│ │ │ ├── players/ # Players tab
|
||||||
|
│ │ │ ├── tables/ # Tables tab
|
||||||
|
│ │ │ ├── financials/ # Financials tab
|
||||||
|
│ │ │ └── more/ # More tab (templates, settings)
|
||||||
|
│ │ └── app.html
|
||||||
|
│ ├── static/
|
||||||
|
│ │ └── sounds/ # Level change, break, bubble sounds
|
||||||
|
│ ├── svelte.config.js
|
||||||
|
│ ├── embed.go # //go:embed all:build
|
||||||
|
│ └── package.json
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Embedded NATS Server with JetStream
|
||||||
|
|
||||||
|
**What:** Start NATS server in-process with JetStream enabled and `sync_interval: always` for single-node durability.
|
||||||
|
**When to use:** Application startup in `cmd/leaf/main.go`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Source: https://github.com/nats-io/nats-server/discussions/6242
|
||||||
|
import (
|
||||||
|
"github.com/nats-io/nats-server/v2/server"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startEmbeddedNATS(dataDir string) (*server.Server, error) {
|
||||||
|
opts := &server.Options{
|
||||||
|
DontListen: true, // In-process only, no TCP listener needed
|
||||||
|
JetStream: true,
|
||||||
|
StoreDir: dataDir,
|
||||||
|
JetStreamMaxMemory: 64 * 1024 * 1024, // 64MB memory limit
|
||||||
|
JetStreamMaxStore: 1024 * 1024 * 1024, // 1GB disk limit
|
||||||
|
JetStreamSyncInterval: 0, // 0 = "always" — fsync every write
|
||||||
|
}
|
||||||
|
|
||||||
|
ns, err := server.NewServer(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create NATS server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go ns.Start()
|
||||||
|
|
||||||
|
if !ns.ReadyForConnections(5 * time.Second) {
|
||||||
|
return nil, errors.New("NATS server startup timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL:** `sync_interval: always` (or `JetStreamSyncInterval: 0`) is mandatory. The Jepsen December 2025 analysis of NATS 2.12.1 found that the default 2-minute fsync interval means recently acknowledged writes exist only in memory and are lost on power failure. For single-node (non-replicated) deployments, every write must fsync immediately.
|
||||||
|
|
||||||
|
### Pattern 2: WebSocket Hub with Connection Registry
|
||||||
|
|
||||||
|
**What:** Central hub managing all WebSocket connections with topic-based broadcasting.
|
||||||
|
**When to use:** Real-time state updates to all connected operator/display clients.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Source: coder/websocket docs + standard hub pattern
|
||||||
|
import "github.com/coder/websocket"
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[*Client]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
tournamentID string // Subscribe to specific tournament
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Broadcast(tournamentID string, msg []byte) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for client := range h.clients {
|
||||||
|
if client.tournamentID == tournamentID || client.tournamentID == "" {
|
||||||
|
select {
|
||||||
|
case client.send <- msg:
|
||||||
|
default:
|
||||||
|
// Client too slow, drop message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On new WebSocket connection — send full state snapshot
|
||||||
|
func (h *Hub) HandleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := websocket.Accept(w, r, nil)
|
||||||
|
if err != nil { return }
|
||||||
|
|
||||||
|
client := &Client{conn: c, send: make(chan []byte, 256)}
|
||||||
|
h.register(client)
|
||||||
|
defer h.unregister(client)
|
||||||
|
|
||||||
|
// Send full state snapshot on connect (CLOCK-09)
|
||||||
|
snapshot := h.getCurrentState(client.tournamentID)
|
||||||
|
client.send <- snapshot
|
||||||
|
|
||||||
|
// Read/write pumps...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: SvelteKit SPA Embedded in Go Binary
|
||||||
|
|
||||||
|
**What:** Build SvelteKit with adapter-static, embed the output, serve with SPA fallback.
|
||||||
|
**When to use:** Serving the frontend from the Go binary.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// svelte.config.js
|
||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html', // SPA fallback
|
||||||
|
precompress: false,
|
||||||
|
strict: true
|
||||||
|
}),
|
||||||
|
paths: {
|
||||||
|
base: '' // Served from root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// frontend/embed.go
|
||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:build
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
func Handler() http.Handler {
|
||||||
|
fsys, _ := fs.Sub(files, "build")
|
||||||
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Try to serve static file first
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(fsys, path); err != nil {
|
||||||
|
// SPA fallback: serve index.html for client-side routing
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Event-Sourced Audit Trail
|
||||||
|
|
||||||
|
**What:** Every state-changing action writes an immutable audit entry to both LibSQL and NATS JetStream.
|
||||||
|
**When to use:** All mutations (buy-in, bust-out, rebuy, clock pause, seat move, etc.).
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AuditEntry struct {
|
||||||
|
ID string `json:"id"` // UUID
|
||||||
|
TournamentID string `json:"tournament_id"`
|
||||||
|
Timestamp int64 `json:"timestamp"` // UnixNano
|
||||||
|
OperatorID string `json:"operator_id"`
|
||||||
|
Action string `json:"action"` // "player.bust", "financial.buyin", etc.
|
||||||
|
TargetID string `json:"target_id"` // Player/table/etc ID
|
||||||
|
PreviousState json.RawMessage `json:"previous_state"`
|
||||||
|
NewState json.RawMessage `json:"new_state"`
|
||||||
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
|
UndoneBy *string `json:"undone_by,omitempty"` // Points to undo entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to both LibSQL (persistence) and NATS JetStream (real-time broadcast)
|
||||||
|
func (a *AuditTrail) Record(ctx context.Context, entry AuditEntry) error {
|
||||||
|
// 1. Insert into LibSQL audit table
|
||||||
|
if err := a.store.InsertAudit(ctx, entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 2. Publish to NATS JetStream for real-time broadcast
|
||||||
|
data, _ := json.Marshal(entry)
|
||||||
|
_, err := a.js.Publish(ctx,
|
||||||
|
fmt.Sprintf("tournament.%s.audit", entry.TournamentID),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: Int64 Financial Math
|
||||||
|
|
||||||
|
**What:** All monetary values stored and calculated as int64 cents. Never float64.
|
||||||
|
**When to use:** Every financial calculation (prize pool, payouts, rake, bounties).
|
||||||
|
|
||||||
|
```go
|
||||||
|
// All money is int64 cents. 1000 = $10.00 or 10.00 EUR or 1000 DKK (10 kr)
|
||||||
|
type Money int64
|
||||||
|
|
||||||
|
func (m Money) String() string {
|
||||||
|
return fmt.Sprintf("%d.%02d", m/100, m%100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prize pool: sum of all entry fees minus rake
|
||||||
|
func CalculatePrizePool(entries []Transaction) Money {
|
||||||
|
var pool Money
|
||||||
|
for _, e := range entries {
|
||||||
|
pool += e.PrizeContribution // Already int64 cents
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payout with round-down to venue denomination
|
||||||
|
func CalculatePayouts(pool Money, structure []PayoutTier, roundTo Money) []Money {
|
||||||
|
payouts := make([]Money, len(structure))
|
||||||
|
var distributed Money
|
||||||
|
for i, tier := range structure {
|
||||||
|
raw := Money(int64(pool) * int64(tier.BasisPoints) / 10000)
|
||||||
|
payouts[i] = (raw / roundTo) * roundTo // Round down
|
||||||
|
distributed += payouts[i]
|
||||||
|
}
|
||||||
|
// Remainder goes to 1st place (standard poker convention)
|
||||||
|
payouts[0] += pool - distributed
|
||||||
|
return payouts
|
||||||
|
}
|
||||||
|
|
||||||
|
// CI GATE TEST: sum of payouts must exactly equal prize pool
|
||||||
|
func TestPayoutSumEqualsPool(t *testing.T) {
|
||||||
|
// ... property-based test across thousands of random inputs
|
||||||
|
// assert: sum(payouts) == pool, always, zero deviation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **float64 for money:** Floating-point representation causes rounding errors. A pool of 10000 cents split 3 ways (3333 + 3333 + 3334) is exact in int64. With float64, 100.00/3 = 33.333... causes cumulative errors across a tournament.
|
||||||
|
- **Client-authoritative clock:** The clock MUST be server-authoritative. Client clocks drift, can be manipulated, and diverge across devices. Server sends ticks, clients display.
|
||||||
|
- **Mutable audit trail:** Audit entries must be append-only. "Undo" creates a NEW entry that references the original, never deletes or modifies existing entries.
|
||||||
|
- **Blocking WebSocket writes:** Never write to a WebSocket connection synchronously in the broadcast loop. Use buffered channels per client with drop semantics for slow consumers.
|
||||||
|
- **Global state instead of tournament-scoped:** Every piece of state (clock, players, tables, financials) must be scoped to a tournament ID. Global singletons break MULTI-01.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Password hashing | Custom hash function | golang.org/x/crypto/bcrypt | Bcrypt handles salt generation, timing-safe comparison, configurable cost |
|
||||||
|
| JWT token management | Custom token format | golang-jwt/jwt/v5 | Standard claims, expiry, signing algorithm negotiation |
|
||||||
|
| QR code generation | Manual QR encoding | skip2/go-qrcode | Reed-Solomon error correction, multiple output formats |
|
||||||
|
| WebSocket protocol | Raw TCP upgrade | coder/websocket | RFC 6455 compliance, compression, ping/pong, close handshake |
|
||||||
|
| SQLite FTS | Custom search index | FTS5 (built into LibSQL) | Tokenization, ranking, prefix queries, diacritics handled |
|
||||||
|
| UUID generation | Custom ID scheme | google/uuid | RFC 4122 compliant, v4 random, v7 time-ordered |
|
||||||
|
| Theme colors | Manual hex values | @catppuccin/palette | 26 colors across 4 flavors, community-maintained, consistent |
|
||||||
|
| HTTP routing + middleware | Custom mux | go-chi/chi v5 | Middleware composition, route grouping, stdlib compatible |
|
||||||
|
|
||||||
|
**Key insight:** The complexity in this phase is in the poker domain logic (ICM, balancing, blind wizard, PKO chains), not in infrastructure. Use battle-tested libraries for all infrastructure concerns so implementation time is spent on domain logic.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: NATS JetStream Default sync_interval Loses Data
|
||||||
|
|
||||||
|
**What goes wrong:** Acknowledged writes exist only in OS page cache for up to 2 minutes. Power failure loses all recent tournament state.
|
||||||
|
**Why it happens:** NATS defaults to `sync_interval: 2m` for performance, relying on cluster replication for durability. Single-node deployments have no replication.
|
||||||
|
**How to avoid:** Set `sync_interval: always` (or `JetStreamSyncInterval: 0` in Go opts) on every embedded NATS instance. Accept the ~10-20% write throughput reduction.
|
||||||
|
**Warning signs:** Tests pass because no simulated power failure; first real power cut at a venue loses the last 2 minutes of tournament data.
|
||||||
|
|
||||||
|
### Pitfall 2: go-libsql Unpinned Dependency Breaks Build
|
||||||
|
|
||||||
|
**What goes wrong:** `go get github.com/tursodatabase/go-libsql@latest` resolves to a different commit on different days. Breaking changes arrive silently.
|
||||||
|
**Why it happens:** go-libsql has no tagged releases. Go modules fall back to pseudo-versions based on commit hash.
|
||||||
|
**How to avoid:** Pin to a specific commit hash in go.mod. Test thoroughly before updating. Document the pinned commit with a comment.
|
||||||
|
**Warning signs:** CI builds succeed locally but fail in a clean environment, or vice versa.
|
||||||
|
|
||||||
|
### Pitfall 3: ICM Calculation Factorial Explosion
|
||||||
|
|
||||||
|
**What goes wrong:** Exact Malmuth-Harville ICM calculation for 20+ players takes minutes or hours. UI freezes during chop negotiation.
|
||||||
|
**Why it happens:** ICM exhausts all possible finish permutations. For N players this is O(N!) — 15 players = 1.3 trillion permutations.
|
||||||
|
**How to avoid:** Use exact calculation for <= 10 players. Use Monte Carlo sampling (Tysen Streib method, 2011) for 11+ players with 100K iterations — converges to <0.1% error in under 1 second.
|
||||||
|
**Warning signs:** Chop calculation works in testing (4-5 players) but hangs in production with 8+ players.
|
||||||
|
|
||||||
|
### Pitfall 4: Payout Rounding Creates Money from Nothing
|
||||||
|
|
||||||
|
**What goes wrong:** Prize pool is 10000 cents, payouts sum to 10050 cents due to rounding up. Venue pays out more than collected.
|
||||||
|
**Why it happens:** Rounding each payout independently can round up. Multiple round-ups accumulate.
|
||||||
|
**How to avoid:** Always round DOWN to venue denomination. Assign remainder to 1st place. CI gate test: assert `sum(payouts) == prize_pool` for every possible input combination.
|
||||||
|
**Warning signs:** Payout sum doesn't match prize pool in edge cases (odd numbers, large fields, unusual denominations).
|
||||||
|
|
||||||
|
### Pitfall 5: Clock Drift Between Server and Client
|
||||||
|
|
||||||
|
**What goes wrong:** Timer shows different values on different devices. Operator's phone says 45 seconds, display says 43 seconds.
|
||||||
|
**Why it happens:** Client-side timers using `setInterval` drift due to JS event loop scheduling, tab throttling, and device sleep.
|
||||||
|
**How to avoid:** Server is authoritative. Send absolute state (level, remaining_ms, is_paused, server_timestamp) not relative ticks. Client calculates display from server time, correcting for known RTT. Re-sync on every WebSocket message.
|
||||||
|
**Warning signs:** Devices show slightly different times; discrepancy grows over long levels.
|
||||||
|
|
||||||
|
### Pitfall 6: Undo Doesn't Re-Rank Correctly
|
||||||
|
|
||||||
|
**What goes wrong:** Undoing a bust-out puts the player back but rankings are wrong. Players who busted after them have incorrect positions.
|
||||||
|
**Why it happens:** Rankings are calculated at bust time. Undoing a bust requires recalculating ALL rankings from that point forward.
|
||||||
|
**How to avoid:** Rankings should be derived from the ordered bust-out list, not stored as independent values. Undo removes from bust list, all subsequent positions shift. Re-derive, don't patch.
|
||||||
|
**Warning signs:** Rankings look correct for simple undo (last bust) but break for earlier undos.
|
||||||
|
|
||||||
|
### Pitfall 7: Table Balancing Race Condition
|
||||||
|
|
||||||
|
**What goes wrong:** System suggests "move Player A from Table 1 to Table 4". Before TD executes, Player B busts from Table 1. Now the move makes tables unbalanced in the other direction.
|
||||||
|
**Why it happens:** Time gap between suggestion and execution. Real tournaments have concurrent busts during hands in play.
|
||||||
|
**How to avoid:** The CONTEXT.md already addresses this: "Suggestion is live and adaptive — if Table 1 loses a player before the move happens, system recalculates or cancels." Implement suggestions as pending proposals that are re-validated before execution.
|
||||||
|
**Warning signs:** TD executes a stale balancing suggestion and tables end up worse than before.
|
||||||
|
|
||||||
|
### Pitfall 8: SvelteKit adapter-static Path Resolution
|
||||||
|
|
||||||
|
**What goes wrong:** Routes work in development but return 404 in production when embedded in Go binary.
|
||||||
|
**Why it happens:** SvelteKit adapter-static generates files like `build/players/index.html`. The Go file server doesn't automatically resolve `/players` to `/players/index.html`.
|
||||||
|
**How to avoid:** Configure `fallback: 'index.html'` in adapter-static config for SPA mode. The Go handler falls back to index.html for any path not found as a static file, letting SvelteKit's client-side router handle navigation.
|
||||||
|
**Warning signs:** Direct navigation to routes works, but refresh on a nested route returns 404.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### LibSQL Local Database Setup
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Source: https://github.com/tursodatabase/go-libsql/blob/main/example/local/main.go
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "github.com/tursodatabase/go-libsql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openDB(dataDir string) (*sql.DB, error) {
|
||||||
|
dbPath := filepath.Join(dataDir, "felt.db")
|
||||||
|
db, err := sql.Open("libsql", "file:"+dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open libsql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable WAL mode for concurrent reads during writes
|
||||||
|
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Enable foreign keys
|
||||||
|
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clock Engine State Machine
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ClockState int
|
||||||
|
const (
|
||||||
|
ClockStopped ClockState = iota
|
||||||
|
ClockRunning
|
||||||
|
ClockPaused
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClockEngine struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tournamentID string
|
||||||
|
state ClockState
|
||||||
|
levels []Level
|
||||||
|
currentLevel int
|
||||||
|
remainingNs int64 // Nanoseconds remaining in current level
|
||||||
|
lastTick time.Time // Monotonic clock reference
|
||||||
|
totalElapsed time.Duration
|
||||||
|
warnings []WarningThreshold
|
||||||
|
hub *ws.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClockEngine) Tick() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.state != ClockRunning { return }
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(c.lastTick)
|
||||||
|
c.lastTick = now
|
||||||
|
c.remainingNs -= elapsed.Nanoseconds()
|
||||||
|
c.totalElapsed += elapsed
|
||||||
|
|
||||||
|
// Check for level transition
|
||||||
|
if c.remainingNs <= 0 {
|
||||||
|
c.advanceLevel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check warning thresholds
|
||||||
|
c.checkWarnings()
|
||||||
|
|
||||||
|
// Determine tick rate: 10/sec in final 10s, 1/sec otherwise
|
||||||
|
remainingSec := c.remainingNs / int64(time.Second)
|
||||||
|
msg := c.buildStateMessage()
|
||||||
|
|
||||||
|
// Broadcast to all clients
|
||||||
|
c.hub.Broadcast(c.tournamentID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClockEngine) Snapshot() ClockSnapshot {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return ClockSnapshot{
|
||||||
|
TournamentID: c.tournamentID,
|
||||||
|
State: c.state,
|
||||||
|
CurrentLevel: c.currentLevel,
|
||||||
|
Level: c.levels[c.currentLevel],
|
||||||
|
RemainingMs: c.remainingNs / int64(time.Millisecond),
|
||||||
|
TotalElapsed: c.totalElapsed,
|
||||||
|
ServerTime: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catppuccin Mocha Theme Setup
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Source: https://catppuccin.com/palette/ — Mocha flavor */
|
||||||
|
:root {
|
||||||
|
/* Base */
|
||||||
|
--ctp-base: #1e1e2e;
|
||||||
|
--ctp-mantle: #181825;
|
||||||
|
--ctp-crust: #11111b;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--ctp-text: #cdd6f4;
|
||||||
|
--ctp-subtext1: #bac2de;
|
||||||
|
--ctp-subtext0: #a6adc8;
|
||||||
|
|
||||||
|
/* Surface */
|
||||||
|
--ctp-surface2: #585b70;
|
||||||
|
--ctp-surface1: #45475a;
|
||||||
|
--ctp-surface0: #313244;
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
--ctp-overlay2: #9399b2;
|
||||||
|
--ctp-overlay1: #7f849c;
|
||||||
|
--ctp-overlay0: #6c7086;
|
||||||
|
|
||||||
|
/* Accents */
|
||||||
|
--ctp-rosewater: #f5e0dc;
|
||||||
|
--ctp-flamingo: #f2cdcd;
|
||||||
|
--ctp-pink: #f5c2e7;
|
||||||
|
--ctp-mauve: #cba6f7;
|
||||||
|
--ctp-red: #f38ba8;
|
||||||
|
--ctp-maroon: #eba0ac;
|
||||||
|
--ctp-peach: #fab387;
|
||||||
|
--ctp-yellow: #f9e2af;
|
||||||
|
--ctp-green: #a6e3a1;
|
||||||
|
--ctp-teal: #94e2d5;
|
||||||
|
--ctp-sky: #89dceb;
|
||||||
|
--ctp-sapphire: #74c7ec;
|
||||||
|
--ctp-blue: #89b4fa;
|
||||||
|
--ctp-lavender: #b4befe;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--ctp-base);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Svelte 5 Runes WebSocket State
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: Svelte 5 runes documentation
|
||||||
|
// lib/stores/tournament.svelte.ts
|
||||||
|
|
||||||
|
class TournamentState {
|
||||||
|
clock = $state<ClockSnapshot | null>(null);
|
||||||
|
players = $state<Player[]>([]);
|
||||||
|
tables = $state<Table[]>([]);
|
||||||
|
financials = $state<FinancialSummary | null>(null);
|
||||||
|
activity = $state<ActivityEntry[]>([]);
|
||||||
|
|
||||||
|
get remainingPlayers() {
|
||||||
|
return this.players.filter(p => p.status === 'active').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isBalanced() {
|
||||||
|
if (this.tables.length <= 1) return true;
|
||||||
|
const counts = this.tables.map(t => t.players.length);
|
||||||
|
return Math.max(...counts) - Math.min(...counts) <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(msg: WSMessage) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'clock.tick':
|
||||||
|
this.clock = msg.data;
|
||||||
|
break;
|
||||||
|
case 'player.update':
|
||||||
|
// Reactive update via $state
|
||||||
|
const idx = this.players.findIndex(p => p.id === msg.data.id);
|
||||||
|
if (idx >= 0) this.players[idx] = msg.data;
|
||||||
|
break;
|
||||||
|
// ... other message types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tournament = new TournamentState();
|
||||||
|
```
|
||||||
|
|
||||||
|
### PIN Authentication Flow
|
||||||
|
|
||||||
|
```go
|
||||||
|
// PIN login -> bcrypt verify -> JWT issue
|
||||||
|
func (a *AuthService) Login(ctx context.Context, pin string) (string, error) {
|
||||||
|
// Rate limiting: exponential backoff after 5 failures
|
||||||
|
if blocked := a.rateLimiter.Check(pin); blocked {
|
||||||
|
return "", ErrTooManyAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
operators, err := a.store.GetOperators(ctx)
|
||||||
|
if err != nil { return "", err }
|
||||||
|
|
||||||
|
for _, op := range operators {
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(op.PINHash), []byte(pin)); err == nil {
|
||||||
|
// Match found — issue JWT
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"sub": op.ID,
|
||||||
|
"role": op.Role, // "admin", "floor", "viewer"
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
return token.SignedString(a.signingKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.rateLimiter.RecordFailure(pin)
|
||||||
|
return "", ErrInvalidPIN
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Svelte stores (writable/readable) | Svelte 5 runes ($state, $derived, $effect) | Oct 2024 (Svelte 5.0) | All state management uses runes, stores deprecated for new code |
|
||||||
|
| nats.JetStreamContext (legacy API) | nats.go/jetstream package (new API) | 2024 | Simpler interfaces, pull consumers, better stream management |
|
||||||
|
| gorilla/websocket | coder/websocket (nhooyr fork) | 2024 | Coder maintains the project, context.Context support, concurrent writes |
|
||||||
|
| Go 1.21 stdlib ServeMux | Go 1.22+ enhanced ServeMux | Feb 2024 | Method matching + path params in stdlib, but chi still better for middleware |
|
||||||
|
| Manual ICM calculation | Monte Carlo ICM approximation | 2011 (Streib) | Practical ICM for any field size, converges quickly |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- **Svelte stores for new code:** Still work but Svelte 5 docs recommend runes ($state) for all new code. Stores remain for backward compatibility.
|
||||||
|
- **nats.JetStreamContext:** Legacy API. The `jetstream` package under `nats.go/jetstream` is the replacement. Don't use the old JetStreamContext methods.
|
||||||
|
- **gorilla/websocket as default choice:** Still works but archived/maintenance-mode. coder/websocket is the actively maintained modern alternative.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **go-libsql CGO Cross-Compilation for CI**
|
||||||
|
- What we know: go-libsql requires CGO_ENABLED=1 and precompiled libsql for linux/amd64
|
||||||
|
- What's unclear: Whether CI runners (GitHub Actions) have the right C toolchain pre-installed, or if we need a custom Docker build image
|
||||||
|
- Recommendation: Test early with a minimal CI pipeline. If problematic, use a Debian-based CI image with build-essential.
|
||||||
|
|
||||||
|
2. **go-libsql Exact Commit to Pin**
|
||||||
|
- What we know: No tagged releases. Must pin to commit hash.
|
||||||
|
- What's unclear: Which commit is most stable as of March 2026
|
||||||
|
- Recommendation: Check the repo's main branch, pick the latest commit, verify it passes local tests, pin it with a comment in go.mod.
|
||||||
|
|
||||||
|
3. **NATS JetStream Stream Configuration for Audit Trail**
|
||||||
|
- What we know: JetStream streams need proper retention, limits, and subject patterns
|
||||||
|
- What's unclear: Optimal retention policy (limits vs interest), max message size for audit entries with full state snapshots, disk space planning
|
||||||
|
- Recommendation: Start with limits-based retention (MaxAge: 90 days, MaxBytes: 1GB), adjust based on real tournament data sizes.
|
||||||
|
|
||||||
|
4. **Svelte 5 Runes with SvelteKit adapter-static**
|
||||||
|
- What we know: Svelte 5 runes work for state management, adapter-static produces a SPA
|
||||||
|
- What's unclear: Whether $state runes in .svelte.ts files work correctly with adapter-static's prerendering (runes are runtime, prerendering is build-time)
|
||||||
|
- Recommendation: Use `fallback: 'index.html'` SPA mode to avoid prerendering issues. Runes are client-side only.
|
||||||
|
|
||||||
|
5. **Thermal Printer Receipt Integration**
|
||||||
|
- What we know: FIN-13 requires receipt generation, CONTEXT.md mentions configurable receipt settings
|
||||||
|
- What's unclear: Which thermal printer protocol to target (ESC/POS is standard), whether to generate in Go or browser
|
||||||
|
- Recommendation: Generate receipt as HTML/CSS rendered in a hidden iframe for print. Thermal printer support can use ESC/POS in a later iteration. Digital receipts first.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
|
||||||
|
- [go-libsql GitHub](https://github.com/tursodatabase/go-libsql) — Local-only API (`sql.Open("libsql", "file:"+path)`), CGO requirement, platform support
|
||||||
|
- [go-libsql local example](https://github.com/tursodatabase/go-libsql/blob/main/example/local/main.go) — Verified local-only database opening pattern
|
||||||
|
- [NATS JetStream docs](https://docs.nats.io/nats-concepts/jetstream) — JetStream configuration, sync_interval
|
||||||
|
- [NATS server discussions #6242](https://github.com/nats-io/nats-server/discussions/6242) — Embedded JetStream account setup, PR #6261 fix
|
||||||
|
- [Jepsen NATS 2.12.1](https://jepsen.io/analyses/nats-2.12.1) — sync_interval durability finding, single-node recommendation
|
||||||
|
- [coder/websocket GitHub](https://github.com/coder/websocket) — v1.8.14, API, features
|
||||||
|
- [go-chi/chi GitHub](https://github.com/go-chi/chi) — v5.2.5, middleware, routing
|
||||||
|
- [golang-jwt/jwt v5](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) — JWT library API
|
||||||
|
- [Catppuccin palette](https://catppuccin.com/palette/) — Mocha hex values verified
|
||||||
|
- [SvelteKit adapter-static docs](https://svelte.dev/docs/kit/adapter-static) — Configuration, fallback, prerender
|
||||||
|
- [Svelte 5 runes blog](https://svelte.dev/blog/runes) — $state, $derived, $effect API
|
||||||
|
- [ICM Wikipedia](https://en.wikipedia.org/wiki/Independent_Chip_Model) — Malmuth-Harville algorithm, factorial complexity
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
|
||||||
|
- [Liip blog: Embed SvelteKit into Go](https://www.liip.ch/en/blog/embed-sveltekit-into-a-go-binary) — Complete go:embed + adapter-static pattern, verified with official docs
|
||||||
|
- [Replay Poker table rebalancing](https://replayhelp.casino.org/hc/en-us/articles/360001878254-How-table-rebalancing-works) — TDA-compliant balancing algorithm description
|
||||||
|
- [PokerSoup blind calculator](https://pokersoup.com/tool/blindStructureCalculator) — Blind structure wizard algorithm approach
|
||||||
|
- [Poker TDA 2024 rules](https://www.pokertda.com/poker-tda-rules/) — Official TDA breaking/balancing rules
|
||||||
|
- [GTO Wizard PKO theory](https://blog.gtowizard.com/the-theory-of-progressive-knockout-tournaments/) — PKO bounty mechanics (half-split)
|
||||||
|
- [HoldemResources ICM](https://www.holdemresources.net/blog/high-accuracy-mtt-icm/) — Monte Carlo ICM approximation for large fields
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
|
||||||
|
- [nats-server releases](https://github.com/nats-io/nats-server/releases) — v2.12.4 latest stable (Jan 2026), needs re-check at implementation time
|
||||||
|
- go-libsql latest commit hash — needs to be fetched fresh at implementation time
|
||||||
|
- Svelte exact version — reported as 5.46+ (Jan 2026), may have newer patches
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — All core libraries verified via official GitHub repos, official docs, and package registries
|
||||||
|
- Architecture: HIGH — Patterns (go:embed SPA, embedded NATS, WebSocket hub, event sourcing) verified via multiple official examples and production usage
|
||||||
|
- Pitfalls: HIGH — NATS Jepsen finding verified via official Jepsen report; ICM complexity verified via Wikipedia and academic sources; other pitfalls derived from verified library characteristics
|
||||||
|
- Domain logic: MEDIUM — Poker domain (ICM, balancing, blind wizard) based on community sources and TDA rules, not on verified software implementations in Go
|
||||||
|
|
||||||
|
**Research date:** 2026-03-01
|
||||||
|
**Valid until:** 2026-03-31 (30 days — stack is stable, check go-libsql for new tagged releases)
|
||||||
Loading…
Add table
Reference in a new issue