From 5cdd7b0fa7688660723761d237ac2c08d96a9a13 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 02:10:18 +0100 Subject: [PATCH] docs(01): research phase domain --- .../01-tournament-engine/01-RESEARCH.md | 903 ++++++++++++++++++ 1 file changed, 903 insertions(+) create mode 100644 .planning/phases/01-tournament-engine/01-RESEARCH.md diff --git a/.planning/phases/01-tournament-engine/01-RESEARCH.md b/.planning/phases/01-tournament-engine/01-RESEARCH.md new file mode 100644 index 0000000..b075882 --- /dev/null +++ b/.planning/phases/01-tournament-engine/01-RESEARCH.md @@ -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 (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 + + + + + +## 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 | + + + +## 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@ +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(null); + players = $state([]); + tables = $state([]); + financials = $state(null); + activity = $state([]); + + 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)