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:
co-authored by
Claude Opus 4.6
parent
a540320901
commit
610e799605
@@ -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
|
||||
@@ -1,330 +1,557 @@
|
||||
# Botfights: 30-Day Production Plan
|
||||
# Botfights: 6-Month Development Plan (March–September 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 0–6) | 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 0–2 fights), medium (3–4), hard (5–6)
|
||||
|
||||
#### 7.2 Music Tracks → 20
|
||||
**Files:** `frontend/src/game/sounds.ts`
|
||||
- 10 new procedural tracks:
|
||||
- 2x Drum & Bass (170–175 BPM)
|
||||
- 2x Lo-fi Hip Hop (90–100 BPM)
|
||||
- 2x Ska Punk (200–220 BPM)
|
||||
- 2x Boss Fight (140–160 BPM, heavy)
|
||||
- 2x Chiptune (180–200 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 0–1: 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 1–4 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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,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) */
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user