feat: SSE live fight spectating with spectator count

Enable real-time fight spectating for all live fights (not just human
fights). Multiple spectators can watch simultaneously via SSE. Spectator
count is tracked per-fight and broadcast with every SSE event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 19:46:39 +00:00
co-authored by Claude Opus 4.6
parent a540320901
commit 610e799605
18 changed files with 1555 additions and 397 deletions
+152
View File
@@ -0,0 +1,152 @@
# Production Hardening v2 — Final Status
## Context
The original hardening plan (`greedy-skipping-lollipop.md`) was 95% complete. A full security audit confirmed the fight engine, scoring, auth, SSRF, rate limiting, and webhook systems were solid. This session addressed remaining production gaps AND added Creator ₿ animation/morph enhancements AND fixed a production ranked-fight bug.
---
## Phase 1: Security Headers & Error Hardening — DONE
### 1.1 Security headers middleware — DONE
**File:** `server/src/app.ts`
- Added `secureHeaders` from `hono/secure-headers` with CSP (production only), X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy
### 1.2 Request body size limit — DONE
**File:** `server/src/app.ts`
- Added `bodyLimit` from `hono/body-limit` — 256KB max for API requests
### 1.3 Hide error details in production — DONE
**File:** `server/src/app.ts`
- `onError` returns generic "Internal server error" in production, full message in dev
---
## Phase 2: Graceful Shutdown & Env Validation — DONE
### 2.1 Graceful shutdown handler — DONE
**File:** `server/src/index.ts`
- SIGTERM/SIGINT handlers wait 10s for in-flight fights, clear pending human challenges, then exit
### 2.2 Export helpers for shutdown — DONE
- `server/src/engine/orchestrator.ts``getActiveFighterCount()`
- `server/src/engine/human-responses.ts``clearAllPending()`
### 2.3 Stricter env validation — DONE
**File:** `server/src/index.ts`
- Warns if CORS_ORIGIN not set in production
---
## Phase 3: Payment Race Condition Fix — DONE
### 3.1 Atomic consumePaymentForQueue — DONE
**File:** `server/src/engine/payments.ts`
- Replaced check-then-set with atomic `UPDATE ... WHERE` (single SQL statement)
- Imported raw `sqlite` from db/index.js
### 3.2 releasePayment reverts DB state — DONE
**File:** `server/src/engine/payments.ts`
- Now also reverts `status` from `consumed` back to `confirmed` in DB
---
## Phase 4: Live Round-by-Round TUI — DONE
### 4.1 onRoundComplete callback — DONE
**File:** `server/src/engine/fight-loop.ts`
- New `onRoundComplete` option, subscribes to `fightEvents.onAll()` for `round_end` events
### 4.2 TUI wired to round updates — DONE
**File:** `server/src/fight-loop-cli.ts`
- `onRoundComplete` updates `state.currentFight` HP/round/challengeType and re-renders
---
## Phase 5: Creator ₿ Animation Enhancements — DONE
### 5.1 Persistent orbiting ₿ letters on entrance — DONE
**File:** `frontend/src/game/FightScene.ts` (Creator entrance)
- Replaced 6 plain circle dots with 10 ₿ text letters in 2 counter-rotating rings
- Varied sizes (7-14px), 5 colors, gentle rocking animation
- Visible for the entire fight
### 5.2 Upgraded omni-morph ₿ orbits — DONE
**File:** `frontend/src/game/FightScene.ts` (`applyCreatorOmniMorph`)
- Increased from 4 to 8 ₿ in dual rings (inner 5, outer counter-rotating 3)
- Varied sizes (10-18px), 3 colors, wobble animation
### 5.3 bitcoinRain uses ₿ text — DONE
**File:** `frontend/src/game/FightScene.ts` (`bitcoinRain`)
- Changed from circles to actual ₿ text with 3 colors, varied sizes (6-18px), spinning as they fall
### 5.4 Four new ₿ swarm choreographies — DONE
**File:** `frontend/src/game/FightScene.ts`
- `bitcoinSwarm` — 18-30 ₿ letters zigzag from Creator to defender
- `satoshiTornado` — 14-24 ₿ spiral in tightening vortex around defender, then explode
- `hodlWave` — wall of ₿ letters (4-6 rows x 7-10 cols) advances like a tidal wave
- `bitcoinBarrage` — rapid-fire stream of 20-35 spinning ₿ from the laptop
- All added to `CREATOR_MOVES` array and `choreographyMap`
---
## Phase 6: Creator Omni-Morph Visual Fix — DONE
### 6.1 Sprite swap on morph — DONE
**File:** `frontend/src/game/FightScene.ts` (`applyCreatorOmniMorph`)
- Was: only added overlays (aura, ₿, color tint) — sprite stayed as Creator
- Now: generates a full sprite sheet for the picked archetype via `generateSpriteSheet(seed, tier, colors, archetypeOverride)`
- Loads it as a Kaplay sprite and swaps with `fighter.use(k.sprite(morphKey))`
- Function changed from sync to `async`
- Unique sprite keys via incrementing counter prevent collisions
### 6.2 Sprite revert after attack — DONE
**File:** `frontend/src/game/FightScene.ts` (morph trigger in playRound)
- `morphRevert` now calls `attacker.use(k.sprite(originalSpriteKey))` to swap back to Creator
- Added `await` on `applyCreatorOmniMorph` call (now async)
---
## Phase 7: Production Ranked Fight Fix — DONE
### 7.1 Missing pubkey in ranked endpoints — DONE
**File:** `frontend/src/composables/useWallet.ts`
- `payEntryFee()` now sends `{ botId, pubkey: pubkey.value }` to `/api/payments/create-invoice`
**File:** `frontend/src/pages/JoinBoutPage.vue`
- `fightRanked()` now sends `{ paymentId, pubkey: pubkey.value }` to `/api/queue/join-ranked`
**Root cause:** Server requires `pubkey` in production for ownership verification, but frontend wasn't sending it. Worked in dev because dev mode skips pubkey checks.
---
## All Files Modified
| File | Phase | Changes |
|------|-------|---------|
| `server/src/app.ts` | 1 | +secureHeaders, +bodyLimit, error hiding |
| `server/src/index.ts` | 2 | +graceful shutdown, +env validation |
| `server/src/engine/orchestrator.ts` | 2 | +getActiveFighterCount() |
| `server/src/engine/human-responses.ts` | 2 | +clearAllPending() |
| `server/src/engine/payments.ts` | 3 | Atomic consume, releasePayment DB revert |
| `server/src/engine/fight-loop.ts` | 4 | +onRoundComplete via fightEvents |
| `server/src/fight-loop-cli.ts` | 4 | Wire onRoundComplete to TUI |
| `frontend/src/game/FightScene.ts` | 5,6 | ₿ animations, omni-morph sprite swap, 4 new choreographies |
| `frontend/src/composables/useWallet.ts` | 7 | +pubkey in create-invoice |
| `frontend/src/pages/JoinBoutPage.vue` | 7 | +pubkey in join-ranked |
| `frontend/src/game/sprites/archetypes/the_creator.ts` | — | No changes (reference only) |
---
## Verification
1. `pnpm build` — verify no type errors (both server and frontend pass)
2. Production server: `curl -I /api/health` — X-Frame-Options, X-Content-Type-Options, CSP headers present
3. Body limit: oversized POST returns 413
4. Error hiding: bad endpoint returns "Internal server error" (not stack trace)
5. Graceful shutdown: `kill -TERM` shows drain log
6. TUI: `pnpm fight-loop --max=3` shows live round-by-round HP updates
7. Payment race: concurrent ranked joins with same paymentId — only 1 succeeds
8. Ranked fights: no more "Missing pubkey" error in production
9. Creator fights: omni-morph visually transforms into random archetypes (sprite swaps)
10. Creator fights: ₿ letters orbit persistently, new swarm attacks fire regularly
+498 -271
View File
@@ -1,330 +1,557 @@
# Botfights: 30-Day Production Plan
# Botfights: 6-Month Development Plan (MarchSeptember 2026)
## Context
## Current State (March 8, 2026)
Botfights is a procedural fighting game (Vue 3 + Kaplay + Hono/SQLite) where AI bots battle through challenge rounds with pixel art sprites, synthesized audio, and choreographed fight animations. Currently a working prototype with 6 character archetypes, 42 choreographies, 4 music tracks, 6 voice profiles, ~45 challenge prompts, and 12 mock bots. The goal is to scale this to production-grade content (100 characters, 300 moves, 10,000 challenges, 20 songs, 30 voices, 30+ arenas) and build an overnight fight loop that runs autonomously.
Botfights is a competitive AI fighting game (Vue 3 + Kaplay + Hono/SQLite) where bots battle through challenge rounds with pixel art sprites, synthesized audio, and choreographed fight animations. The core is feature-complete:
| Category | Count | Status |
|----------|-------|--------|
| Archetypes | 101 (100 + THE CREATOR) | Done |
| Choreographies | 352+ (300 base + 40 tier-gated + 12 creator) | Done |
| Challenge Types | 16 | Done |
| Challenge Prompts | 800 (551 multiple-choice, 249 creative) | Done |
| Mock Bots | 100 (tiers 06) | Done |
| Arenas | 25 with modifiers | Done |
| Voice Profiles | 60+ | Done |
| Music Tracks | 10 procedural synth | Done |
| Sound Effects | 30+ | Done |
| Pages | 20 | Done |
| Nostr Auth | NIP-04/44/19 | Done |
| Payments | NWC + Lightning Address + Cashu | Done |
| PWA | Manifest + SW + offline assets | Done |
| Docker | Multi-stage, 42MB prod image | Done |
| TUI Fight Loop | Live round-by-round | Done |
| Production Hardening | Security headers, SSRF, rate limiting, graceful shutdown | Done |
**FightScene.ts** is 11,313 lines. The codebase works end-to-end but hasn't been battle-tested with real users at scale.
---
## Phase 1: Module Splitting (Days 1-6)
## Month 1: Social Layer & Real-Money Launch (March 9 April 5)
The 3 monolith files must be split before any content scaling. Without this, adding content to 3500-line files is unmaintainable.
Goal: Make botfights a place people want to hang out and bet sats.
### Day 1-2: Split `sprites.ts` (954 lines)
### Week 1: Spectator Experience
```
frontend/src/game/sprites/
index.ts -- re-exports generateSpriteSheet, getBotColors, etc.
palette.ts -- makePal, parseHSL, Pal interface
constants.ts -- FRAME_SIZE, ANIMATIONS, INTERNAL, SCALE
drawing.ts -- px(), box(), fill() drawing primitives
base-body.ts -- drawFrame() core body/head/arms/legs/pose logic
archetypes/
index.ts -- archetype registry + roll function
standard.ts -- base (no extra features)
lobster.ts -- claws, antennae, segmented body, tail
sheep.ts -- wool, floppy ears, hooves
cyborg.ts -- metal plating, robot eye, wiring
blob.ts -- wobbly outline, googly eyes, drool
tank.ts -- armor, rivets, treads
judge.ts -- generateJudgeSpriteSheet()
```
#### 1.1 Live Fight Spectating via SSE
**Files:** `server/src/routes/fights.ts`, `frontend/src/pages/FightPage.vue`
- SSE endpoint streams round results, HP changes, challenge reveals in real-time
- Multiple spectators can watch a live fight simultaneously
- Auto-redirect from fight card to live view when fight starts
- Spectator count badge on active fights
Each archetype exports `{ name, weight, drawFeatures(ctx, pal, params) }`. The base-body calls the archetype's `drawFeatures` after the shared body drawing.
#### 1.2 Fight Feed / Activity Stream
**New file:** `frontend/src/pages/FeedPage.vue`
- Reverse-chronological feed of recent fights: winner, KO/decision, Elo changes
- Filter by tier, arena, bot name
- Click any fight to watch replay
- Replace or augment current HomePage with this
**Test:** Sprite preview page renders all 6 archetypes at all tiers identically to before.
#### 1.3 Reactions / Crowd Noise
**Files:** `frontend/src/components/FightViewer.vue`, `server/src/routes/fights.ts`
- Spectators can send emoji reactions during live fights (fist, fire, skull, 100, clown)
- Reactions appear as floating particles over the fight
- Server aggregates reactions, broadcasts via SSE
- Crowd SFX intensity scales with reaction count
### Day 3-4: Split `FightScene.ts` (~3500 lines)
### Week 2: Betting System UI
#### 2.1 Bet Placement UI
**Files:** `frontend/src/components/BetPanel.vue`, `frontend/src/pages/FightCardPage.vue`
- Pre-fight bet panel: pick winner, set amount (21/100/500/1000 sats)
- Cashu token deposit flow (paste token or scan QR)
- NWC direct-pay option for connected wallets
- Odds display based on Elo difference
- Bet confirmation with payout preview
#### 2.2 Bet Settlement & Payout
**Files:** `server/src/engine/betting.ts`, `server/src/engine/payments.ts`
- Atomic settlement on fight completion
- Winner payout minus 5% house cut (goes to fight pot for ranked matches)
- Cashu token minting for winnings OR Lightning payout
- Refund on cancelled/errored fights
#### 2.3 Betting History
**Files:** `frontend/src/pages/BotProfilePage.vue`, new `frontend/src/components/BetHistory.vue`
- Personal bet history on profile page
- P&L tracking (total wagered, total won, net)
- "Hot streak" and "cold streak" indicators
### Week 3: Nostr Social Integration
#### 3.1 Fight Results to Nostr
**New file:** `server/src/engine/nostr-publish.ts`
- Post NIP-01 kind:1 notes for notable fights (KOs, perfects, upsets, tier changes)
- Include fight replay link, bot names, Elo changes
- Configurable relay list via env var
- Only publish if `BOTFIGHTS_NOSTR_NSEC` is set
#### 3.2 Nostr Profile Enrichment
**Files:** `frontend/src/composables/useNostr.ts`, `frontend/src/pages/BotProfilePage.vue`
- Fetch NIP-05, display name, banner from Nostr relays
- Show Nostr profile picture alongside bot sprite
- Link to user's Nostr profile (njump.me or similar)
#### 3.3 Zap Integration
**Files:** `frontend/src/components/FightViewer.vue`, `server/src/routes/payments.ts`
- "Zap the winner" button after fight ends
- NWC zap flow: create invoice, user approves, confirm
- Zap receipts displayed on bot profile
### Week 4: Ranked Season System
#### 4.1 Season Framework
**New file:** `server/src/engine/seasons.ts`
- Season = 2 weeks, auto-rotates
- Season Elo resets to soft floor (blend of final Elo + 1200 baseline)
- Season leaderboard separate from all-time
- Season rewards: title badges, exclusive arena unlocks
#### 4.2 Season Leaderboard UI
**Files:** `frontend/src/pages/LeaderboardPage.vue`
- Toggle between "This Season" and "All Time"
- Season countdown timer
- Top 3 highlighted with crown/medal sprites
- Season history archive
#### 4.3 Ranked Queue Improvements
**Files:** `server/src/engine/ranked-queue.ts`, `frontend/src/pages/JoinBoutPage.vue`
- Elo-bracket matchmaking (±200 Elo, widens over time)
- Ranked queue status indicator (how many waiting)
- Estimated wait time
- Ranked-only challenges (harder prompts, no multiple choice)
---
## Month 2: Tournaments, Human Play & Content Expansion (April 6 May 3)
Goal: Structured competition and enough content that nothing feels repetitive.
### Week 5: Tournament System
#### 5.1 Tournament Engine
**New files:** `server/src/engine/tournaments.ts`, `server/src/db/schema.ts` (new tables)
- Single-elimination brackets (8, 16, 32 bots)
- Round-robin group stage option
- Entry fee (configurable sats amount) → prize pool
- Auto-advance winners, schedule next round
- DB tables: `tournaments`, `tournament_entries`, `tournament_matches`
#### 5.2 Tournament UI
**New files:** `frontend/src/pages/TournamentPage.vue`, `frontend/src/pages/TournamentListPage.vue`
- Visual bracket display (SVG or canvas)
- Live bracket updates as fights complete
- Tournament lobby: registered bots, countdown to start
- Results page with champion highlight
#### 5.3 Scheduled Tournaments
**Files:** `server/src/engine/fight-loop.ts`, `server/src/engine/tournaments.ts`
- Daily automated tournaments (e.g., 8pm UTC)
- "The Halvening" weekly 32-bot major tournament
- Special themed tournaments (code-golf only, roast-battle only)
- Cron-based scheduling via fight loop
### Week 6: Human vs Bot Improvements
#### 6.1 Human Fight Polish
**Files:** `frontend/src/pages/HumanFightPage.vue`, `server/src/engine/human-responses.ts`
- Fix the TODO in human-responses.ts (currently `pass # TODO: implement`)
- Mobile-optimized input (large buttons for multiple choice, big text area for creative)
- Countdown timer with visual urgency (color shift, shake)
- Immediate feedback after answering (correct/wrong animation)
#### 6.2 Practice Mode
**New file:** `frontend/src/pages/PracticePage.vue`
- Fight against mock bots without Elo impact
- Choose difficulty tier
- Post-fight breakdown: which rounds you won/lost and why
- Suggested improvements
#### 6.3 Human vs Human
**Files:** `server/src/engine/orchestrator.ts`, `server/src/routes/queue.ts`
- Real-time matchmaking for two human players
- Both answer same challenge simultaneously
- WebSocket or SSE for synchronization
- Elo-rated human ladder separate from bot ladder
### Week 7: Content Scaling
#### 7.1 Challenge Prompts → 2,000
**Files:** `server/src/engine/challenges.ts`
- 75 more prompts per existing 16 types = 1,200 new prompts
- Focus on Bitcoin/cypherpunk themed prompts:
- "What year was the Bitcoin whitepaper published?"
- "Explain proof-of-work to a medieval blacksmith"
- "Roast someone who thinks Ethereum is decentralized"
- "Write a haiku about the mempool"
- All factual types get multiple-choice options
- Difficulty tiers: easy (tier 02 fights), medium (34), hard (56)
#### 7.2 Music Tracks → 20
**Files:** `frontend/src/game/sounds.ts`
- 10 new procedural tracks:
- 2x Drum & Bass (170175 BPM)
- 2x Lo-fi Hip Hop (90100 BPM)
- 2x Ska Punk (200220 BPM)
- 2x Boss Fight (140160 BPM, heavy)
- 2x Chiptune (180200 BPM)
- Intensity-grouped: low (lo-fi, chiptune), mid (ska, D&B), high (boss fight)
#### 7.3 Arenas → 40
**Files:** `server/src/engine/arenas.ts`, `frontend/src/game/FightScene.ts`
- 15 new arenas:
- bitcoin_mine (hash_power modifier)
- lightning_node (speed_3x)
- satoshis_garage (legacy_code)
- mempool (chaos_mode)
- silk_road_ruins (trap_heavy)
- block_height_tower (accuracy_buff)
- hodl_cave (endurance_2x)
- whale_pool (high_stakes)
- node_farm (efficiency_buff)
- genesis_block (all_types)
- pizza_day_parlor (food_2x)
- difficulty_adjustment (adaptive)
- 51_percent_arena (chaos_mode)
- timechain_temple (speed_2x)
- nostr_relay_room (crowd_favorite)
- Each with unique theme colors and background elements
### Week 8: Choreography & Animation Polish
#### 8.1 Choreographies → 500
**Files:** `frontend/src/game/FightScene.ts`
- 148 new choreographies across categories:
- 30 Bitcoin-themed (lightning_bolt, hash_smash, block_drop, mempool_flood, difficulty_bomb, fee_spike, utxo_scatter, node_sync, relay_bounce, channel_force_close, etc.)
- 25 more food moves
- 25 more energy/beam moves
- 20 more silly/absurd moves
- 20 more weapon moves
- 15 more vehicle moves
- 13 more animal moves
- Wire into CHALLENGE_THEMED with balanced distribution
#### 8.2 Entrance Animations
**Files:** `frontend/src/game/FightScene.ts`
- 10 new tier-gated entrances:
- Tier 01: simple walk-in
- Tier 2: slide in with dust
- Tier 3: drop from above with impact shake
- Tier 4: teleport with lightning flash
- Tier 5: dramatic slow-mo entrance with camera zoom
- Tier 6: custom per-bot entrance (THE CREATOR golden portal already done)
#### 8.3 Victory Celebrations
**Files:** `frontend/src/game/FightScene.ts`
- Post-fight victory animations tied to finish type:
- KO: winner flexes, camera flash effect
- Perfect: winner does victory lap, confetti rain
- Decision: both bow, crowd cheers
- Upset: dramatic zoom on winner, shocked crowd SFX
---
## Month 3: Scale, Polish & Launch (May 4 May 31)
Goal: Production-ready for real users, real sats, real competition.
### Week 9: FightScene.ts Decomposition
#### 9.1 Split the Monolith
FightScene.ts at 11,313 lines is the single biggest maintenance risk. Split into focused modules:
```
frontend/src/game/fight/
index.ts -- createFightScene() orchestrator, returns controller
types.ts -- FightContext interface (k, W, H, GROUND_Y, HOME_A, HOME_B, helpers)
constants.ts -- ARENA_THEMES, spriteAnims, positions
particles.ts -- spawnSparks, spawnBulletHoles, spawnExhaust, etc.
projectiles.ts -- spawnProjectile, spawnBullet, spawnProp, spawnBarrage
effects.ts -- glitchRGB, scanlineGlitch, vhsTracking, dimensionalShift, etc.
grotesque.ts -- spawnGrotesqueDetails, destroyGrotesqueDetails
arena-decor.ts -- drawArenaDecor() per-arena backgrounds
index.ts -- createFightScene() orchestrator
types.ts -- FightContext, shared interfaces
constants.ts -- arena themes, sprite anims, positions
particles.ts -- all particle spawn functions
projectiles.ts -- projectile spawning and physics
effects.ts -- screen effects (glitch, scanline, VHS, etc.)
grotesque.ts -- close-up detail system
arena.ts -- arena background rendering
entrances.ts -- bot entrance animations
morphs.ts -- morph system (elemental, mech, beast, omni)
choreography/
index.ts -- choreographyMap registry, pickChoreography
challenge-map.ts -- CHALLENGE_THEMED + critMoves
basic.ts -- dashPunch, aerialSlam, flyingKick, uppercut
ranged.ts -- projectile, gunBurst, sniperShot, minigunSpray
movement.ts -- dashThrough, fullScreenDash, teleportStrike
heavy.ts -- groundPound, jetpackDive, bodySlam, suplex
weapons.ts -- swordSlash, hammerSmash, katanaCombo, chainsawRev, whipCrack
vehicles.ts -- motorbikeCharge, carSmash, boatCannon
themed.ts -- golfClubSmash, fireBreath, riddleBarrage, coinShower, etc.
finishers.ts -- playKO (4 styles), playPerfect
index.ts -- choreographyMap, pickChoreography
registry.ts -- CHALLENGE_THEMED, critMoves
basic.ts -- punches, kicks, slams
ranged.ts -- projectiles, guns, beams
heavy.ts -- ground pounds, body slams
weapons.ts -- swords, hammers, etc.
food.ts -- food-themed moves
silly.ts -- absurd/comedy moves
bitcoin.ts -- Bitcoin-themed moves
creator.ts -- THE CREATOR exclusive moves
ultimates.ts -- tier-gated ultimate moves
finishers.ts -- KO styles, perfect finish
round.ts -- playRound, playTaunt, playDodge
camera.ts -- camera shake, zoom, pan
hud.ts -- HP bars, round counter, timer
```
**Critical pattern:** Choreography functions close over Kaplay context. Each file exports functions that take a `FightContext` object with all shared state/helpers.
- Zero behavior changes — pure refactor
- Each module receives FightContext object
- Test: replay 50 fights, verify identical behavior
**Test:** Replay several fights, verify all 42 choreographies work.
### Week 10: Performance & Mobile
### Day 5-6: Split `sounds.ts` (1381 lines) + server modules
#### 10.1 Performance Optimization
- Profile FightScene with Chrome DevTools
- Reduce particle count on mobile (detect via `navigator.maxTouchPoints`)
- Lazy-load Kokoro TTS model (2.2MB) — don't block initial render
- Sprite sheet atlas: batch all archetypes into single texture
- RequestAnimationFrame budget monitoring — drop effects if frame time > 16ms
```
frontend/src/game/sounds/
index.ts -- re-exports
context.ts -- AudioContext setup, gain nodes
primitives.ts -- tone(), noise(), sweep() helpers
sfx.ts -- all sfx functions (punch, kick, special, etc.)
voice.ts -- voice profiles, speak(), announce*(), voice lines
music/
index.ts -- startMusic(), stopMusic(), setMusicIntensity()
types.ts -- MusicTrack interface
player.ts -- playMusicBar() scheduler
tracks/
neon-fury.ts
dark-circuit.ts
pixel-blitz.ts
skull-crusher.ts
#### 10.2 Mobile UX Polish
**Files:** Various frontend components
- Touch-friendly bet panel (large tap targets, swipe gestures)
- Responsive fight viewer (portrait mode layout)
- Bottom navigation bar for mobile
- Haptic feedback on hits (Vibration API)
- Reduced motion mode (prefers-reduced-motion)
server/src/engine/
challenges/
index.ts -- pickChallenge(), registry
types.ts -- Challenge interfaces
speed-blitz.ts -- prompts array
riddle.ts
... (one per type)
mock/
index.ts -- runMockFight(), seedMockFights()
bots.ts -- MOCK_BOTS array
answers.ts -- MOCK_ANSWERS, BAD_ANSWERS
trash-talk.ts -- TRASH_TALK lines
```
#### 10.3 Offline / Weak Connection
- Queue position preserved on reconnect (SSE retry)
- Optimistic UI for bet placement
- Replay cache: save last 10 watched fights for offline replay
- Connection status indicator
**Test:** Full end-to-end fight replay with music, SFX, voice.
### Week 11: Admin, Analytics & Bot Developer Experience
**Deliverable:** Identical functionality, ~40 small focused modules instead of 3 monoliths.
#### 11.1 Admin Dashboard
**New files:** `frontend/src/pages/AdminPage.vue`, `server/src/routes/admin.ts`
- Protected by THE CREATOR pubkey (only admin)
- Live stats: active fights, queue depth, bets in escrow, total sats moved
- Bot management: deactivate, reset Elo, ban
- Challenge management: add/remove prompts, preview scoring
- Fight log viewer with full round details
- System health: DB size, memory, uptime
#### 11.2 Privacy-Respecting Analytics
**New file:** `server/src/engine/analytics.ts`
- No PII, no cookies, no third-party scripts
- Aggregate counts only: fights/day, unique bots/day, sats wagered/day
- Stored in SQLite, queryable from admin dashboard
- Public stats endpoint for transparency: `GET /api/stats/public`
#### 11.3 Bot Developer Docs
**Files:** `frontend/src/pages/DocsPage.vue`
- Interactive API explorer (try endpoints from the browser)
- Webhook payload examples for all 16 challenge types
- Response format with scoring breakdown
- "Build your first bot" tutorial (curl, Python, Node examples)
- Webhook testing tool (send test challenge, see response + score)
### Week 12: Launch Prep & Stress Testing
#### 12.1 Load Testing
- Simulate 100 concurrent spectators on single fight
- Simulate 20 concurrent fights
- Simulate 50 concurrent bet placements
- SQLite WAL mode verification under write contention
- Memory profiling under sustained fight-loop operation (24hr soak test)
#### 12.2 Backup & Recovery
**New file:** `server/src/engine/backup.ts`
- Automated SQLite backup to file (daily rotation, keep 7)
- Export fight history as JSON (for Nostr archival)
- Elo snapshot before season reset (rollback safety)
#### 12.3 Launch Checklist
- [ ] All 16 challenge types tested end-to-end with real webhook
- [ ] NWC payment flow tested with real sats (testnet then mainnet)
- [ ] Cashu token deposit + withdrawal tested
- [ ] Betting flow: place bet → watch fight → receive payout
- [ ] Tournament: create → fill → run → settle prizes
- [ ] Human fight: join queue → answer challenges → see result
- [ ] Mobile: full flow on iOS Safari + Android Chrome
- [ ] PWA: install → offline → reconnect → resume
- [ ] TUI fight loop: 24hr soak test without crash
- [ ] Docker: fresh build → deploy → health check → seed → fight
- [ ] Nostr: fight results published to relay, zaps working
- [ ] Security: rate limiting, SSRF, body limits, error hiding verified
- [ ] THE CREATOR: all exclusive features working (entrance, morphs, cameo, moves)
- [ ] Bundle size < 300KB gzipped (run `npx vite-bundle-visualizer`)
- [ ] Memory: 50-fight replay stays flat (Chrome DevTools heap snapshot)
- [ ] Server: 24hr fight-loop RSS < 250MB
- [ ] Mobile: fight loads in < 2s on throttled 4G
- [ ] SQLite: indexes verified, WAL mode enabled, p95 query < 50ms
---
## Phase 2: Characters (Days 7-12)
## Performance & Footprint (Continuous Thread)
### Day 7-8: New archetypes batch 1 (8 new)
- `dog.ts` -- floppy ears, wagging tail, snout with tongue, collar
- `cat.ts` -- pointed ears, whiskers, curled tail, slit eyes
- `cactus.ts` -- spikes everywhere, small flower on head, no real arms
- `pizza.ts` -- triangular body, cheese drip, pepperoni spots
- `mushroom.ts` -- dome cap head, spotted cap, stubby legs, spore particles
- `shark.ts` -- dorsal fin, teeth row, tail fin
- `penguin.ts` -- tuxedo coloring, beak, flippers
- `octopus.ts` -- 4 visible tentacles, large head, suction cups
Performance is not a one-week task — it's a discipline that runs across all 3 months. Every feature addition must respect these budgets.
### Day 9-10: New archetypes batch 2 (8 more)
- `skeleton.ts` -- visible ribs, skull head, bony limbs
- `ghost.ts` -- semi-transparent, wavy bottom (no legs), glowing eyes
- `alien.ts` -- big head, huge eyes, antenna, green tint
- `dinosaur.ts` -- tail, tiny arms, spiky back, big jaw
- `pirate.ts` -- eye patch, hat, peg leg, hook hand
- `ninja.ts` -- mask, headband, throwing star
- `cowboy.ts` -- hat, bandana, boots with spurs
- `wizard.ts` -- pointed hat, robe, staff replaces one arm, beard
### Budgets
### Day 11: Final archetypes + hybrid system
- `bee.ts` -- stripes, wings, stinger, antennae
- `frog.ts` -- wide mouth, bulging eyes, webbed feet, long tongue
- `snail.ts` -- shell on back, eye stalks, slime trail
- **Hybrid system:** 10% chance a bot combines features from 2 archetypes (lobster+sunglasses, sheep+robot arm, etc.)
- **Total:** 25 archetypes
| Metric | Target | Current Estimate |
|--------|--------|-----------------|
| Initial JS bundle | < 300KB gzipped | Audit needed |
| Kokoro TTS model | Lazy-load, < 2.2MB | 2.2MB (loaded eagerly) |
| Sprite sheet per bot | < 15KB PNG | ~8KB |
| Fight replay memory | < 80MB peak | Unknown (profile needed) |
| Server memory (idle) | < 100MB RSS | ~60MB |
| Server memory (fight-loop 24hr) | < 250MB RSS | Unknown |
| Docker image | < 50MB | 42MB |
| SQLite DB after 10K fights | < 200MB | ~5MB (100 fights) |
| Time to first fight frame | < 2s on 4G | Unknown |
| SSE reconnect | < 1s | Unknown |
### Day 12: 100 Named Bots + Seed Data
Expand `MOCK_BOTS` from 12 to 100 with distribution:
- ~5 tier-5, ~10 tier-4, ~20 tier-3, ~25 tier-2, ~25 tier-1, ~15 tier-0
### Month 1 Performance Tasks
Funny names by theme:
- **Tech:** `segfault_sarah`, `stack_overflow_sam`, `infinite_loop_larry`, `null_reference_nancy`
- **Food:** `pizza_pete`, `sushi_sensei`, `taco_tornado`, `burrito_bomber`
- **Animals:** `mega_lobster_xl`, `tactical_penguin`, `stealth_shark_3000`
- **Pop culture:** `keyboard_warrior`, `meme_lord_420`, `reply_guy_9000`
- **Absurd:** `sentient_toaster`, `angry_calculator`, `philosophical_fork`
#### P1.1 Bundle Audit & Tree-Shaking
- Run `npx vite-bundle-visualizer` to identify bloat
- Verify Kaplay tree-shakes unused modules
- Verify nostr-tools only imports what's needed (not full bundle)
- Code-split routes: lazy-load all pages except HomePage
- Target: identify top 3 bundle offenders and fix
Pre-seed 200 fight history records.
#### P1.2 Kokoro TTS Lazy Loading
**Files:** `frontend/src/game/sounds.ts`
- Don't load the 2.2MB Kokoro model on page load
- Load on first fight start (with loading indicator)
- Cache in service worker after first load
- Fallback to Web Speech API while loading
**Test:** `pnpm seed`, verify 100 bots, leaderboard renders, sprite preview shows all archetypes.
#### P1.3 Sprite Atlas
**Files:** `frontend/src/game/sprites/`
- Currently generates sprite sheets per-bot at fight time
- Pre-generate common archetypes into a shared atlas texture
- Single GPU texture upload instead of per-bot
- Reduces fight init time and GPU memory
---
#### P1.4 SSE Connection Pooling
**Files:** `server/src/routes/fights.ts`
- Ensure SSE connections are properly cleaned up on client disconnect
- Add connection limit per IP (max 5 concurrent SSE streams)
- Heartbeat every 15s to detect dead connections
- Memory: track active SSE count in admin stats
## Phase 3: Choreographies (Days 13-19)
### Month 2 Performance Tasks
Scale from 42 to 300 choreographies. Each follows the established function signature.
#### P2.1 Fight Replay Memory Profiling
- Profile memory during 10-fight replay session
- Identify particle/sprite leaks (Kaplay objects not destroyed)
- Add `fight.destroy()` cleanup that nulls all references
- Test: play 50 fights consecutively, memory should stay flat
### Day 13-14: Food choreographies (~30 moves)
`choreography/food.ts`:
- `pizzaThrow`, `bananaSlip`, `pieFace`, `hotDogWhip`, `sushiRoll`
- `watermelonSmash`, `popcornBarrage`, `burritoWrap`, `cakeExplosion`, `iceCreamFreeze`
- `tacoSlam`, `donutSpin`, `breadSlap`, `eggCrack`, `soupSplash`
- `cheeseWheel`, `grapeShot`, `pineappleGrenade`, `cornCobGatling`, `steakSlap`
- Plus ~10 more food-themed variations
#### P2.2 Choreography Performance Tiers
**Files:** `frontend/src/game/FightScene.ts`
- Tag each choreography with a `particleCount` estimate
- On low-end devices (detect via `navigator.hardwareConcurrency < 4`):
- Reduce particle counts by 60%
- Skip grotesque close-ups
- Use simpler camera movements
- Disable background arena animations
- Toggle in settings: "Performance mode"
### Day 15-16: Spins/flips (~25) + vehicles (~15)
`choreography/spins.ts`:
- `spin720`, `backflipKick`, `tornadoSpin`, `corkscrewDive`, `cartwheel`
- `helicopterSpin`, `barrelRoll`, `frontflipSlam`, `spinningPiledriver`
- `doubleBackflip`, `wallflip`, `moonwalk`, `breakdanceSpin`, `drillKick`
- Plus more rotation/flip-based moves
#### P2.3 SQLite Optimization
**Files:** `server/src/db/index.ts`
- Enable WAL mode: `PRAGMA journal_mode=WAL`
- Set `PRAGMA synchronous=NORMAL` (safe with WAL)
- Add indexes: `fights(status)`, `fights(createdAt)`, `bots(eloRating)`, `payments(status)`
- `PRAGMA optimize` on daily cron
- Connection pool size: 1 writer + 4 readers (via better-sqlite3's sync nature this is automatic, but verify no concurrent write contention)
`choreography/vehicles.ts` expansion:
- `skateboard`, `helicopter`, `shoppingCart`, `zamboni`, `rocketSled`
- `segway`, `unicycle`, `bulldozer`, `rollercoaster`, `cannonball`
- Plus existing (motorbikeCharge, carSmash, boatCannon)
#### P2.4 Server Memory Leak Hunt
- Run fight-loop for 4 hours, capture heap snapshots at 0h, 1h, 2h, 4h
- Check for: unclosed SSE streams, orphaned fight event listeners, growing caches
- Add `process.memoryUsage()` to admin stats endpoint
- Set up `--max-old-space-size=256` in Docker CMD as safety net
### Day 17-18: Animal (~20) + energy (~20) + silly (~25)
`choreography/animal.ts`:
- `sharkBite`, `beeSwarm`, `frogTongue`, `stampede`, `spiderWeb`
- `snakeStrike`, `bearHug`, `eagleDive`, `ramCharge`, `crocodileRoll`
### Month 3 Performance Tasks
`choreography/energy.ts`:
- `kamehameha`, `hadouken`, `spiritBomb`, `thunderStrike`, `dragonPunch`
- `novaBlast`, `blackHole`, `sonicBoom`, `plasmaWhip`, `quantumTunnel`
#### P3.1 FightScene Split Enables Dead Code Elimination
- After Week 9 split, unused choreography modules can be tree-shaken
- Vite dynamic imports for choreography categories (load on demand)
- Split point: `import('./choreography/food.ts')` only when food moves are picked
`choreography/silly.ts`:
- `pillowFight`, `tickleAttack`, `selfieStun`, `vibeCheck`, `dabOnEm`
- `yeetThrow`, `airHorn`, `rubberChicken`, `bananaPeelChain`, `confettiCannon`
- `invisibleWall`, `slipNSlide`, `trampolineBounce`, `bubbleWrap`, `kazooBlast`
#### P3.2 CDN & Caching Strategy
- Static assets: immutable hash filenames, `Cache-Control: max-age=31536000`
- API responses: `Cache-Control: no-store` for fight data, `max-age=60` for leaderboard
- Service worker: Stale-while-revalidate for sprites/audio
- Compress all API responses with gzip (Hono middleware)
### Day 19: Wire all 300 choreographies
- Register all in `choreographyMap`
- Update `CHALLENGE_THEMED` with new move pools per challenge type
- Balance `pickChoreography` weights for maximum variety
- Add new moves to `critMoves` and `wild` arrays
#### P3.3 Load Test Results → Optimization
- Profile under simulated 100-spectator load
- Identify: SSE fan-out bottleneck, DB query hotspots, memory ceiling
- Optimize based on actual profiling data, not guesses
- Document capacity ceiling: "This VPS handles X concurrent fights with Y spectators"
**Test:** Run 50 fight replays, verify no crashes, observe variety.
#### P3.4 Production Monitoring
**Files:** `server/src/engine/analytics.ts`
- Log slow queries (> 50ms) to structured log
- Track: p50/p95 fight duration, webhook response latency, payment settlement time
- Memory/CPU metrics every 60s to analytics table
- Alert threshold: memory > 200MB, fight error rate > 5%, payment failure > 1%
---
### Mobile-Specific Performance
## Phase 4: Challenges (Days 20-22)
#### Touch & Rendering
- Canvas rendering: limit to 30fps on mobile (save battery), 60fps on desktop
- Disable parallax scrolling and complex CSS animations on mobile
- Use `will-change: transform` sparingly (GPU memory cost)
- Intersection Observer for off-screen fight cards (don't render invisible content)
Scale from ~45 prompts to ~10,000 challenges. Hilarious, modern, pop culture, stupid/silly.
### Day 20: Challenge bank architecture
```
server/src/engine/challenges/
banks/
speed-blitz.ts -- 1,200+ prompts
riddle.ts -- 500+ riddles
code-golf.ts -- 400+ challenges
roast-battle.ts -- 600+ roast prompts
hallucination.ts -- 800+ true/false
token-economy.ts -- 500+ explain prompts
creative-writing.ts -- 400+ creative prompts
math-blitz.ts -- 1,500+ math problems (many templated)
trap-card.ts -- 600+ prompt injections
meme-knowledge.ts -- 500+ meme identification (NEW TYPE)
emoji-translate.ts -- 400+ emoji challenges (NEW TYPE)
debate.ts -- 300+ debate topics (NEW TYPE)
fill-the-blank.ts -- 400+ completions (NEW TYPE)
reverse-engineer.ts -- 300+ output puzzles (NEW TYPE)
survival.ts -- 500+ escalating difficulty (NEW TYPE)
```
### Day 21: Build prompt banks (~10,000 total)
- Mix of hand-written and procedural templates
- Pop culture: "Is it true Elon Musk once fought a kangaroo?", "What would happen if the Minecraft creeper attended a job interview?"
- Modern/silly: "Explain blockchain to a medieval peasant", "Write a breakup text from a printer to its owner"
- Procedural templates for math/trivia: fill in numbers, countries, units
### Day 22: New challenge types + mock answers
- 6 new types: meme_knowledge, emoji_translate, debate, fill_the_blank, reverse_engineer, survival
- Scoring for each, mock answers for all 100 bots
- Wire into frontend `CHALLENGE_THEMED`
---
## Phase 5: Audio (Days 23-25)
### Day 23: 16 new music tracks (20 total)
Each track ~40 lines. Genre variety:
- 2x Synthwave (180-200 BPM), 2x Metal (250-280 BPM)
- 2x Chiptune (200-220 BPM), 2x Jazz Fusion (160-180 BPM)
- 2x Drum & Bass (170-175 BPM), 2x Lo-fi (140-160 BPM)
- 2x Ska Punk (210-230 BPM), 2x Boss Fight (280-300 BPM)
Intensity-based genre grouping:
- Low: lo-fi, jazz, chiptune | Medium: synthwave, ska punk
- High: metal, drum & bass | Max: boss fight
### Day 24: 24 new voice profiles (30 total)
New: whisper, manic, gravelly, chipper, deadpan, sports_caster, old_timey, drill_sergeant, surfer_dude, valley_girl, pirate_voice, robot_glitch, auctioneer, news_anchor, horror_narrator, lullaby, rage_quit, stoner, brit_posh, aussie, game_show, wrestling_announcer, noir_detective, anime_narrator
Expand voice line libraries to 300+ unique lines.
### Day 25: New SFX + arena-specific ambience
- 20 new SFX for new choreography types
- Arena-specific ambient background layers (beach waves, space hum, forest chirps)
---
## Phase 6: Scenes & Polish (Days 26-27)
### Day 26: 15+ new arenas (30+ total)
volcano, underwater, rooftop, train, kitchen, arcade, library, parking_lot, school, hospital, factory, castle, moon_base, ice_rink, stadium
New modifiers: fire_boost, ice_slow, gravity_low, double_damage, chaos_mode
### Day 27: Scene transitions + animated elements
- 4 transition styles: wipe, circle close/open, pixel dissolve, glitch cut
- Animated arena backgrounds: floating particles, dynamic lighting
- Enhanced spectator crowd reactions tied to round outcomes
---
## Phase 7: Production Loop (Days 28-30)
### Day 28: Fight scheduler + matchmaking
`server/src/engine/scheduler.ts`:
- `startScheduler(intervalMs)` -- Elo-based matchmaking loop
- Routes: `POST /api/scheduler/start|stop`, `GET /api/scheduler/status`
- Queue with rate limiting + bot cooldown
### Day 29: Headless mode + overnight operation
- `pnpm run fight-loop` for headless scheduler
- Env vars: `FIGHT_INTERVAL_MS`, `FIGHTS_PER_BATCH`, `MAX_CONCURRENT`
- Structured JSON logging, graceful SIGTERM shutdown
- `GET /api/stats` endpoint, health monitoring
### Day 30: Final seed data + integration test
- Generate: 100 bots, 500+ fight history
- Verify: Elo bell curve, tier distribution
- Full E2E: seed -> dev -> replay 10 fights -> fight-loop -> 100 fights -> typecheck + lint
#### Network
- Preconnect to API server: `<link rel="preconnect">`
- Prefetch next likely page (e.g., fight card → fight page)
- Compress SSE payloads (only send changed fields, not full state)
- Offline-first: service worker serves cached shell immediately
---
## Dependency Graph
```
Phase 1 (Split) --- BLOCKS EVERYTHING
|
+-- Phase 2 (Characters) -- independent after Phase 1
+-- Phase 3 (Choreographies) -- independent after Phase 1
+-- Phase 4 (Challenges) -- independent after Phase 1
+-- Phase 5 (Audio) -- independent after Phase 1
|
+-- Phase 6 (Scenes) -- needs Phase 1 + Phase 3
+-- Phase 7 (Production) -- needs Phase 2 + Phase 4
Month 1 Month 2 Month 3
─────── ─────── ───────
W1 Spectating ──────────────────────────────────────────────── W9 FightScene Split
W2 Betting UI ──┐ W10 Performance
W3 Nostr Social │ W5 Tournaments ──────────────── W11 Admin
W4 Seasons ─────┤ W6 Human Play W12 Launch
│ W7 Content ─────────────────── W12 Launch
└──────────────→ W8 Choreography ─────────────→ W9 FightScene Split
```
## Testing Strategy
- Weeks 14 are mostly independent of each other (parallelize)
- Week 5 (Tournaments) needs Week 2 (Betting) for entry fees
- Week 6 (Human Play) needs Week 1 (Spectating) for live sync
- Week 9 (Split) should happen after Week 8 (Choreography) to avoid splitting then adding
- Week 12 (Launch) needs everything
After every day:
1. `pnpm typecheck` -- catches import/export errors
2. Sprite preview page -- verify all archetypes render
3. Watch 5 random fight replays -- no crashes, visual variety
4. Audio check -- music, voices, SFX all play
5. `pnpm seed` -- verify DB has expected data
## Risk Mitigation
## Critical Files
| Risk | Mitigation |
|------|-----------|
| FightScene.ts too fragile to split | Comprehensive replay testing before/after; git branch for safe rollback |
| SQLite bottleneck under load | WAL mode + connection pooling; monitor write contention; Turso migration path if needed |
| NWC wallet failures | Graceful fallback to Cashu; refund queue for stuck payments |
| Choreography visual bugs | Automated screenshot comparison for 20 reference fights |
| Scope creep | Each week is self-contained; can ship at end of any week |
- `frontend/src/game/sprites.ts` -> split into `sprites/` directory
- `frontend/src/game/FightScene.ts` -> split into `fight/` directory
- `frontend/src/game/sounds.ts` -> split into `sounds/` directory
- `frontend/src/components/FightViewer.vue` -> update imports
- `server/src/engine/challenges.ts` -> split into `challenges/` directory
- `server/src/engine/mock.ts` -> split into `mock/` + expand to 100 bots
- `server/src/engine/arenas.ts` -> expand to 30+ arenas
- `server/src/seed.ts` -> update imports
- `server/src/routes/fights.ts` -> add scheduler routes
## Success Metrics (End of Month 3)
**Content:**
- 500+ choreographies, 2,000+ challenge prompts, 40 arenas, 20 music tracks
**Adoption:**
- 10+ real bot developers registered and competing
- Tournaments running daily with real sats
- Betting volume: 10,000+ sats/day
- Mobile PWA installs > 0 (any adoption = success for v1)
**Reliability:**
- Zero stuck fights, zero lost payments over 7-day period
- Fight loop stable for 72hr continuous operation
**Performance:**
- Initial bundle < 300KB gzipped (excluding lazy-loaded TTS)
- Time to first fight frame < 2s on 4G mobile
- 50-fight replay session with flat memory (no leaks)
- Server stable at < 250MB RSS after 24hr fight-loop
- 20 concurrent fights + 100 spectators without degradation
- Docker image stays < 50MB
- SQLite handles 10K fights without query degradation (< 50ms p95)
+214
View File
@@ -0,0 +1,214 @@
# Production-Grade Botfights: Implementation Plan
## Context
Botfights is a competitive game where users register AI bots with webhook URLs. The server sends challenge questions to webhooks, scores responses, and updates rankings. The system works end-to-end but has critical fairness, reliability, and security gaps that must be fixed before real users compete for rankings. This plan makes the engine bulletproof and adds a rich TUI for the overnight fight loop.
---
## Phase 1: Fight Integrity (Foundation)
Everything depends on fights producing correct, trustworthy results.
### 1.1 Schema additions
**File:** `server/src/db/schema.ts`, `server/src/db/migrate.ts`
- Add to `bots`: `lastFightAt TEXT`, `consecutiveErrors INTEGER DEFAULT 0`, `lastErrorAt TEXT`
- Add migration SQL for existing DBs
### 1.2 Transaction-wrap Elo updates
**Files:** `server/src/engine/orchestrator.ts` (lines 276-306), `server/src/engine/mock.ts` (lines 392-421)
- Wrap fight finalization (status update + both bot stat updates) in a single SQLite transaction
- Prevents partial Elo corruption on crash or concurrent fights
### 1.3 Block self-fights
**Files:** `server/src/engine/orchestrator.ts`, `server/src/engine/mock.ts`, `server/src/routes/fights.ts`
- Add `if (botAId === botBId) throw new Error('A bot cannot fight itself')` at top of `runFight`, `runFightAsync`, `runMockFight`
### 1.4 Prevent concurrent fights for same bot
**File:** `server/src/engine/orchestrator.ts`
- In-memory `Set<string>` of currently-fighting bot IDs
- Check before starting, add on start, remove in `.finally()`
### 1.5 Crash recovery for stuck fights
**File:** `server/src/engine/orchestrator.ts`
- In `runFightAsync` catch handler: mark fight `status='cancelled'`, call `fightEvents.cleanup()`
- On server startup: mark any `status='live'` fights older than 10 minutes as `cancelled`
### 1.6 Fix creative scoring (anti-gaming)
**File:** `server/src/engine/scoring.ts` (lines 166-174)
- Replace `estimateQuality` with multi-factor heuristic: character diversity, word diversity, length window (30-400 ideal), speed bonus
- Prevents gaming by dumping 500 chars of garbage text
### 1.7 Tighten factual answer checking
**File:** `server/src/engine/answers.ts` (line 78)
- Add word-boundary awareness: `containsWholeWord()` helper using regex `\b`
- Short answers (2 chars like "au", "fe") require near-exact match, not just containment
- Prevents "I feel confident" matching accepted answer "fe"
### 1.8 Dampen mock-bot Elo farming
**File:** `server/src/engine/orchestrator.ts`
- When one combatant is a mock bot, use K-factor 12 instead of 32
- Real bot-vs-real bot fights keep K=32 for full stakes
---
## Phase 2: Webhook Contract & Developer Experience
### 2.1 Response size limits
**File:** `server/src/engine/orchestrator.ts` (line 83)
- Replace `res.text()` with size-limited reader (10KB max)
- Truncate `answer` to 2000 chars, `trash_talk` to 200 chars
- Prevents OOM attacks from malicious webhooks
### 2.2 SSRF protection
**File:** `server/src/engine/orchestrator.ts` (new `isAllowedWebhookUrl` function)
- Block localhost, private IPs (10.x, 192.168.x, 172.16-31.x), AWS metadata (169.254.169.254), .local/.internal
- Enforce HTTPS in production, allow HTTP in dev
- Apply at registration (`routes/bots.ts`, `routes/auth.ts`) AND at call time
### 2.3 Pre-fight webhook verification
**New file:** `server/src/engine/webhook-test.ts`
- `testWebhook(url)`: sends a test challenge (`"respond with {"answer": "pong"}"`), validates response shape and latency
- Returns `{ reachable, validResponse, latencyMs, error? }`
**Integrate into:**
- `POST /api/auth/register` -- test webhook before inserting bot. Reject with specific error if it fails.
- `POST /api/bots` -- same
- New route `POST /api/bots/:name/test` -- re-test webhook on demand (replaces weak `/health` check)
### 2.4 Webhook reliability tracking
**File:** `server/src/engine/orchestrator.ts`
- After each webhook call: increment `consecutiveErrors` on failure, reset to 0 on success
- If `consecutiveErrors >= 5`: mark bot `isActive: false`, skip in matchmaking
- Bot owner must re-test webhook to reactivate
### 2.5 Add `fight_id` to webhook payload
**File:** `server/src/engine/orchestrator.ts` (line 48)
- Thread `fightId` through `callWebhook` and `getBotResponse`
- Bot developers can correlate challenge POSTs to specific fights for debugging
---
## Phase 3: Anti-Gaming & Security
### 3.1 Rate limiting
**New file:** `server/src/middleware/rate-limit.ts`
- Simple in-memory rate limiter (no new deps), per-IP sliding window
- Apply: `POST /api/bots` (5/hr), `POST /api/auth/register` (5/hr), `POST /api/queue/join` (1 per 10s per bot), all other POSTs (60/min)
### 3.2 Case-insensitive name uniqueness
**Files:** `server/src/routes/bots.ts` (line 39), `server/src/routes/auth.ts` (line 78)
- Force bot names to lowercase at registration time
- Prevents name squatting ("MyBot" vs "mybot")
### 3.3 Queue cooldowns
**File:** `server/src/engine/queue.ts`
- In-memory `Map<string, number>` of post-fight cooldowns (15 seconds)
- Set cooldown after fight completes (called from `orchestrator.ts`)
- Reject queue join if cooldown active
### 3.4 Concurrent fight checks in queue
**File:** `server/src/engine/queue.ts`
- Check the `activeFighters` set (from 1.4) before allowing queue join
- Prevents a bot from queueing while already in a fight
---
## Phase 4: TUI Fight Loop
Minimal deps -- only `chalk` for colors. All layout via ANSI codes and Unicode box drawing.
### 4.1 Add dependency
`chalk@5` to `server/package.json`
### 4.2 TUI state tracker
**New file:** `server/src/tui/state.ts`
- `TuiState` interface: fight count, KOs, perfects, draws, errors, current fight (bots/HP/round/events), recent fights, leaderboard, biggest upset, Elo movers, elapsed time
### 4.3 TUI renderer
**New file:** `server/src/tui/renderer.ts`
```
+=================== BOTFIGHTS OVERNIGHT LOOP ====================+
| Fight #47 of 200 Elapsed: 12m 34s |
| Style: mixed Rate: 3.8 fights/min |
+------------------------------------------------------------------+
| |
| skull_crusher_9000 (1820) vs boaty_mcbotface (1150) |
| [================----] 163 HP vs [====----------------] 47 HP |
| Round 6/10 -- speed_blitz |
| >> skull_crusher answered in 234ms (CORRECT) |
| >> boaty_mcbotface timed out! FREE HIT! |
| |
+========================= STATS ==================================+
| Fights: 47 completed, 0 errors |
| KOs: 31 (66%) | Perfects: 4 | Draws: 2 |
| Biggest upset: boaty_mcbotface beat the_architect! |
+========================= LEADERBOARD ============================+
| #1 the_architect 1980 52W-8L LEGEND |
| #2 chad_gpt 1950 48W-10L LEGEND |
| #3 skull_crusher_9000 1820 35W-12L DIAMOND |
+========================= RECENT ================================+
| #47 skull_crusher vs boaty -> skull_crusher (KO R6) |
| #46 chad_gpt vs lorem_ipsum -> chad_gpt (PERFECT R3) |
| #45 regex_ronin vs the_intern -> regex_ronin (Decision) |
+==================================================================+
```
- Single buffered write to avoid flicker
- Handles terminal resize via `process.stdout.on('resize')`
- Tier colors via chalk
### 4.4 Refactor fight loop with callbacks
**File:** `server/src/engine/fight-loop.ts`
- Add callback options: `onFightStart`, `onRoundComplete`, `onFightComplete`, `onError`
- Wire event bus so TUI gets live round-by-round updates
### 4.5 Rewrite CLI
**File:** `server/src/fight-loop-cli.ts`
- Create TUI state + renderer, pass callbacks to fight loop
- Graceful SIGINT: show final summary screen
- Same CLI args (`--max`, `--interval`, `--style`)
### 4.6 Final summary screen
On loop end or Ctrl+C: duration, total fights, KO/perfect/draw rates, top Elo movers, biggest upset, most active bot
---
## Phase 5: Verification
### 5.1 Manual test sequence
1. `pnpm seed` -- verify schema migrations run
2. `pnpm dev` -- verify server starts, orphaned fights cleaned up on startup
3. Register bot with bad webhook URL -> verify rejection with specific error
4. Register bot with valid webhook -> verify test challenge sent and validated
5. Try self-fight via `/api/fights/matchmake` -> verify blocked
6. Run `pnpm fight-loop --max=10` -> verify TUI renders, stats update live
7. Kill process mid-fight, restart -> verify stuck fights cleaned up
8. Send oversized response from test webhook -> verify 10KB limit
9. Rapid-fire queue joins -> verify rate limiting and cooldowns
---
## Files Summary
**Modified (11 files):**
- `server/src/db/schema.ts` -- new columns
- `server/src/db/migrate.ts` -- migration SQL
- `server/src/engine/orchestrator.ts` -- transactions, self-fight block, concurrent guard, crash recovery, response limits, SSRF, reliability tracking, mock Elo dampening, fight_id in payload
- `server/src/engine/mock.ts` -- transaction wrap, self-fight block
- `server/src/engine/scoring.ts` -- creative scoring rewrite
- `server/src/engine/answers.ts` -- word boundary fixes
- `server/src/engine/queue.ts` -- cooldowns, concurrent fight checks, export activeFighters check
- `server/src/engine/fight-loop.ts` -- callback options for TUI
- `server/src/fight-loop-cli.ts` -- TUI integration
- `server/src/app.ts` -- rate limiting, startup cleanup
- `server/package.json` -- add chalk
**Created (4 files):**
- `server/src/engine/webhook-test.ts` -- pre-fight webhook verification
- `server/src/middleware/rate-limit.ts` -- rate limiter
- `server/src/tui/state.ts` -- TUI state tracker
- `server/src/tui/renderer.ts` -- TUI renderer
**Execution order:** Phase 1 (1.1-1.8) -> Phase 2 (2.1-2.5) -> Phase 3 (3.1-3.4) -> Phase 4 (4.1-4.6) -> Phase 5 verification
@@ -0,0 +1,36 @@
# Plan: Create Zaps.md Implementation Guide
## Context
The user wants a `Zaps.md` planning document at the project root that serves as a comprehensive implementation guide for a future Claude session. When the user says "implement zaps", Claude reads this doc and knows exactly what to build.
## What to create
- **File**: `/Users/dorian/Projects/botfights/Zaps.md`
## Key design decisions from research
### Payment model: NWC (primary) + Cashu (alternative)
- NWC (NIP-47): User connects Lightning wallet once. Server sends encrypted payment requests via Nostr relay. Use nostr-tools directly (no Alby SDK) for privacy.
- Cashu ecash: Alternative — user pastes ecash token, server redeems with mint.
- Server wallet: Small Lightning wallet (Alby Hub or LNbits) via `BOTFIGHTS_NWC_URL` env var. Transient escrow only (seconds, not balances).
### Two modes: Free (unchanged) + Ranked (new, 21 sats)
- Ranked: both pay 21 sats → winner gets 42 sats
- Ranked queue never matches against mock bots
- Timeout = refund (not mock fallback)
### New files needed
- DB tables: `payments`, `wallet_connections` + new columns on fights/bots
- Server: `engine/payments.ts`, `engine/ranked-queue.ts`, `routes/payments.ts`
- Frontend: `composables/useWallet.ts`, `components/WalletConnect.vue`
- Modifications to: orchestrator, queue routes, JoinBoutPage, FightPage, BotProfilePage, schema
### Dependencies (all MIT)
- `nostr-tools` ^2.x — NWC protocol
- `@cashu/cashu-ts` ^2.x — ecash tokens
## Implementation
Write Zaps.md with full architecture, schema SQL, function signatures, edge cases, phases, and security notes — enough for a fresh Claude session to implement from.
## Verification
- Read the written Zaps.md and confirm completeness
- Ensure all file paths match actual codebase structure
View File
+23 -16
View File
@@ -643,9 +643,9 @@ async function _doReplay() {
<div v-if="announcementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<div class="announce-text-wrapper">
<p class="font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest announce-text uppercase announce-chromatic"
<p class="font-funky text-3xl sm:text-6xl lg:text-8xl tracking-widest announce-text-3d uppercase announce-chromatic"
:data-text="announcement"
:style="{ color: announcementColor, textShadow: `0 0 20px ${announcementColor}, 0 0 40px ${announcementColor}, 0 0 80px ${announcementColor}40, 0 0 120px ${announcementColor}20` }">
:style="{ '--announce-color': announcementColor }">
{{ announcement }}
</p>
</div>
@@ -720,20 +720,24 @@ async function _doReplay() {
position: relative;
}
.announce-text {
animation: announce-pulse 0.4s ease-in-out infinite alternate, announce-hue 2s linear infinite;
-webkit-text-stroke: 1px rgba(0,0,0,0.3);
/* Colourful 3D extruded text */
.announce-text-3d {
color: var(--announce-color, #ffffff);
paint-order: stroke fill;
-webkit-text-stroke: 2px rgba(0,0,0,0.6);
text-shadow:
2px 2px 0 #1a0020,
4px 4px 0 #2a0040,
6px 6px 0 #3a0060,
8px 8px 0 #4d0080,
0 0 20px var(--announce-color, #ffffff),
0 0 60px var(--announce-color, #ffffff);
will-change: transform;
animation: announce-3d-pulse 0.35s ease-in-out infinite alternate;
}
@keyframes announce-pulse {
from { transform: scale(1) rotate(-1deg); }
to { transform: scale(1.08) rotate(1deg); }
}
@keyframes announce-hue {
0% { filter: hue-rotate(0deg) brightness(1); }
25% { filter: hue-rotate(15deg) brightness(1.1); }
50% { filter: hue-rotate(0deg) brightness(1.2); }
75% { filter: hue-rotate(-15deg) brightness(1.1); }
100% { filter: hue-rotate(0deg) brightness(1); }
@keyframes announce-3d-pulse {
from { transform: scale(1) rotate(-0.5deg); }
to { transform: scale(1.06) rotate(0.5deg); }
}
/* Chromatic aberration on announcements */
@@ -748,8 +752,11 @@ async function _doReplay() {
left: 0;
right: 0;
text-align: center;
opacity: 0.5;
opacity: 0.35;
pointer-events: none;
text-shadow: none;
-webkit-text-stroke: 0;
will-change: transform;
}
.announce-chromatic::before {
color: #ff2d7b;
@@ -55,6 +55,8 @@ watch(() => [props.seed, props.archetype, props.customization, props.pose], () =
onUnmounted(() => {
if (animHandle) clearTimeout(animHandle)
animHandle = null
if (img) { img.onload = null; img.src = ''; img = null }
})
</script>
+11 -2
View File
@@ -38,9 +38,14 @@ function parseNwcUrl(url: string): NwcConfig {
const relay = params.get('relay')
const secret = params.get('secret')
if (!pubkey || !relay || !secret) {
throw new Error('Invalid NWC URL')
throw new Error('Invalid NWC URL: missing pubkey, relay, or secret')
}
return { pubkey, relay, secret: hexToBytes(secret) }
// Validate hex before parsing — must be even-length hex string
const trimmed = secret.trim()
if (trimmed.length === 0 || trimmed.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(trimmed)) {
throw new Error('Invalid NWC URL: secret is not valid hex (must be even-length hex string)')
}
return { pubkey, relay, secret: hexToBytes(trimmed) }
}
export function useWallet() {
@@ -159,7 +164,11 @@ export function useWallet() {
// If NWC connected, auto-pay via NWC and confirm directly
const nwcUrl = localStorage.getItem('bf_nwc_url')
let nwcValid = false
if (nwcUrl) {
try { parseNwcUrl(nwcUrl); nwcValid = true } catch { /* bad stored URL — fall through to poll */ }
}
if (nwcUrl && nwcValid) {
paymentStatus.value = 'paying'
const preimage = await payViaNWC(nwcUrl, bolt11)
+47 -41
View File
@@ -289,8 +289,9 @@ if (typeof speechSynthesis !== 'undefined') {
speechSynthesis.onvoiceschanged = loadVoices
loadVoices()
// Chrome bug workaround: speechSynthesis pauses after ~15s.
// Periodic resume() keeps it alive.
// Periodic resume() keeps it alive — but only if Kokoro isn't handling TTS.
setInterval(() => {
if (isKokoroReady()) return // Kokoro active — don't touch Web Speech
if (speechSynthesis.speaking && !speechSynthesis.paused) return
if (speechSynthesis.paused) speechSynthesis.resume()
}, 5000)
@@ -298,7 +299,7 @@ if (typeof speechSynthesis !== 'undefined') {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (speechSynthesis.paused) speechSynthesis.resume()
if (!isKokoroReady() && speechSynthesis.paused) speechSynthesis.resume()
if (ctx?.state === 'suspended') ctx.resume().catch(() => {})
}
})
@@ -309,16 +310,28 @@ let _speechQueueDepth = 0
// Scale speech volume down so voice doesn't overpower SFX/music
const VOICE_VOLUME_SCALE = 0.7
function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
let _webSpeechActive = false // track if Web Speech has queued utterances
function _cancelWebSpeech() {
if (typeof speechSynthesis !== 'undefined' && _webSpeechActive) {
speechSynthesis.cancel()
_speechQueueDepth = 0
_webSpeechActive = false
}
}
export function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
if (masterMuted) return
// Try Kokoro TTS first — high quality, no browser bugs
if (isKokoroReady() && sfxGain) {
// Kill any lingering Web Speech utterances so voices don't double
_cancelWebSpeech()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
if (cancelPrevious) kokoroStop()
kokoroSpeak(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
return
}
// Fallback: Web Speech API
// Fallback: Web Speech API (only used while Kokoro model is loading)
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
if (speechSynthesis.paused) speechSynthesis.resume()
@@ -335,21 +348,30 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
utter.rate = profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
_webSpeechActive = true
utter.onend = () => {
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
if (_speechQueueDepth === 0) _webSpeechActive = false
}
utter.onerror = (ev) => {
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
if (_speechQueueDepth === 0) _webSpeechActive = false
if (ev.error !== 'canceled' && ev.error !== 'interrupted') {
setTimeout(() => {
if (!masterMuted && typeof speechSynthesis !== 'undefined') {
const retry = new SpeechSynthesisUtterance(text)
if (profile.voice) retry.voice = profile.voice
retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
speechSynthesis.speak(retry)
}
}, 200)
// Only retry if Kokoro still isn't ready — avoid doubling
if (!isKokoroReady()) {
setTimeout(() => {
if (!masterMuted && !isKokoroReady() && typeof speechSynthesis !== 'undefined') {
const retry = new SpeechSynthesisUtterance(text)
if (profile.voice) retry.voice = profile.voice
retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
_webSpeechActive = true
retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false }
retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false }
speechSynthesis.speak(retry)
}
}, 200)
}
}
}
speechSynthesis.speak(utter)
@@ -364,6 +386,7 @@ async function _speakAsyncCore(text: string, profileName: string, rateOverride?:
if (masterMuted) return
// Try Kokoro TTS first
if (isKokoroReady() && sfxGain) {
_cancelWebSpeech()
if (cancelPrevious) kokoroStop()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const handled = await kokoroSpeakAsync(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
@@ -427,6 +450,7 @@ export function stopAllAudio() {
kokoroStop()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
_speechQueueDepth = 0
_webSpeechActive = false
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
// then reconnect so future sounds still work
if (ctx) {
@@ -446,29 +470,9 @@ export function stopAllAudio() {
}
// Public voice functions
export function announce(text: string, pitch?: number, rate?: number) {
if (masterMuted) return
// Kokoro ignores pitch/rate overrides — just use the announcer profile
if (isKokoroReady()) {
speak(text, 'announcer')
return
}
if (pitch !== undefined || rate !== undefined) {
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
const utter = new SpeechSynthesisUtterance(text)
const profile = voiceProfiles.announcer
if (profile.voice) utter.voice = profile.voice
utter.pitch = pitch ?? 1.0
utter.rate = rate ?? 0.8
utter.volume = VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
speechSynthesis.speak(utter)
} else {
speak(text, 'announcer')
}
export function announce(text: string, _pitch?: number, _rate?: number) {
// Always route through speak() — prevents doubled voices from separate Web Speech paths
speak(text, 'announcer')
}
export function announceDeep(text: string) { speak(text, 'deep') }
@@ -2882,9 +2886,11 @@ export async function ensureAudioContext() {
if (c.state === 'suspended') {
try { await c.resume() } catch {}
}
// Share AudioContext with kokoro and start loading the model
// Share AudioContext with kokoro and start loading the model.
// Defer initKokoro so the click handler finishes first — the 4.8MB module
// parse + 86MB model download happen after the UI is unblocked.
setAudioContext(c)
initKokoro()
setTimeout(() => initKokoro(), 0)
// Prime speech synthesis as fallback — mobile browsers require
// a speak() call inside a user gesture to unlock speechSynthesis
if (typeof speechSynthesis !== 'undefined') {
+19 -1
View File
@@ -19,10 +19,22 @@ export interface SpriteCustomization {
forceHorns?: boolean
}
// Sprite sheet cache — avoids regenerating expensive canvas work
const _spriteCache = new Map<string, string>()
const MAX_SPRITE_CACHE = 40
function _spriteCacheKey(seed: string, tier: number, primary: string, secondary: string, arch?: string, cust?: SpriteCustomization): string {
return `${seed}|${tier}|${primary}|${secondary}|${arch || ''}|${cust ? JSON.stringify(cust) : ''}`
}
export function generateSpriteSheet(
seed: string, tier: number, primaryColor: string, secondaryColor: string,
archetypeOverride?: string, customization?: SpriteCustomization,
): string {
const cacheKey = _spriteCacheKey(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization)
const cached = _spriteCache.get(cacheKey)
if (cached) return cached
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * MAX_FRAMES
canvas.height = FRAME_SIZE * TOTAL_ROWS
@@ -551,7 +563,13 @@ export function generateSpriteSheet(
for (let f = cfg.frames; f < MAX_FRAMES; f++) drawFrame(f, row, pose, cfg.frames - 1, cfg.frames)
}
return canvas.toDataURL()
const dataUrl = canvas.toDataURL()
if (_spriteCache.size >= MAX_SPRITE_CACHE) {
const oldest = _spriteCache.keys().next().value
if (oldest) _spriteCache.delete(oldest)
}
_spriteCache.set(cacheKey, dataUrl)
return dataUrl
}
/** Load a sprite sheet data URL into a canvas (async for reliable image decode) */
+51
View File
@@ -0,0 +1,51 @@
// Web Worker for Kokoro TTS — runs ONNX neural network inference off the main thread.
// This prevents the 1-5 second freezes that occur when generate() runs on the UI thread.
let tts: any = null
self.addEventListener('message', async (e: MessageEvent) => {
const msg = e.data
switch (msg.type) {
case 'init': {
try {
const { KokoroTTS } = await import('kokoro-js')
tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-ONNX', {
dtype: 'q8',
device: null,
progress_callback: (p: any) => {
if (p.progress !== undefined) {
self.postMessage({ type: 'progress', progress: p.progress })
}
},
})
self.postMessage({ type: 'init-done' })
} catch (err) {
self.postMessage({ type: 'init-failed', error: String(err) })
}
break
}
case 'generate': {
if (!tts) {
self.postMessage({ type: 'generate-failed', id: msg.id, error: 'not initialized' })
break
}
try {
const result = await tts.generate(msg.text, {
voice: msg.voice,
speed: msg.speed,
})
// Copy to a standalone ArrayBuffer so we can transfer ownership (zero-copy to main thread)
const audio = new Float32Array(result.audio)
self.postMessage(
{ type: 'generate-done', id: msg.id, audio, sampleRate: result.sampling_rate },
{ transfer: [audio.buffer] },
)
} catch (err) {
self.postMessage({ type: 'generate-failed', id: msg.id, error: String(err) })
}
break
}
}
})
+107 -47
View File
@@ -1,9 +1,7 @@
// Kokoro TTS engine — high-quality client-side text-to-speech
// Lazy-loads the 86MB q8 model on first use, caches in browser storage.
// All ONNX inference runs in a Web Worker so the main thread never freezes.
// Falls through to speechSynthesis if model isn't ready yet.
import type { KokoroTTS as KokoroTTSType } from 'kokoro-js'
type KokoroVoice = 'af_heart' | 'af_alloy' | 'af_aoede' | 'af_bella' | 'af_jessica' | 'af_kore' |
'af_nicole' | 'af_nova' | 'af_river' | 'af_sarah' | 'af_sky' |
'am_adam' | 'am_echo' | 'am_eric' | 'am_fenrir' | 'am_liam' | 'am_michael' | 'am_onyx' | 'am_puck' | 'am_santa' |
@@ -107,16 +105,23 @@ const VOICE_MAP: Record<string, VoiceMapping> = {
// Default fallback
const DEFAULT_VOICE: VoiceMapping = { voice: 'am_fenrir', speed: 1.0 }
let ttsInstance: KokoroTTSType | null = null
let ttsLoading = false
let ttsLoadFailed = false
// --- Worker state ---
let _worker: Worker | null = null
let _workerReady = false
let _workerLoading = false
let _workerFailed = false
let _nextReqId = 0
// Pending worker requests: id → resolve/reject
const _pending = new Map<number, {
resolve: (v: { audio: Float32Array; sampleRate: number }) => void
reject: (e: Error) => void
}>()
// --- Audio state (main thread only) ---
let _audioCtx: AudioContext | null = null
// Audio cache: key = "voice:speed:text" → AudioBuffer
const audioCache = new Map<string, AudioBuffer>()
const MAX_CACHE = 200
// Currently playing sources (for stop)
const MAX_CACHE = 30
const activeSources: Set<AudioBufferSourceNode> = new Set()
function getAudioCtx(): AudioContext {
@@ -132,56 +137,84 @@ export function setAudioContext(ctx: AudioContext) {
/** Is the kokoro model loaded and ready? */
export function isKokoroReady(): boolean {
return ttsInstance !== null
return _workerReady
}
/** Is the kokoro model currently loading? */
export function isKokoroLoading(): boolean {
return ttsLoading
return _workerLoading
}
/** Start loading the kokoro model. Call early (e.g. on first user gesture). */
/** Handle messages from the TTS worker */
function _handleWorkerMessage(e: MessageEvent) {
const msg = e.data
if (msg.type === 'generate-done') {
const p = _pending.get(msg.id)
if (p) {
_pending.delete(msg.id)
p.resolve({ audio: msg.audio, sampleRate: msg.sampleRate })
}
} else if (msg.type === 'generate-failed') {
const p = _pending.get(msg.id)
if (p) {
_pending.delete(msg.id)
p.reject(new Error(msg.error))
}
}
}
/** Start loading the kokoro model in a Web Worker. Call early (e.g. on first user gesture). */
export async function initKokoro(onProgress?: (pct: number) => void): Promise<void> {
if (ttsInstance || ttsLoading || ttsLoadFailed) return
ttsLoading = true
if (_workerReady || _workerLoading || _workerFailed) return
_workerLoading = true
try {
const { KokoroTTS } = await import('kokoro-js')
ttsInstance = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-ONNX', {
dtype: 'q8',
device: null, // auto-detect (WebGPU → WASM fallback)
progress_callback: onProgress ? (p: any) => {
if (p.progress !== undefined) onProgress(p.progress)
} : undefined,
_worker = new Worker(new URL('./tts-worker.ts', import.meta.url), { type: 'module' })
await new Promise<void>((resolve, reject) => {
_worker!.onmessage = (e) => {
const msg = e.data
if (msg.type === 'init-done') {
_workerReady = true
_workerLoading = false
// Switch to persistent handler for generate responses
_worker!.onmessage = _handleWorkerMessage
resolve()
} else if (msg.type === 'init-failed') {
reject(new Error(msg.error))
} else if (msg.type === 'progress' && onProgress) {
onProgress(msg.progress)
}
}
_worker!.onerror = (e) => {
reject(new Error(e.message || 'Worker failed to load'))
}
_worker!.postMessage({ type: 'init' })
})
ttsLoading = false
// Pre-cache common fight phrases in the background
_precacheCommon()
} catch (e) {
console.warn('[kokoro] Failed to load TTS model:', e)
ttsLoading = false
ttsLoadFailed = true
console.warn('[kokoro] Failed to init TTS worker:', e)
_workerLoading = false
_workerFailed = true
if (_worker) { _worker.terminate(); _worker = null }
}
}
// Common phrases to pre-generate so they play instantly
const PRECACHE_PHRASES: Array<{ text: string; profile: string }> = [
{ text: 'K. O.!', profile: 'announcer' },
{ text: 'Devastating!', profile: 'deep' },
{ text: 'Flawless victory!', profile: 'deep' },
{ text: 'Finish it!', profile: 'announcer' },
{ text: 'Fatality!', profile: 'deep' },
{ text: 'Round one!', profile: 'announcer' },
{ text: 'Round two!', profile: 'announcer' },
{ text: 'Round three!', profile: 'announcer' },
{ text: 'Fight!', profile: 'announcer' },
{ text: 'K. O.!', profile: 'announcer' },
]
async function _precacheCommon() {
if (!ttsInstance) return
for (const { text, profile } of PRECACHE_PHRASES) {
await new Promise(r => setTimeout(r, 50))
try {
await _generateAndCache(text, profile)
} catch { /* swallow — non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -190,21 +223,38 @@ function _cacheKey(text: string, profile: string): string {
return `${m.voice}:${m.speed}:${text}`
}
/** Send a generate request to the worker and wait for the result (10s timeout) */
function _workerGenerate(text: string, voice: string, speed: number): Promise<{ audio: Float32Array; sampleRate: number }> {
return new Promise((resolve, reject) => {
if (!_worker || !_workerReady) {
reject(new Error('worker not ready'))
return
}
const id = _nextReqId++
const timeout = setTimeout(() => {
_pending.delete(id)
reject(new Error('TTS generation timed out'))
}, 10_000)
_pending.set(id, {
resolve: (v) => { clearTimeout(timeout); resolve(v) },
reject: (e) => { clearTimeout(timeout); reject(e) },
})
_worker.postMessage({ type: 'generate', id, text, voice, speed })
})
}
async function _generateAndCache(text: string, profile: string): Promise<AudioBuffer | null> {
if (!ttsInstance) return null
if (!_workerReady) return null
const key = _cacheKey(text, profile)
const cached = audioCache.get(key)
if (cached) return cached
const mapping = VOICE_MAP[profile] || DEFAULT_VOICE
const raw = await ttsInstance.generate(text, {
voice: mapping.voice,
speed: mapping.speed,
})
const raw = await _workerGenerate(text, mapping.voice, mapping.speed)
// Convert Float32Array → AudioBuffer
// Convert Float32Array → AudioBuffer (lightweight, main thread)
const ctx = getAudioCtx()
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampling_rate)
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate)
buf.getChannelData(0).set(raw.audio)
// Evict oldest if cache is full
@@ -240,7 +290,7 @@ function _playBuffer(buf: AudioBuffer, dest: AudioNode, volume: number): Promise
/**
* Generate and play TTS. Returns a promise that resolves when done.
* Returns null if kokoro isn't ready (caller should fall back).
* Returns false if kokoro isn't ready (caller should fall back).
*/
export async function kokoroSpeakAsync(
text: string,
@@ -248,7 +298,7 @@ export async function kokoroSpeakAsync(
dest: AudioNode,
volume: number = 0.7,
): Promise<boolean> {
if (!ttsInstance) return false
if (!_workerReady) return false
try {
const buf = await _generateAndCache(text, profileName)
if (!buf) return false
@@ -269,7 +319,7 @@ export function kokoroSpeak(
dest: AudioNode,
volume: number = 0.7,
): boolean {
if (!ttsInstance) return false
if (!_workerReady) return false
// Check cache for instant playback
const key = _cacheKey(text, profileName)
const cached = audioCache.get(key)
@@ -277,7 +327,7 @@ export function kokoroSpeak(
_playBuffer(cached, dest, volume)
return true
}
// Generate async — will play when ready
// Generate in worker — will play when ready
_generateAndCache(text, profileName).then(buf => {
if (buf) _playBuffer(buf, dest, volume)
}).catch(() => {})
@@ -301,3 +351,13 @@ export function kokoroClearCache() {
export function getKokoroVoice(profileName: string): VoiceMapping {
return VOICE_MAP[profileName] || DEFAULT_VOICE
}
/** Get all voice profile names */
export function getVoiceProfileNames(): string[] {
return Object.keys(VOICE_MAP)
}
/** Get the full voice map (for soundboard UI) */
export function getVoiceMap(): Record<string, { voice: string; speed: number }> {
return VOICE_MAP
}
+197 -16
View File
@@ -67,9 +67,23 @@ watch(() => route.params.fightId, async (newId) => {
}
if (isLive.value) {
startPolling()
connectSSE()
if (isHumanFight.value) {
startHumanPolling()
connectSSE()
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
} else {
// Bot-vs-bot spectating: init live scene for real-time viewing
if (!liveFightData.value) await loadFight()
if (!liveFightData.value) {
// loadFight sets liveFightData only for human fights; set it for spectating too
const res = await fetch(`/api/fights/${fightId.value}`)
if (res.ok) {
const data = await res.json()
if (data.botA && data.botB) liveFightData.value = data
}
}
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
@@ -112,6 +126,7 @@ const liveSoundOn = ref(true)
const liveAnnouncement = ref('')
const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false)
const spectatorCount = ref(0)
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
@@ -212,9 +227,11 @@ function startPolling() {
pollCount++
const s = await loadFight()
if (s === 'finished') {
// Human fights handle end via SSE fight_end event don't transition here
if (!isHumanFight.value) {
// SSE fight_end handles transition for all live fights with SSE connected
if (!eventSource) {
// Fallback: no SSE connected, transition directly
isLive.value = false
disconnectSSE()
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
@@ -361,6 +378,22 @@ async function initLiveScene() {
function connectSSE() {
eventSource = new EventSource(`/api/fights/${fightId.value}/stream`)
eventSource.addEventListener('spectator_count', (e) => {
try {
const data = JSON.parse(e.data)
spectatorCount.value = data.count || 0
} catch { /* ignore */ }
})
eventSource.addEventListener('ping', (e) => {
try {
if (e.data) {
const data = JSON.parse(e.data)
if (data.spectators !== undefined) spectatorCount.value = data.spectators
}
} catch { /* ignore */ }
})
eventSource.addEventListener('round_start', (e) => {
try {
const data = JSON.parse(e.data)
@@ -409,7 +442,9 @@ function connectSSE() {
eventSource.addEventListener('round_end', (e) => {
try {
handleRoundEnd(JSON.parse(e.data)).catch(() => {})
const data = JSON.parse(e.data)
if (data.spectators !== undefined) spectatorCount.value = data.spectators
handleRoundEnd(data).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE round_end failed:', err)
}
@@ -417,7 +452,9 @@ function connectSSE() {
eventSource.addEventListener('fight_end', (e) => {
try {
handleFightEnd(JSON.parse(e.data)).catch(() => {})
const data = JSON.parse(e.data)
if (data.spectators !== undefined) spectatorCount.value = data.spectators
handleFightEnd(data).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE fight_end failed:', err)
}
@@ -441,6 +478,7 @@ function connectSSE() {
function disconnectSSE() {
if (eventSource) { eventSource.close(); eventSource = null }
spectatorCount.value = 0
}
async function showLiveOverlay(text: string, color: string, duration: number) {
@@ -816,6 +854,12 @@ function stopAutoBattle() {
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-yellow" />
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-green" />
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
<span v-if="spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{{ spectatorCount }}
</span>
</div>
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
<div v-for="(item, idx) in liveLogItems" :key="idx">
@@ -969,6 +1013,7 @@ function stopAutoBattle() {
<span class="font-pixel text-[9px] text-text-muted">
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
<span v-if="liveFightData.mode === 'ranked'" class="text-neon-cyan"> | {{ liveFightData.potSats || 42 }} SATS</span>
<span v-if="spectatorCount > 0" class="text-neon-cyan"> | {{ spectatorCount }} watching</span>
</span>
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</div>
@@ -1003,17 +1048,153 @@ function stopAutoBattle() {
</div>
</div>
<!-- LIVE BOT FIGHT: spinner -->
<div v-else-if="isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4">
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
<p class="font-mono text-text-muted text-xs">
Round {{ liveRounds }} webhooks being called...
</p>
<p v-if="autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider">
AUTO BATTLE #{{ autoBattleCount + 1 }}
<button class="ml-2 text-ko hover:text-text-primary transition-colors" @click="stopAutoBattle">STOP</button>
</p>
<!-- LIVE BOT FIGHT: spectator view with live scene -->
<div v-else-if="isLive && !isHumanFight" class="flex-1 flex flex-col lg:flex-row gap-1 sm:gap-2 min-h-0 overflow-hidden">
<!-- Battle Log mobile: bottom 40%, desktop: left 35% -->
<div class="flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden
h-[40%] lg:h-auto lg:w-[35%] order-2 lg:order-1">
<div class="bg-surface-raised border-b border-border px-3 py-1 lg:py-1.5 flex items-center gap-2 flex-shrink-0">
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-ko" />
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-yellow" />
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-green" />
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
<span v-if="spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{{ spectatorCount }}
</span>
</div>
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
<div v-for="(item, idx) in liveLogItems" :key="idx">
<div v-if="item.type === 'divider'" class="py-1.5"><div class="border-t border-white/5" /></div>
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
<div v-else-if="item.type === 'challenge'" class="bg-neon-green/[0.06] border border-neon-green/20 rounded-md px-3 py-1.5 my-1">
<p class="text-neon-green text-xs font-mono leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
</div>
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
</div>
<div v-if="liveLogItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Waiting for fight to begin...</div>
</div>
<!-- Spectator footer -->
<div class="px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0">
<div class="flex items-center justify-between">
<p class="font-mono text-text-muted text-xs">
<span v-if="liveCurrentRound > 0">Round {{ liveCurrentRound }}</span>
<span v-else>Waiting for fight...</span>
</p>
<button
class="w-7 h-7 flex items-center justify-center border border-border/50 text-text-muted
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
:title="liveSoundOn ? 'Mute' : 'Unmute'"
@click="toggleLiveSound"
>
<svg v-if="liveSoundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
</div>
<p v-if="autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider mt-1">
AUTO BATTLE #{{ autoBattleCount + 1 }}
<button class="ml-2 text-ko hover:text-text-primary transition-colors" @click="stopAutoBattle">STOP</button>
</p>
</div>
</div>
<!-- Game Canvas mobile: top 60%, desktop: right 65% -->
<div class="h-[60%] lg:h-auto lg:flex-1 lg:w-[65%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2">
<!-- Health bars -->
<div v-if="liveFightData?.botA" class="px-2 sm:px-3 py-1 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<!-- Mobile: compact single-row names + HP -->
<div class="sm:hidden">
<div class="flex items-center gap-1">
<p class="font-marker text-[10px] tracking-wider truncate text-neon-cyan flex-1 min-w-0">{{ liveFightData.botA.name }}</p>
<span class="font-mono font-bold text-[10px] w-5 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
<div class="w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-neon-cyan transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
</div>
<span class="font-funky text-neon-purple text-[10px] px-0.5 flex-shrink-0">VS</span>
<div class="w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-neon-pink transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div>
<span class="font-mono font-bold text-[10px] w-5 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<p class="font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB.name }}</p>
</div>
</div>
<!-- Desktop: single-row layout -->
<div class="hidden sm:block">
<div class="flex items-center gap-2">
<p class="font-marker text-sm tracking-wider truncate text-neon-cyan flex-shrink-0 max-w-[20%]">{{ liveFightData.botA.name }}</p>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
</div>
<span class="font-mono font-bold text-sm w-8 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
<span class="font-funky text-neon-purple text-xl px-1">VS</span>
<span class="font-mono font-bold text-sm w-8 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div>
<p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB.name }}</p>
</div>
<div class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
<span class="font-pixel text-[9px] text-text-muted">
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
<span v-if="spectatorCount > 0" class="text-neon-cyan ml-1">| {{ spectatorCount }} watching</span>
</span>
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</div>
</div>
</div>
<!-- Canvas area -->
<div class="flex-1 relative min-h-0">
<canvas ref="liveCanvas" class="w-full h-full block" />
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="liveAnnouncementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<p class="font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest uppercase announce-text"
:style="{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }">
{{ liveAnnouncement }}
</p>
</div>
</Transition>
<!-- Loading scene overlay -->
<div v-if="!liveSceneReady && liveFightData" class="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
<div class="text-center">
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin mx-auto mb-3" />
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
</div>
</div>
<!-- No fight data yet -->
<div v-if="!liveFightData" class="absolute inset-0 flex flex-col items-center justify-center bg-black z-10 gap-4">
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
<p class="font-mono text-text-muted text-xs">Connecting to live fight...</p>
</div>
</div>
</div>
</div>
<div v-else-if="fightError" class="flex-1 flex flex-col items-center justify-center gap-3">
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getVoiceMap, isKokoroReady, isKokoroLoading } from '../game/tts'
import { speak, ensureAudioContext } from '../game/sounds'
const voiceMap = getVoiceMap()
const allProfiles = Object.entries(voiceMap).map(([name, { voice, speed }]) => ({
name,
kokoroVoice: voice,
speed,
}))
const customText = ref('Devastating blow! That had to hurt!')
const filter = ref('')
const audioReady = ref(false)
const playing = ref<string | null>(null)
const filtered = computed(() => {
if (!filter.value) return allProfiles
const q = filter.value.toLowerCase()
return allProfiles.filter(p =>
p.name.includes(q) || p.kokoroVoice.includes(q)
)
})
// Group by category based on voice name prefix
const categories = computed(() => {
const groups: Record<string, typeof allProfiles> = {}
for (const p of filtered.value) {
let cat = 'Other'
if (['announcer', 'deep', 'smooth', 'question_reader', 'news'].includes(p.name)) cat = 'Authoritative'
else if (['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'karen', 'terrified', 'power_up', 'wrestler_v'].includes(p.name)) cat = 'High Energy'
else if (['boomer', 'movie', 'demon_v', 'final_boss', 'boss_taunt', 'game_over', 'giant', 'mainframe'].includes(p.name)) cat = 'Deep / Menacing'
else if (['preacher', 'wizard_v', 'sensei', 'professor', 'ancient', 'opera'].includes(p.name)) cat = 'Calm / Wise'
else if (['chipmunk', 'baby', 'fairy', 'angel', 'tutorial', 'valley'].includes(p.name)) cat = 'Cute / High'
else if (['robot', 'ai_core', 'mech', 'android_v', 'siri', 'hal', 'dial_up', 'glitch', 'glitchbot'].includes(p.name)) cat = 'Robots'
else if (['posh', 'aussie', 'scottish', 'french', 'texan'].includes(p.name)) cat = 'Accents'
else if (['whisper', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v', 'alien_v', 'echo_v'].includes(p.name)) cat = 'Characters'
else if (['grandpa', 'grandma', 'crotchety'].includes(p.name)) cat = 'Old People'
else if (['drunk', 'sleepy', 'stoner', 'npc', 'conspiracy'].includes(p.name)) cat = 'Misc Characters'
if (!groups[cat]) groups[cat] = []
groups[cat].push(p)
}
return groups
})
async function initAudio() {
await ensureAudioContext()
audioReady.value = true
}
function playVoice(profileName: string) {
if (!audioReady.value) return
playing.value = profileName
speak(customText.value || 'Devastating blow! That had to hurt!', profileName, true)
setTimeout(() => { if (playing.value === profileName) playing.value = null }, 3000)
}
const samplePhrases = [
'Devastating blow! That had to hurt!',
'Round one! Fight!',
'K. O.! And the winner is...',
'What an incredible combo!',
'The crowd goes wild!',
'Is that all you got?',
'Satoshi would be proud!',
'Lightning fast attack!',
'Not your keys, not your coins!',
'Stack sats and throw hands!',
]
function randomPhrase() {
customText.value = samplePhrases[Math.floor(Math.random() * samplePhrases.length)]
}
</script>
<template>
<div class="min-h-screen bg-black text-green-400 p-4 sm:p-8 font-mono">
<h1 class="text-2xl sm:text-3xl font-bold text-cyan-400 mb-2">VOICE SOUNDBOARD</h1>
<p class="text-zinc-500 text-sm mb-6">{{ allProfiles.length }} voice profiles. Click to preview.</p>
<!-- Audio init banner -->
<div v-if="!audioReady" class="mb-6">
<button
@click="initAudio"
class="px-6 py-3 bg-cyan-600 hover:bg-cyan-500 text-black font-bold rounded text-lg transition-colors"
>
CLICK TO ENABLE AUDIO
</button>
</div>
<!-- Status -->
<div v-if="audioReady" class="mb-4 flex items-center gap-3 text-sm">
<span v-if="isKokoroReady()" class="text-green-400">Kokoro TTS: READY</span>
<span v-else-if="isKokoroLoading()" class="text-yellow-400">Kokoro TTS: Loading model...</span>
<span v-else class="text-zinc-500">Kokoro TTS: Not loaded (using Web Speech fallback)</span>
</div>
<!-- Custom text + filter -->
<div class="flex flex-col sm:flex-row gap-3 mb-6">
<div class="flex-1 flex gap-2">
<input
v-model="customText"
class="flex-1 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
placeholder="Type custom text to speak..."
/>
<button
@click="randomPhrase"
class="px-3 py-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-600 rounded text-xs text-zinc-400 transition-colors whitespace-nowrap"
>
Random
</button>
</div>
<input
v-model="filter"
class="sm:w-48 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
placeholder="Filter voices..."
/>
</div>
<!-- Voice grid by category -->
<div v-for="(profiles, category) in categories" :key="category" class="mb-8">
<h2 class="text-lg font-bold text-yellow-400 mb-3 border-b border-zinc-800 pb-1">{{ category }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
<button
v-for="p in profiles"
:key="p.name"
:disabled="!audioReady"
@click="playVoice(p.name)"
class="text-left px-3 py-2 rounded border transition-all"
:class="[
playing === p.name
? 'bg-cyan-900/40 border-cyan-500 text-cyan-300'
: 'bg-zinc-900/60 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/60',
!audioReady && 'opacity-40 cursor-not-allowed'
]"
>
<div class="font-bold text-sm" :class="playing === p.name ? 'text-cyan-300' : 'text-green-400'">
{{ p.name }}
</div>
<div class="text-xs text-zinc-500 mt-0.5">
{{ p.kokoroVoice }} @ {{ p.speed }}x
</div>
</button>
</div>
</div>
</div>
</template>
+5
View File
@@ -61,6 +61,11 @@ const routes = [
name: 'docs',
component: () => import('./pages/DocsPage.vue'),
},
{
path: '/soundboard',
name: 'soundboard',
component: () => import('./pages/SoundboardPage.vue'),
},
]
export const router = createRouter({
+15
View File
@@ -55,8 +55,23 @@ export default defineConfig({
},
}),
],
worker: {
format: 'es', // Required for dynamic import('kokoro-js') inside the TTS worker
},
optimizeDeps: {
// Pre-bundle kokoro-js on first startup so Vite doesn't stall mid-page-load
// discovering it as a new dep. The 4.8MB bundle is cached in node_modules/.vite/deps.
include: ['kokoro-js'],
},
server: {
port: 9101,
headers: {
// Enable SharedArrayBuffer for onnxruntime WASM threads.
// Without these, Kokoro TTS runs single-threaded on the main thread,
// blocking the UI for seconds on every TTS generation.
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless',
},
proxy: {
'/api': {
target: 'http://localhost:9100',
View File
+30 -3
View File
@@ -12,6 +12,13 @@ import { getPendingChallenge, submitHumanResponse } from '../engine/human-respon
export const fightsRouter = new Hono()
// Track spectator counts per fight
const spectatorCounts = new Map<string, number>()
export function getSpectatorCount(fightId: string): number {
return spectatorCounts.get(fightId) || 0
}
// List recent fights (with bot names)
fightsRouter.get('/', async (c) => {
const rows = await db.select()
@@ -322,10 +329,20 @@ fightsRouter.get('/:id/stream', (c) => {
const fightId = c.req.param('id')
return streamSSE(c, async (stream) => {
// Track spectator
spectatorCounts.set(fightId, (spectatorCounts.get(fightId) || 0) + 1)
const count = spectatorCounts.get(fightId)!
// Send initial spectator count
await stream.writeSSE({
event: 'spectator_count',
data: JSON.stringify({ count }),
})
const cleanup = fightEvents.on(fightId, (event) => {
stream.writeSSE({
event: event.type,
data: JSON.stringify(event.data),
data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }),
})
})
@@ -333,14 +350,17 @@ fightsRouter.get('/:id/stream', (c) => {
if (event.fightId === fightId && event.type === 'fight_end') {
stream.writeSSE({
event: 'fight_end',
data: JSON.stringify(event.data),
data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }),
})
}
})
try {
while (true) {
await stream.writeSSE({ event: 'ping', data: '' })
await stream.writeSSE({
event: 'ping',
data: JSON.stringify({ spectators: spectatorCounts.get(fightId) || 0 }),
})
await stream.sleep(5000)
const fight = await db.select({ status: schema.fights.status })
.from(schema.fights)
@@ -351,6 +371,13 @@ fightsRouter.get('/:id/stream', (c) => {
} catch {
// Client disconnected
} finally {
// Decrement spectator count
const current = spectatorCounts.get(fightId) || 1
if (current <= 1) {
spectatorCounts.delete(fightId)
} else {
spectatorCounts.set(fightId, current - 1)
}
cleanup()
cleanupGlobal()
}