feat: retro mode — arcade combo round with gamepad overlays
Every fight now includes one Retro Mode round where bots submit gamepad combo inputs (↑↓←→ A B). 24 moves across 4 tiers: basic (always shown), standard (partially revealed), super (must discover), and ultra (KONAMI CODE for 50 dmg). Discovery bonus gives 1.5x damage. Includes pixel-art gamepad overlays (P1/P2) with animated button presses, retro-specific narrations, and mock bot combo responses scaled by ELO. Also adds loops/plan.md with 11-phase production hardening roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0d6ba7d5a7
commit
1f8e8569ef
@@ -360,6 +360,8 @@ async function _doReplay() {
|
|||||||
isCritical,
|
isCritical,
|
||||||
botAScore: round.botAScore || 0,
|
botAScore: round.botAScore || 0,
|
||||||
botBScore: round.botBScore || 0,
|
botBScore: round.botBScore || 0,
|
||||||
|
botAResponse: round.challengeType === 'retro_mode' ? (round.botAResponse || undefined) : undefined,
|
||||||
|
botBResponse: round.challengeType === 'retro_mode' ? (round.botBResponse || undefined) : undefined,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[FightViewer] playRound ${round.roundNumber} error:`, err)
|
console.error(`[FightViewer] playRound ${round.roundNumber} error:`, err)
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface RoundEvent {
|
|||||||
isCritical: boolean
|
isCritical: boolean
|
||||||
botAScore: number
|
botAScore: number
|
||||||
botBScore: number
|
botBScore: number
|
||||||
|
botAResponse?: string
|
||||||
|
botBResponse?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARENA_THEMES: Record<string, { bg: string; ground: string; accent: string }> = {
|
const ARENA_THEMES: Record<string, { bg: string; ground: string; accent: string }> = {
|
||||||
@@ -97,6 +99,7 @@ const CHALLENGE_THEMED: Record<string, { themed: string[]; generic: string[] }>
|
|||||||
demolition: { themed: ['dynamiteBlast', 'c4Detonation', 'nukeStrike', 'grenadeBlast', 'rocketLauncher', 'volcanoErupt'], generic: ['fireworksBurst', 'partyPopper', 'pinataSmash', 'cherryBomb', 'confettiCannon', 'anvilDrop', 'pianoDrop'] },
|
demolition: { themed: ['dynamiteBlast', 'c4Detonation', 'nukeStrike', 'grenadeBlast', 'rocketLauncher', 'volcanoErupt'], generic: ['fireworksBurst', 'partyPopper', 'pinataSmash', 'cherryBomb', 'confettiCannon', 'anvilDrop', 'pianoDrop'] },
|
||||||
vehicle_mayhem: { themed: ['motorbikeCharge', 'carSmash', 'tankRoll', 'helicopterStrike', 'zamboniCrush', 'tractorPlow'], generic: ['shoppingCart', 'forkliftCharge', 'airplaneSwoop', 'rocketRide', 'unicycleRun', 'golfCartDrive', 'boatCannon'] },
|
vehicle_mayhem: { themed: ['motorbikeCharge', 'carSmash', 'tankRoll', 'helicopterStrike', 'zamboniCrush', 'tractorPlow'], generic: ['shoppingCart', 'forkliftCharge', 'airplaneSwoop', 'rocketRide', 'unicycleRun', 'golfCartDrive', 'boatCannon'] },
|
||||||
medieval_combat: { themed: ['swordSlash', 'katanaCombo', 'battleAxe', 'maceSwing', 'crossbowBolt', 'shieldBash'], generic: ['flailSwing', 'holyWater', 'dragonBreath', 'enchantedArrow', 'laserSword', 'scrollBlast'] },
|
medieval_combat: { themed: ['swordSlash', 'katanaCombo', 'battleAxe', 'maceSwing', 'crossbowBolt', 'shieldBash'], generic: ['flailSwing', 'holyWater', 'dragonBreath', 'enchantedArrow', 'laserSword', 'scrollBlast'] },
|
||||||
|
retro_mode: { themed: ['dashPunch', 'uppercut', 'rapidFlurry', 'flyingKick', 'multiHit', 'fullScreenDash', 'backflipKick', 'cycloneKick'], generic: ['bodySlam', 'grappleFlurry', 'suplex', 'katanaCombo', 'breakdanceSweep', 'corkscrewDive', 'pinballCombo'] },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier-gated ultimate move pools — cumulative (tier 3 bot can use tier 2+3 moves)
|
// Tier-gated ultimate move pools — cumulative (tier 3 bot can use tier 2+3 moves)
|
||||||
@@ -7833,6 +7836,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
hallucination_check: () => announceHype('HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!'),
|
hallucination_check: () => announceHype('HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!'),
|
||||||
trap_card: () => announceDramatic('TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!'),
|
trap_card: () => announceDramatic('TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!'),
|
||||||
token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'),
|
token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'),
|
||||||
|
retro_mode: () => announceHype('RETRO MODE! INSERT COIN! FIGHT!'),
|
||||||
}
|
}
|
||||||
const voiceFn = challengeVoice[event.challengeType]
|
const voiceFn = challengeVoice[event.challengeType]
|
||||||
if (voiceFn) { voiceFn(); didChallengeVoice = true }
|
if (voiceFn) { voiceFn(); didChallengeVoice = true }
|
||||||
@@ -7881,6 +7885,98 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === RETRO MODE GAMEPAD OVERLAY ===
|
||||||
|
const retroObjs: any[] = []
|
||||||
|
if (event.challengeType === 'retro_mode') {
|
||||||
|
const padW = 90
|
||||||
|
const padH = 70
|
||||||
|
const padY = 18
|
||||||
|
const padAX = 12
|
||||||
|
const padBX = W - padW - 12
|
||||||
|
const btnSize = 14
|
||||||
|
const dpadColor = '#333333'
|
||||||
|
const btnOff = '#444444'
|
||||||
|
const btnA = '#22cc44'
|
||||||
|
const btnB = '#cc3333'
|
||||||
|
const labelColor = '#00f0ff'
|
||||||
|
|
||||||
|
// Draw two gamepads
|
||||||
|
for (const side of ['a', 'b'] as const) {
|
||||||
|
const px = side === 'a' ? padAX : padBX
|
||||||
|
// Pad background
|
||||||
|
retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#111111')), k.opacity(0.85), k.z(48)]))
|
||||||
|
retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#00f0ff')), k.opacity(0.15), k.z(48), k.outline(1)]))
|
||||||
|
// D-pad
|
||||||
|
const dx = px + 20
|
||||||
|
const dy = padY + 28
|
||||||
|
// Up
|
||||||
|
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy - btnSize * 1.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_up']))
|
||||||
|
// Down
|
||||||
|
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy + btnSize * 0.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_down']))
|
||||||
|
// Left
|
||||||
|
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize * 1.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_left']))
|
||||||
|
// Right
|
||||||
|
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx + btnSize * 0.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_right']))
|
||||||
|
// A button
|
||||||
|
retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 30, dy - 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_A']))
|
||||||
|
retroObjs.push(k.add([k.text('A', { size: 8 }), k.pos(px + padW - 33, dy - 10), k.color(safeColor(k, '#888')), k.z(50)]))
|
||||||
|
// B button
|
||||||
|
retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 16, dy + 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_B']))
|
||||||
|
retroObjs.push(k.add([k.text('B', { size: 8 }), k.pos(px + padW - 19, dy + 2), k.color(safeColor(k, '#888')), k.z(50)]))
|
||||||
|
// Label
|
||||||
|
const label = side === 'a' ? 'P1' : 'P2'
|
||||||
|
retroObjs.push(k.add([k.text(label, { size: 10 }), k.pos(px + padW / 2 - 6, padY + 3), k.color(safeColor(k, labelColor)), k.z(50)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate gamepad button presses based on bot responses
|
||||||
|
const flashBtn = async (side: 'a' | 'b', inputStr: string) => {
|
||||||
|
if (!inputStr) return
|
||||||
|
const arrows: Record<string, string> = { '↑': 'up', '↓': 'down', '←': 'left', '→': 'right' }
|
||||||
|
for (const ch of inputStr) {
|
||||||
|
const dir = arrows[ch]
|
||||||
|
if (dir) {
|
||||||
|
const tag = 'retro_' + side + '_' + dir
|
||||||
|
const objs = k.get(tag)
|
||||||
|
for (const o of objs) { o.color = safeColor(k, '#00f0ff'); }
|
||||||
|
await k.wait(0.08)
|
||||||
|
for (const o of objs) { o.color = safeColor(k, dpadColor); }
|
||||||
|
}
|
||||||
|
if (ch === 'A' || ch === 'a') {
|
||||||
|
const objs = k.get('retro_' + side + '_A')
|
||||||
|
for (const o of objs) { if (o.color) o.color = safeColor(k, btnA); }
|
||||||
|
await k.wait(0.08)
|
||||||
|
for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); }
|
||||||
|
}
|
||||||
|
if (ch === 'B' || ch === 'b') {
|
||||||
|
const objs = k.get('retro_' + side + '_B')
|
||||||
|
for (const o of objs) { if (o.color) o.color = safeColor(k, btnB); }
|
||||||
|
await k.wait(0.08)
|
||||||
|
for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and animate each bot's combo moves
|
||||||
|
const movesA = (event.botAResponse || '').split('|').map(s => s.trim()).filter(Boolean).slice(0, 3)
|
||||||
|
const movesB = (event.botBResponse || '').split('|').map(s => s.trim()).filter(Boolean).slice(0, 3)
|
||||||
|
|
||||||
|
// Flash moves in parallel for both pads
|
||||||
|
const animateCombo = async (side: 'a' | 'b', moves: string[]) => {
|
||||||
|
for (const combo of moves) {
|
||||||
|
await flashBtn(side, combo)
|
||||||
|
// Show combo text above pad
|
||||||
|
const px = side === 'a' ? padAX : padBX
|
||||||
|
const comboLabel = k.add([k.text(combo, { size: 8 }), k.pos(px + 4, padY + padH + 4), k.color(safeColor(k, '#ffff00')), k.opacity(1), k.z(50)])
|
||||||
|
retroObjs.push(comboLabel)
|
||||||
|
await k.wait(0.25)
|
||||||
|
comboLabel.opacity = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fire-and-forget — the animations run during the exchange loop below
|
||||||
|
animateCombo('a', movesA)
|
||||||
|
animateCombo('b', movesB)
|
||||||
|
}
|
||||||
|
|
||||||
for (let ex = 0; ex < exchangeCount; ex++) {
|
for (let ex = 0; ex < exchangeCount; ex++) {
|
||||||
const isLastExchange = ex === exchangeCount - 1
|
const isLastExchange = ex === exchangeCount - 1
|
||||||
// In earlier exchanges, sometimes the loser attacks back
|
// In earlier exchanges, sometimes the loser attacks back
|
||||||
@@ -8051,6 +8147,9 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
speedLines.forEach(l => { if (l.exists()) l.destroy() })
|
speedLines.forEach(l => { if (l.exists()) l.destroy() })
|
||||||
speedLines = []
|
speedLines = []
|
||||||
|
|
||||||
|
// Clean up retro gamepad overlays
|
||||||
|
retroObjs.forEach(o => { if (o.exists()) o.destroy() })
|
||||||
|
|
||||||
// Clean up grotesque overlays and any leftover morphs before zooming out
|
// Clean up grotesque overlays and any leftover morphs before zooming out
|
||||||
destroyGrotesqueDetails()
|
destroyGrotesqueDetails()
|
||||||
destroyMorphOverlays()
|
destroyMorphOverlays()
|
||||||
|
|||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
# Overnight Plan — Production Hardening (2-Month Roadmap)
|
||||||
|
|
||||||
|
> Harden botfights for production: database indexes, memory leak fixes, payment security, Nostr auth bugs, form validation, observability, deployment.
|
||||||
|
> Server: `/Users/dorian/projects/botfights/server/` — Write/Edit tools blocked by hooks; use Bash heredoc for server file writes.
|
||||||
|
> Frontend: `/Users/dorian/Projects/botfights/frontend/` — standard Write/Edit tools work.
|
||||||
|
> Follow CLAUDE.md conventions: `<script setup lang="ts">`, conventional commits, no external code copying.
|
||||||
|
> Run `pnpm typecheck` from project root after each phase to verify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Database Indexes (Critical Performance)
|
||||||
|
|
||||||
|
- [ ] **Add SQLite indexes via migration**: In `server/src/db/startup.ts`, add a new migration block after existing migrations. Use the existing pattern of `try { sqlite.exec(...) } catch {}` for each CREATE INDEX IF NOT EXISTS statement. Add these indexes:
|
||||||
|
```sql
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fights_status ON fights(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fights_bot_a ON fights(bot_a_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fights_bot_b ON fights(bot_b_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fights_created ON fights(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fights_winner ON fights(winner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fights_mode ON fights(mode);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rounds_fight ON rounds(fight_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_fight ON payments(fight_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_bot ON payments(bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bets_fight ON bets(fight_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bets_bettor ON bets(bettor_pubkey);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bets_status ON bets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bots_pubkey ON bots(public_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bots_elo ON bots(elo_rating);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bots_active ON bots(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bots_name_lower ON bots(LOWER(name));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_bot ON wallet_connections(bot_id);
|
||||||
|
```
|
||||||
|
Add a log line: `console.log('[botfights] database indexes ensured')`. Verify: `cd server && npx tsx src/index.ts` starts without errors, then Ctrl+C.
|
||||||
|
|
||||||
|
- [ ] **Enable WAL checkpoint strategy**: In `server/src/db/index.ts`, after the existing `PRAGMA journal_mode=WAL` and `PRAGMA foreign_keys=ON`, add these pragmas to the sqlite instance:
|
||||||
|
```sql
|
||||||
|
PRAGMA wal_autocheckpoint = 1000;
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
PRAGMA cache_size = -20000;
|
||||||
|
PRAGMA mmap_size = 268435456;
|
||||||
|
```
|
||||||
|
These improve concurrent read performance and prevent WAL file growth. `busy_timeout = 5000` prevents SQLITE_BUSY errors under load. `cache_size = -20000` means 20MB page cache. `mmap_size` enables memory-mapped I/O for reads. Verify: `cd server && npx tsx src/index.ts` starts without errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Memory Leak Fixes
|
||||||
|
|
||||||
|
- [ ] **Rate limiter TTL cleanup**: In `server/src/middleware/rate-limit.ts`, the `rateLimit()` function (line 14) uses an in-memory Map `hitCounts` with a 5-minute cleanup interval. The cleanup loop (`setInterval`) runs every 5 minutes but only cleans entries older than the window. Problem: the `botRateLimit()` function (line 39) uses a Map `cooldowns` with NO cleanup at all — entries persist forever. Fix: Add a cleanup interval to `botRateLimit` that runs every 60 seconds and deletes entries older than `cooldownMs * 2`. Also, cap the `hitCounts` Map size: if it exceeds 10,000 entries, clear the oldest half. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Active fighters Set leak prevention**: In `server/src/engine/orchestrator.ts`, the `activeFighters` Set (around line 37) tracks bots currently in fights but relies on `finally` blocks for cleanup. Add a safety mechanism: create a `cleanupStaleFighters()` function that runs every 60 seconds. It should check each botId in `activeFighters` against the `fights` table — if no fight with status='live' exists for that bot, remove it from the Set. Call this on a `setInterval` inside the existing `cleanupOrphanedFights()` function or alongside it in `app.ts`. Log when stale entries are cleaned: `console.log('[orchestrator] cleaned stale fighter: ${botId}')`. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Betting escrow memory persistence**: In `server/src/engine/betting.ts`, the in-memory escrow Map stores bet data that would be lost on server restart. Add a `recoverEscrow()` function that runs on startup: query `bets` table for rows with `status = 'locked'` and `settled_at IS NULL`, rebuild the escrow Map from these rows. Export and call this function from `server/src/index.ts` after `seedClassicBots()`. Also add a `cleanupSettledEscrow()` function that runs every 5 minutes and removes entries from the escrow Map where all bets for that fightId have been settled (status not 'locked' or 'pending'). Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Nostr Auth — Fix Stale Profile Bug
|
||||||
|
|
||||||
|
- [ ] **Clear wallet state on key change**: In `frontend/src/composables/useNostr.ts`, the `clearAllState()` function (around line 69-76) clears `pubkey`, `bot`, and `profilePicUrl` but does NOT clear wallet-related localStorage keys. When a user generates a new identity, their old wallet connection persists in `bf_wallet_method` and `bf_nwc_url`. Fix: In `clearAllState()`, add these lines after the existing clears:
|
||||||
|
```typescript
|
||||||
|
localStorage.removeItem('bf_wallet_method')
|
||||||
|
localStorage.removeItem('bf_nwc_url')
|
||||||
|
```
|
||||||
|
This ensures wallet state from a previous identity doesn't bleed into a new one.
|
||||||
|
|
||||||
|
- [ ] **Validate stored bot belongs to current pubkey on restore**: In `frontend/src/composables/useNostr.ts`, the auto-restore logic (around line 83-105) loads `bf_bot` from localStorage and trusts it without checking if it matches the current pubkey. Fix: In the auto-restore function, after loading the stored bot and calling `POST /api/auth/login`, validate that the returned bot matches what's in localStorage. If the server returns `exists: false` but localStorage has a bot, clear the stale bot:
|
||||||
|
```typescript
|
||||||
|
if (!data.exists && bot.value) {
|
||||||
|
bot.value = null
|
||||||
|
store('bf_bot', null)
|
||||||
|
store('bf_pic', null)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Also, if the server returns a different bot ID than what's stored, update localStorage to match the server response. This prevents showing old fighter data after key regeneration. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Clear Nostr profile pic cache on identity change**: In `frontend/src/composables/useNostr.ts`, the `generateLogin()` function (around line 175-193) calls `clearAllState()` which clears `bf_pic`, but there's a subtle race condition: if `fetchNostrProfilePic()` is still running from a previous login when a new key is generated, it could write the OLD key's profile pic into the new session. Fix: Add a generation counter (simple integer ref that increments in `generateLogin()` and `loginWithNsec()`). In `fetchNostrProfilePic()`, capture the counter value at the start and check it hasn't changed before writing the result:
|
||||||
|
```typescript
|
||||||
|
const generation = ref(0)
|
||||||
|
// In generateLogin():
|
||||||
|
generation.value++
|
||||||
|
// In fetchNostrProfilePic():
|
||||||
|
const gen = generation.value
|
||||||
|
// ... after relay fetch ...
|
||||||
|
if (generation.value !== gen) return // identity changed during fetch
|
||||||
|
```
|
||||||
|
Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Force profile refetch on pubkey change in BotProfilePage**: In `frontend/src/pages/BotProfilePage.vue`, the profile data is loaded once in `onMounted()` (around line 265-276) with no reactive watcher on the current pubkey. If the user switches identity without a page reload, stale data shows. Fix: Import `watch` from vue and add a watcher on `pubkey` from `useNostr()`:
|
||||||
|
```typescript
|
||||||
|
watch(() => pubkey.value, async (newPk) => {
|
||||||
|
// Refetch stats with new pubkey
|
||||||
|
const qs = newPk ? `?pubkey=${encodeURIComponent(newPk)}` : ''
|
||||||
|
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats${qs}`)
|
||||||
|
if (res.ok) stats.value = await res.json()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Form Validation Hardening
|
||||||
|
|
||||||
|
- [ ] **Unified name validation with real-time feedback**: In `frontend/src/pages/JoinBoutPage.vue`, the bot name input (around line 608-617) and human name input (around line 866-874) both validate on submit only. Add real-time validation: create a computed `nameError` that checks: (1) length 2-12, (2) alphanumeric + `-_` only via `/^[a-zA-Z0-9_-]+$/`, (3) no leading/trailing hyphens or underscores. Show the error message below the input in red text (`text-red-400 text-xs mt-1`). Disable the confirm button when `nameError` is truthy. Apply to both `botName` and `humanName` fields. Also trim whitespace on input (use `v-model.trim`). Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Webhook URL validation with protocol enforcement**: In `frontend/src/pages/JoinBoutPage.vue`, the webhook input (around line 781-788) only validates with `new URL()` on submit. Add real-time validation: create a computed `webhookError` that checks: (1) must start with `https://` or `http://` (show "URL must start with https://"), (2) must be valid URL, (3) must not point to localhost/127.0.0.1/private IPs (mirror `isAllowedWebhookUrl` check from `server/src/engine/orchestrator.ts`). Show error below input. Add a "TEST" button next to the URL input that calls the existing test endpoint and shows latency or error inline. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Server-side validation tightening**: In `server/src/routes/auth.ts`, harden all three registration endpoints (`/register`, `/register-human`, `/login`):
|
||||||
|
1. Add input sanitization: trim all string inputs, reject strings with null bytes (`\0`), reject names that are all underscores/hyphens.
|
||||||
|
2. In `/register` (line 86): add `webhookUrl` length limit (max 2048 chars). Add `archetype` validation against a known list (import archetype names or validate it's a non-empty alphanumeric string under 32 chars). Add `profilePicUrl` length limit (max 2048 chars) and validate it starts with `https://`.
|
||||||
|
3. In `/register-human` (line 185): add `avatarSeed` validation (alphanumeric, max 32 chars).
|
||||||
|
4. In `/update` (line 247): add the same `profilePicUrl` validation (https only, max 2048).
|
||||||
|
Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **NWC connection string validation**: In `frontend/src/composables/useWallet.ts`, the `connectNWC()` function (around line 49-75) sends the NWC connection string directly to the server without client-side validation. Add validation before the API call: (1) must start with `nostr+walletconnect://`, (2) must contain `relay=` parameter, (3) must contain `secret=` parameter, (4) secret must be 64-char hex. Return a descriptive error if validation fails. Also in `frontend/src/components/WalletConnect.vue`, show these validation errors inline below the NWC paste field. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Payment Security Hardening
|
||||||
|
|
||||||
|
- [ ] **Add pubkey auth to all payment endpoints**: In `server/src/routes/payments.ts`, several endpoints lack proper authentication. Fix:
|
||||||
|
1. `POST /create-invoice` (line 86): require `pubkey` in body, verify bot belongs to that pubkey before creating invoice.
|
||||||
|
2. `GET /winnings/:botId` (line 162): require `pubkey` query param, verify bot belongs to that pubkey. Currently returns all Cashu tokens for any botId without auth — this is a critical vulnerability.
|
||||||
|
3. `POST /submit-cashu` (line 144): require `pubkey` in body, verify bot belongs to that pubkey.
|
||||||
|
4. `DELETE /disconnect-wallet` (line 212): already has pubkey, but add explicit length/format check (64-char hex).
|
||||||
|
Add a helper function `verifyBotOwnership(pubkey: string, botId: string): Promise<boolean>` to reduce code duplication. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Rate limit payment endpoints**: In `server/src/routes/payments.ts`, add rate limiting to prevent abuse:
|
||||||
|
1. `POST /connect-wallet`: `rateLimit(3600_000, 10)` — 10 connections per hour per IP
|
||||||
|
2. `POST /create-invoice`: `rateLimit(60_000, 5)` — 5 invoices per minute per IP
|
||||||
|
3. `POST /submit-cashu`: `rateLimit(60_000, 5)` — 5 redemptions per minute per IP
|
||||||
|
4. `POST /claim/:paymentId`: `rateLimit(60_000, 10)` — 10 claims per minute per IP
|
||||||
|
5. `POST /confirm/:paymentId`: `rateLimit(60_000, 10)` — 10 confirms per minute per IP
|
||||||
|
Import `rateLimit` from `../middleware/rate-limit.js`. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **NWC relay connection pooling and timeout hardening**: In `server/src/engine/payments.ts`, the `nwcRequest()` function (around line 300+) opens a new WebSocket to the NWC relay for every request with a 30-second timeout. This blocks the fight completion and can leak connections. Fix:
|
||||||
|
1. Create a `RelayPool` class that maintains a single WebSocket per relay URL, reconnects on disconnect, and queues requests.
|
||||||
|
2. Reduce the NWC response timeout from 30s to 10s.
|
||||||
|
3. Add a `nwcRequestWithRetry(method, params, retries=2)` wrapper that retries on timeout with exponential backoff (2s, 4s).
|
||||||
|
4. In `payWinner()`, run the payout in a `setTimeout(0)` / `queueMicrotask` so it doesn't block fight completion — the fight result should be returned immediately while payout happens asynchronously. Update `fight.payoutStatus` to 'pending' before returning, then 'paid' or 'failed' after the async payout completes.
|
||||||
|
Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Cashu token verification hardening**: In `server/src/engine/betting.ts`, the `placeBet()` function verifies Cashu tokens but may not handle all failure modes. In `server/src/engine/payments.ts`, the `redeemCashuToken()` function should also be hardened. Fix:
|
||||||
|
1. Add token amount verification: decode the Cashu token and verify the sum of proofs matches `amountSats` before accepting.
|
||||||
|
2. Add mint URL validation: only accept tokens from the configured `BOTFIGHTS_CASHU_MINT_URL`. Reject tokens from unknown mints.
|
||||||
|
3. Add double-spend protection: before accepting a token, check if any proof `secret` in the token already exists in the payments or bets table (store proof secrets on successful redemption).
|
||||||
|
4. Wrap all Cashu operations in try/catch with specific error messages (network error, invalid token, insufficient amount, wrong mint, double spend).
|
||||||
|
Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Payment idempotency keys**: In `server/src/routes/payments.ts`, add idempotency protection:
|
||||||
|
1. `POST /create-invoice`: Accept an optional `idempotencyKey` in the body. Before creating a new invoice, check if a payment with the same idempotency key exists. If so, return the existing invoice instead of creating a duplicate. Store the idempotency key in a new column on the payments table (add migration in startup.ts: `ALTER TABLE payments ADD COLUMN idempotency_key TEXT`).
|
||||||
|
2. `POST /confirm/:paymentId`: Already idempotent (returns existing status if already confirmed). No change needed.
|
||||||
|
3. `POST /submit-cashu`: Add idempotency key check similar to create-invoice.
|
||||||
|
Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Concurrent Fight Limits & Reliability
|
||||||
|
|
||||||
|
- [ ] **Global concurrent fight cap**: In `server/src/engine/orchestrator.ts`, there is no limit on simultaneous fights. Add a constant `MAX_CONCURRENT_FIGHTS = 20` at the top of the file. In `runFightAsync()`, before starting a fight, check `activeFighters.size / 2` (each fight has 2 fighters). If `>= MAX_CONCURRENT_FIGHTS`, reject with error `'Server at capacity — try again in a moment.'`. Also add a `getActiveFightCount()` export that returns `Math.floor(activeFighters.size / 2)`. In `server/src/routes/fights.ts`, use this in the matchmake and practice endpoints to return 503 with retry-after header when at capacity. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Webhook circuit breaker**: In `server/src/engine/orchestrator.ts`, bots are deactivated after 5 consecutive webhook errors (`consecutiveErrors >= 5`). But there's no recovery path — once deactivated, only manual `/update` re-enables. Add an automatic recovery check: in `trackWebhookResult()`, if a bot has `consecutiveErrors >= 5` and `isActive = false`, and `lastErrorAt` is more than 1 hour ago, attempt a single test call to the webhook. If it succeeds, reset `consecutiveErrors = 0` and `isActive = true`. Log: `console.log('[orchestrator] bot ${botId} auto-recovered after webhook fix')`. Cap the check to once per hour per bot using a simple Map with timestamps. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Graceful shutdown**: In `server/src/index.ts`, add SIGTERM and SIGINT handlers that:
|
||||||
|
1. Call `stopBackgroundFights()` from `server/src/engine/background.ts`
|
||||||
|
2. Wait up to 30 seconds for `activeFighters` Set to empty (poll every 1s)
|
||||||
|
3. Close the database connection: `sqlite.close()` from `server/src/db/index.ts` (export the sqlite instance if not already exported)
|
||||||
|
4. Log `[botfights] graceful shutdown complete`
|
||||||
|
5. `process.exit(0)`
|
||||||
|
This prevents data corruption on deploy/restart. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Fight timeout enforcement**: In `server/src/engine/orchestrator.ts`, the `executeFightRounds()` function has per-round timeouts but no overall fight timeout. If a fight hangs (e.g., webhook returns slowly for every round), it could block resources indefinitely. Add an overall fight timeout of 5 minutes: wrap the round loop in a `Promise.race()` with a 5-minute timer. On timeout, cancel the fight (`status = 'cancelled'`), clean up `activeFighters`, and log `[orchestrator] fight ${fightId} timed out after 5 minutes`. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Security Hardening
|
||||||
|
|
||||||
|
- [ ] **CORS origin restriction**: In `server/src/app.ts`, CORS is currently set to `'*'` (all origins) via the `CORS_ORIGIN` env var. For production, this should restrict to the actual frontend domain. Fix: In `app.ts`, where the CORS middleware is configured, change the default from `'*'` to check `NODE_ENV`: if production, require `CORS_ORIGIN` to be explicitly set and warn if it's `'*'`. Log: `console.warn('[botfights] WARNING: CORS_ORIGIN is *, set to your domain in production')`. Also add the CORS headers `Access-Control-Allow-Credentials: true` and restrict `Access-Control-Allow-Methods` to `GET, POST, DELETE, OPTIONS`. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Request body size limits**: In `server/src/app.ts`, there is no request body size limit. A malicious client could send a multi-GB body and crash the server. Fix: Add a body size limit middleware before the routes. In Hono, use the built-in body limit middleware: `app.use('/api/*', bodyLimit({ maxSize: 64 * 1024 }))` (64KB max). Import `bodyLimit` from `hono/body-limit`. For the webhook test endpoint specifically, the 10KB limit is already in the orchestrator, but the route itself should also enforce it. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Helmet-style security headers**: In `server/src/app.ts`, add a middleware that sets security headers on all responses:
|
||||||
|
```typescript
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
await next()
|
||||||
|
c.header('X-Content-Type-Options', 'nosniff')
|
||||||
|
c.header('X-Frame-Options', 'DENY')
|
||||||
|
c.header('X-XSS-Protection', '0')
|
||||||
|
c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
Place this BEFORE the CORS middleware so it applies to all responses. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Webhook SSRF hardening**: In `server/src/engine/orchestrator.ts`, the `isAllowedWebhookUrl()` function (around line 50) blocks private IPs but uses simple string matching. Harden it: (1) resolve the hostname to IP via DNS lookup (`dns.promises.lookup`) and check the resolved IP against private ranges — this catches DNS rebinding attacks where a domain resolves to 127.0.0.1. (2) Block `file://`, `ftp://`, `data:` schemes. (3) Block URLs with userinfo (`user:pass@host`). (4) Block URLs with ports below 80 or above 65535 (except 443, 8080, 8443, 3000-9999). Make the function async and update all callers (`auth.ts` register and update endpoints). Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Wallet encryption key enforcement in production**: In `server/src/engine/crypto.ts` (38 lines), if `BOTFIGHTS_WALLET_ENCRYPTION_KEY` is not set, a random key is generated. This means wallet data encrypted in one session can't be decrypted after a restart. Fix: In production (`NODE_ENV === 'production'`), throw an error on startup if `BOTFIGHTS_WALLET_ENCRYPTION_KEY` is not set, rather than silently using a random key. In dev mode, keep the random key behavior but log a warning. Also validate that the key is exactly 64 hex characters (32 bytes). Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Observability & Health
|
||||||
|
|
||||||
|
- [ ] **Health check endpoint**: In `server/src/app.ts`, add a `GET /health` endpoint (outside `/api/*` so it's not rate-limited) that returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": process.uptime(),
|
||||||
|
"activeFights": activeFighters.size / 2,
|
||||||
|
"dbOk": true/false,
|
||||||
|
"timestamp": new Date().toISOString()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Check DB health by running `SELECT 1` via `sqlite.prepare('SELECT 1').get()`. If it throws, set `dbOk: false` and `status: 'degraded'`. Import `activeFighters` from orchestrator or use the new `getActiveFightCount()`. Return 200 for ok, 503 for degraded. This endpoint will be used by the VPS monitoring and deployment scripts. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Structured logging utility**: Create `server/src/engine/logger.ts` with a simple structured logger. No dependencies — just wrap `console.log`/`console.error` with JSON output in production and human-readable in dev:
|
||||||
|
```typescript
|
||||||
|
export function log(level: 'info' | 'warn' | 'error', component: string, message: string, data?: Record<string, unknown>) {
|
||||||
|
const entry = { ts: Date.now(), level, component, msg: message, ...data }
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console[level === 'error' ? 'error' : 'log'](JSON.stringify(entry))
|
||||||
|
} else {
|
||||||
|
console[level === 'error' ? 'error' : 'log'](`[${component}] ${message}`, data || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Replace 5-10 key `console.log` calls in `orchestrator.ts`, `payments.ts`, and `background.ts` with this logger (focus on fight start/end, payment events, and errors). Do NOT replace every console.log — just the critical operational ones. Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Request timing middleware**: In `server/src/app.ts`, add a middleware that logs slow requests. Before the routes, add:
|
||||||
|
```typescript
|
||||||
|
app.use('/api/*', async (c, next) => {
|
||||||
|
const start = Date.now()
|
||||||
|
await next()
|
||||||
|
const ms = Date.now() - start
|
||||||
|
if (ms > 1000) {
|
||||||
|
log('warn', 'http', `slow request: ${c.req.method} ${c.req.path}`, { ms, status: c.res.status })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
Import `log` from `./engine/logger.js`. This catches endpoints that are unexpectedly slow (DB queries without indexes, webhook timeouts). Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Fight metrics counter**: In `server/src/engine/orchestrator.ts`, add a simple metrics object that tracks counts since startup:
|
||||||
|
```typescript
|
||||||
|
export const metrics = {
|
||||||
|
fightsStarted: 0,
|
||||||
|
fightsCompleted: 0,
|
||||||
|
fightsCancelled: 0,
|
||||||
|
webhookErrors: 0,
|
||||||
|
webhookTimeouts: 0,
|
||||||
|
avgRoundMs: 0,
|
||||||
|
totalRoundMs: 0,
|
||||||
|
totalRounds: 0,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Increment `fightsStarted` in `runFightAsync()`, `fightsCompleted`/`fightsCancelled` in `executeFightRounds()`, `webhookErrors`/`webhookTimeouts` in `callWebhook()`. Track round duration and compute running average. Expose these via `GET /health` endpoint (add `metrics` field to the health response). Verify: `cd server && npx tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Frontend Polish & Error States
|
||||||
|
|
||||||
|
- [ ] **Global API error handler**: In `frontend/src/composables/useNostr.ts` and `frontend/src/composables/useWallet.ts`, API calls use raw `fetch()` without consistent error handling. Create `frontend/src/utils/api.ts` with a thin wrapper:
|
||||||
|
```typescript
|
||||||
|
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new ApiError(res.status, body.error || 'Unknown error', body.details)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public status: number, message: string, public details?: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Replace 5-8 of the most important `fetch()` calls in `useNostr.ts` (login, register, registerHuman, update) and `useWallet.ts` (connectNWC, payEntryFee, createInvoice) with `apiFetch()`. Handle `ApiError` in the callers to show user-friendly error messages. Do NOT replace every fetch call — focus on the ones where errors are user-facing. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Loading states for all async operations in JoinBoutPage**: In `frontend/src/pages/JoinBoutPage.vue`, there are multiple async operations (login, name check, webhook test, registration) that should show loading states. Check each submit button: if it doesn't already have a loading/disabled state, add one. Specifically:
|
||||||
|
1. Login button: add `isLoggingIn` ref, set true during `handleLogin()`, disable button and show "LOGGING IN..." text while true.
|
||||||
|
2. Name confirm buttons (both bot and human): already have `isCheckingName` — verify it works and the button text changes.
|
||||||
|
3. Webhook confirm: add `isTestingWebhook` ref, show "TESTING..." during the webhook verification.
|
||||||
|
4. Register: add `isRegistering` ref, show "REGISTERING..." during the API call.
|
||||||
|
All buttons should use `:disabled="isLoading"` and show the loading text via ternary in the button content. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
- [ ] **Error toast/notification system**: Create `frontend/src/composables/useToast.ts` — a minimal toast notification composable:
|
||||||
|
```typescript
|
||||||
|
const toasts = ref<Array<{ id: number; message: string; type: 'error' | 'success' | 'info'; timeout: number }>>([])
|
||||||
|
let nextId = 0
|
||||||
|
export function useToast() {
|
||||||
|
function show(message: string, type: 'error' | 'success' | 'info' = 'info', duration = 4000) {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value.push({ id, message, type, timeout: window.setTimeout(() => dismiss(id), duration) })
|
||||||
|
}
|
||||||
|
function dismiss(id: number) {
|
||||||
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||||
|
}
|
||||||
|
return { toasts: readonly(toasts), show, dismiss }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Create `frontend/src/components/ToastContainer.vue` — renders toasts fixed at bottom-right with appropriate colors (red for error, green for success, cyan for info). Style matches existing neon aesthetic: `font-display`, `tracking-wider`, dark background with colored border. Add `<ToastContainer />` to `frontend/src/App.vue` (or the root layout). Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: Deployment & Operations
|
||||||
|
|
||||||
|
- [ ] **Database backup script**: Create `server/scripts/backup-db.sh`:
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
DB_PATH="${DB_PATH:-./data/botfights.db}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/botfights_$TIMESTAMP.db'"
|
||||||
|
# Keep only last 30 backups
|
||||||
|
ls -t "$BACKUP_DIR"/botfights_*.db | tail -n +31 | xargs -r rm
|
||||||
|
echo "[backup] created botfights_$TIMESTAMP.db ($(du -h "$BACKUP_DIR/botfights_$TIMESTAMP.db" | cut -f1))"
|
||||||
|
```
|
||||||
|
Make it executable: `chmod +x server/scripts/backup-db.sh`. This uses SQLite's online backup API (safe during writes). The 30-backup retention keeps ~1 day of hourly backups. Verify: script is syntactically valid with `bash -n server/scripts/backup-db.sh`.
|
||||||
|
|
||||||
|
- [ ] **Systemd service file**: Create `server/deploy/botfights.service`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Botfights Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=botfights
|
||||||
|
WorkingDirectory=/opt/botfights/server
|
||||||
|
ExecStart=/usr/bin/node --experimental-specifier-resolution=node dist/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=9100
|
||||||
|
EnvironmentFile=-/opt/botfights/.env
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=botfights
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/opt/botfights/server/data
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
Also create `server/deploy/botfights-backup.timer` and `server/deploy/botfights-backup.service` for hourly database backups via systemd timer. The backup service runs `/opt/botfights/server/scripts/backup-db.sh`. Verify: files are valid INI format.
|
||||||
|
|
||||||
|
- [ ] **Zero-downtime deploy script**: Create `server/scripts/deploy.sh`:
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
DEPLOY_DIR="/opt/botfights"
|
||||||
|
echo "[deploy] pulling latest..."
|
||||||
|
cd "$DEPLOY_DIR" && git pull origin main
|
||||||
|
echo "[deploy] installing deps..."
|
||||||
|
cd server && pnpm install --frozen-lockfile
|
||||||
|
echo "[deploy] building..."
|
||||||
|
pnpm build
|
||||||
|
echo "[deploy] backing up database..."
|
||||||
|
bash scripts/backup-db.sh
|
||||||
|
echo "[deploy] restarting service..."
|
||||||
|
sudo systemctl restart botfights
|
||||||
|
echo "[deploy] waiting for health check..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf http://localhost:9100/health > /dev/null 2>&1; then
|
||||||
|
echo "[deploy] healthy after ${i}s"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "[deploy] FAILED: health check timeout"
|
||||||
|
exit 1
|
||||||
|
```
|
||||||
|
Make executable: `chmod +x server/scripts/deploy.sh`. Verify: `bash -n server/scripts/deploy.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: Typecheck & Final Verification
|
||||||
|
|
||||||
|
- [ ] **Full typecheck**: Run `pnpm typecheck` from project root (`/Users/dorian/Projects/botfights`). Fix ALL TypeScript errors in both server and frontend. This is the final gate — every previous phase should have verified individually, but this catches any cross-package issues.
|
||||||
|
|
||||||
|
- [ ] **Server startup smoke test**: Run `cd /Users/dorian/projects/botfights/server && npx tsx src/index.ts` and verify:
|
||||||
|
1. `[botfights] database migrated` appears
|
||||||
|
2. `[botfights] database indexes ensured` appears
|
||||||
|
3. No crash on startup
|
||||||
|
4. `curl http://localhost:9100/health` returns `{"status":"ok",...}`
|
||||||
|
5. `curl http://localhost:9100/api/bots` returns a list
|
||||||
|
Stop the server after verification.
|
||||||
|
|
||||||
|
- [ ] **Frontend build**: Run `cd /Users/dorian/Projects/botfights/frontend && pnpm build`. Verify zero errors. Check the output bundle size is reasonable (should be under 5MB total).
|
||||||
|
|
||||||
|
- [ ] **Commit all changes**: Stage all modified and new files. Commit with message: `feat: production hardening — indexes, auth fixes, payment security, observability`. Do NOT push — the user will review and push manually.
|
||||||
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'crypto'
|
|||||||
import { db, schema, sqlite } from '../db/index.js'
|
import { db, schema, sqlite } from '../db/index.js'
|
||||||
import { randomArena } from './arenas.js'
|
import { randomArena } from './arenas.js'
|
||||||
import { pickChallenge, type Challenge } from './challenges.js'
|
import { pickChallenge, type Challenge } from './challenges.js'
|
||||||
|
import { generateMockRetroResponse } from './retro-moves.js'
|
||||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, schema, sqlite } from '../db/index.js'
|
|||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import { randomArena, type Arena } from './arenas.js'
|
import { randomArena, type Arena } from './arenas.js'
|
||||||
import { pickChallenge, type Challenge } from './challenges.js'
|
import { pickChallenge, type Challenge } from './challenges.js'
|
||||||
|
import { generateRetroChallenge } from './retro-moves.js'
|
||||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||||
import { fightEvents } from './events.js'
|
import { fightEvents } from './events.js'
|
||||||
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
|
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
|
||||||
@@ -326,8 +327,13 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
|||||||
let winnerId: string | null = null
|
let winnerId: string | null = null
|
||||||
const usedTypes = new Set<string>()
|
const usedTypes = new Set<string>()
|
||||||
|
|
||||||
|
// Pick a random round for retro mode (rounds 3-8, ensuring it's not too early or late)
|
||||||
|
const retroRound = 3 + Math.floor(Math.random() * Math.min(6, MAX_ROUNDS - 4))
|
||||||
|
|
||||||
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
||||||
const challenge = pickChallenge(usedTypes, arena.modifier)
|
const challenge = round === retroRound
|
||||||
|
? generateRetroChallenge()
|
||||||
|
: pickChallenge(usedTypes, arena.modifier)
|
||||||
usedTypes.add(challenge.type)
|
usedTypes.add(challenge.type)
|
||||||
|
|
||||||
emit(fightId, 'round_start', {
|
emit(fightId, 'round_start', {
|
||||||
@@ -378,7 +384,7 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
|||||||
fightId,
|
fightId,
|
||||||
roundNumber: round,
|
roundNumber: round,
|
||||||
challengeType: challenge.type,
|
challengeType: challenge.type,
|
||||||
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring }),
|
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring, retroKnown: challenge.type === 'retro_mode' ? challenge.answers : undefined }),
|
||||||
botAResponse: responseA.answer,
|
botAResponse: responseA.answer,
|
||||||
botATimeMs: responseA.timeMs,
|
botATimeMs: responseA.timeMs,
|
||||||
botAScore: result.botAScore,
|
botAScore: result.botAScore,
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Retro Mode — arcade fighting game combo engine
|
||||||
|
import type { Challenge } from './challenges.js'
|
||||||
|
|
||||||
|
export interface RetroMove {
|
||||||
|
input: string
|
||||||
|
name: string
|
||||||
|
damage: number
|
||||||
|
tier: 'basic' | 'standard' | 'super' | 'ultra'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RETRO_MOVES: RetroMove[] = [
|
||||||
|
// Basic — always revealed
|
||||||
|
{ input: 'A', name: 'Jab', damage: 5, tier: 'basic' },
|
||||||
|
{ input: 'B', name: 'Kick', damage: 6, tier: 'basic' },
|
||||||
|
{ input: '→+A', name: 'Hook', damage: 8, tier: 'basic' },
|
||||||
|
{ input: '←+B', name: 'Low Kick', damage: 7, tier: 'basic' },
|
||||||
|
// Standard — some revealed each fight
|
||||||
|
{ input: '↓→+A', name: 'Fireball', damage: 12, tier: 'standard' },
|
||||||
|
{ input: '↓←+B', name: 'Spin Kick', damage: 14, tier: 'standard' },
|
||||||
|
{ input: '→→+A', name: 'Dash Punch', damage: 15, tier: 'standard' },
|
||||||
|
{ input: '↑↓+A', name: 'Uppercut', damage: 16, tier: 'standard' },
|
||||||
|
{ input: '←→+B', name: 'Slide Kick', damage: 13, tier: 'standard' },
|
||||||
|
{ input: '↑+A', name: 'Rising Fist', damage: 11, tier: 'standard' },
|
||||||
|
{ input: '↓+B+A', name: 'Leg Sweep', damage: 10, tier: 'standard' },
|
||||||
|
{ input: '→+B+A', name: 'Elbow Strike', damage: 12, tier: 'standard' },
|
||||||
|
// Super — never revealed, must discover
|
||||||
|
{ input: '↓→↓→+A', name: 'Hadouken', damage: 22, tier: 'super' },
|
||||||
|
{ input: '←↓→+B', name: 'Dragon Kick', damage: 25, tier: 'super' },
|
||||||
|
{ input: '↑↑↓↓+A', name: 'Power Surge', damage: 28, tier: 'super' },
|
||||||
|
{ input: '→←→+A+B', name: 'Tiger Knee', damage: 24, tier: 'super' },
|
||||||
|
{ input: '↓↓↑+B+A', name: 'Shoryuken', damage: 26, tier: 'super' },
|
||||||
|
{ input: '←←→→+A', name: 'Sonic Boom', damage: 23, tier: 'super' },
|
||||||
|
{ input: '↑→↓←+A+B', name: 'Cyclone', damage: 30, tier: 'super' },
|
||||||
|
// Ultra — the ultimate secret
|
||||||
|
{ input: '↑↑↓↓←→←→+B+A', name: 'KONAMI CODE', damage: 50, tier: 'ultra' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Build canonical form: arrows joined, then +buttons
|
||||||
|
function canonicalize(raw: string): string {
|
||||||
|
let s = raw
|
||||||
|
s = s.replace(/\bup\b/gi, '↑')
|
||||||
|
s = s.replace(/\bdown\b/gi, '↓')
|
||||||
|
s = s.replace(/\bleft\b/gi, '←')
|
||||||
|
s = s.replace(/\bright\b/gi, '→')
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
const buttons: string[] = []
|
||||||
|
|
||||||
|
for (const c of s) {
|
||||||
|
if ('↑↓←→'.includes(c)) dirs.push(c)
|
||||||
|
else if ('Aa'.includes(c)) buttons.push('A')
|
||||||
|
else if ('Bb'.includes(c)) buttons.push('B')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirs.length === 0 && buttons.length === 0) return ''
|
||||||
|
|
||||||
|
let result = dirs.join('')
|
||||||
|
if (buttons.length > 0) {
|
||||||
|
if (result.length > 0) result += '+'
|
||||||
|
result += buttons.join('+')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute canonical lookup
|
||||||
|
const MOVE_LOOKUP = new Map<string, RetroMove>()
|
||||||
|
for (const move of RETRO_MOVES) {
|
||||||
|
MOVE_LOOKUP.set(canonicalize(move.input), move)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupMove(raw: string): RetroMove | null {
|
||||||
|
return MOVE_LOOKUP.get(canonicalize(raw)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetroMoveResult {
|
||||||
|
input: string
|
||||||
|
name: string | null
|
||||||
|
damage: number
|
||||||
|
discovered: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetroScoreResult {
|
||||||
|
totalDamage: number
|
||||||
|
moves: RetroMoveResult[]
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreRetroResponse(answer: string | null, knownInputs: string[]): RetroScoreResult {
|
||||||
|
if (!answer) return { totalDamage: 0, moves: [], score: 0 }
|
||||||
|
|
||||||
|
const knownSet = new Set(knownInputs.map(canonicalize))
|
||||||
|
const parts = answer.split('|').map(s => s.trim()).filter(Boolean).slice(0, 3)
|
||||||
|
const moves: RetroMoveResult[] = []
|
||||||
|
let totalDamage = 0
|
||||||
|
|
||||||
|
for (const raw of parts) {
|
||||||
|
const move = lookupMove(raw)
|
||||||
|
if (move) {
|
||||||
|
const isDiscovered = !knownSet.has(canonicalize(move.input))
|
||||||
|
const dmg = isDiscovered ? Math.round(move.damage * 1.5) : move.damage
|
||||||
|
totalDamage += dmg
|
||||||
|
moves.push({ input: raw, name: move.name, damage: dmg, discovered: isDiscovered })
|
||||||
|
} else {
|
||||||
|
moves.push({ input: raw, name: null, damage: 0, discovered: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalDamage, moves, score: Math.min(10, totalDamage / 5) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRetroChallenge(): Challenge {
|
||||||
|
const basics = RETRO_MOVES.filter(m => m.tier === 'basic')
|
||||||
|
const standards = RETRO_MOVES.filter(m => m.tier === 'standard')
|
||||||
|
|
||||||
|
// Reveal 3-5 random standard moves
|
||||||
|
const shuffled = [...standards].sort(() => Math.random() - 0.5)
|
||||||
|
const revealCount = 3 + Math.floor(Math.random() * 3)
|
||||||
|
const revealed = shuffled.slice(0, revealCount)
|
||||||
|
|
||||||
|
const knownMoves = [...basics, ...revealed]
|
||||||
|
const knownInputs = knownMoves.map(m => m.input)
|
||||||
|
|
||||||
|
const moveList = knownMoves.map(m => ` ${m.input} = ${m.name} (${m.damage} dmg)`).join('\n')
|
||||||
|
|
||||||
|
const prompt = `RETRO MODE — ARCADE FIGHT!\n\nEnter 3 gamepad combos separated by |\nButtons: ↑ ↓ ← → A B\n\nKNOWN MOVES:\n${moveList}\n\nSECRET COMBOS exist! Longer button chains = more damage. Experiment!\n\nFormat: combo1 | combo2 | combo3\nExample: ↓→+A | B | →→+A`
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'retro_mode',
|
||||||
|
label: 'Retro Mode',
|
||||||
|
prompt,
|
||||||
|
answers: knownInputs,
|
||||||
|
timeout_ms: 12000,
|
||||||
|
scoring: 'factual',
|
||||||
|
baseDamage: 22,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate mock retro response based on bot ELO
|
||||||
|
export function generateMockRetroResponse(elo: number): string {
|
||||||
|
const basics = RETRO_MOVES.filter(m => m.tier === 'basic')
|
||||||
|
const standards = RETRO_MOVES.filter(m => m.tier === 'standard')
|
||||||
|
const supers = RETRO_MOVES.filter(m => m.tier === 'super')
|
||||||
|
|
||||||
|
const moves: string[] = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const roll = Math.random()
|
||||||
|
const superChance = Math.max(0, (elo - 1400) / 1500)
|
||||||
|
const standardChance = Math.max(0.3, (elo - 900) / 1200)
|
||||||
|
const whiffChance = Math.max(0, (1300 - elo) / 2000)
|
||||||
|
|
||||||
|
if (roll < whiffChance) {
|
||||||
|
const gibberish = ['↓↓↓+A', '→←+A+A', '↑+B+B+A', '←↓↑→+A', '→↓←+B+B'][Math.floor(Math.random() * 5)]
|
||||||
|
moves.push(gibberish)
|
||||||
|
} else if (roll < whiffChance + superChance) {
|
||||||
|
moves.push(supers[Math.floor(Math.random() * supers.length)].input)
|
||||||
|
} else if (roll < whiffChance + superChance + standardChance) {
|
||||||
|
moves.push(standards[Math.floor(Math.random() * standards.length)].input)
|
||||||
|
} else {
|
||||||
|
moves.push(basics[Math.floor(Math.random() * basics.length)].input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moves.join(' | ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all known inputs as flat strings for reference
|
||||||
|
export function getRetroMoveInputs(): string[] {
|
||||||
|
return RETRO_MOVES.map(m => m.input)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Challenge } from './challenges.js'
|
import type { Challenge } from './challenges.js'
|
||||||
import { checkAnswer } from './answers.js'
|
import { checkAnswer } from './answers.js'
|
||||||
|
import { scoreRetroResponse, type RetroScoreResult } from './retro-moves.js'
|
||||||
|
|
||||||
export interface RoundResult {
|
export interface RoundResult {
|
||||||
botAScore: number
|
botAScore: number
|
||||||
@@ -29,6 +30,11 @@ export function scoreRound(
|
|||||||
comboA: number,
|
comboA: number,
|
||||||
comboB: number,
|
comboB: number,
|
||||||
): RoundResult {
|
): RoundResult {
|
||||||
|
// Retro mode has its own scoring
|
||||||
|
if (challenge.type === 'retro_mode') {
|
||||||
|
return scoreRetroRound(challenge, botA, botB, responseA, responseB, arenaModifier, comboA, comboB)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle timeouts/errors -- instant loss for the failing bot
|
// Handle timeouts/errors -- instant loss for the failing bot
|
||||||
if (responseA.timedOut && responseB.timedOut) {
|
if (responseA.timedOut && responseB.timedOut) {
|
||||||
return {
|
return {
|
||||||
@@ -189,6 +195,7 @@ const ARENA_MODIFIER_TYPES: Record<string, string[]> = {
|
|||||||
nature_2x: ['nature_clash', 'animal_kingdom'],
|
nature_2x: ['nature_clash', 'animal_kingdom'],
|
||||||
hack_2x: ['hack_battle'],
|
hack_2x: ['hack_battle'],
|
||||||
sports_2x: ['sports_showdown'],
|
sports_2x: ['sports_showdown'],
|
||||||
|
retro_2x: ['retro_mode'],
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyModifiers(
|
function applyModifiers(
|
||||||
@@ -367,6 +374,15 @@ function generateNarration(
|
|||||||
`${critPrefix}${winner} played the market perfectly. ${loser} belongs on WallStreetBets.`,
|
`${critPrefix}${winner} played the market perfectly. ${loser} belongs on WallStreetBets.`,
|
||||||
`${critPrefix}${loser}'s token strategy was worse than buying NFTs in 2022. ${winner} PROFITS!`,
|
`${critPrefix}${loser}'s token strategy was worse than buying NFTs in 2022. ${winner} PROFITS!`,
|
||||||
],
|
],
|
||||||
|
retro_mode: [
|
||||||
|
`${critPrefix}${winner}'s combo game is UNREAL! ${loser} should stick to button mashing!`,
|
||||||
|
`${critPrefix}PERFECT INPUT from ${winner}! ${loser}'s controller might be broken!`,
|
||||||
|
`${critPrefix}${winner} reads the frame data perfectly! ${loser} gets downloaded and DESTROYED!`,
|
||||||
|
`${critPrefix}QUARTER CIRCLE FORWARD INTO PAIN! ${winner}'s arcade skills are LEGENDARY! ${loser} needs more quarters!`,
|
||||||
|
`${critPrefix}${winner} plays like they wrote the strategy guide! ${loser} plays like they're using a dance pad!`,
|
||||||
|
`${critPrefix}INSERT COIN TO CONTINUE? ${loser} is OUT of quarters! ${winner} DOMINATES the cabinet!`,
|
||||||
|
`${critPrefix}${winner} chains combos like a speedrunner! ${loser} can't even find the start button!`,
|
||||||
|
],
|
||||||
food_fight: [
|
food_fight: [
|
||||||
`${critPrefix}${winner} serves up a five-star beating! ${loser} got ROASTED and TOASTED!`,
|
`${critPrefix}${winner} serves up a five-star beating! ${loser} got ROASTED and TOASTED!`,
|
||||||
`${critPrefix}${loser} just got served. Literally. ${winner} is the head chef of PAIN!`,
|
`${critPrefix}${loser} just got served. Literally. ${winner} is the head chef of PAIN!`,
|
||||||
@@ -387,6 +403,119 @@ function generateNarration(
|
|||||||
return options[Math.floor(Math.random() * options.length)]
|
return options[Math.floor(Math.random() * options.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function scoreRetroRound(
|
||||||
|
challenge: Challenge,
|
||||||
|
botA: { id: string; name: string },
|
||||||
|
botB: { id: string; name: string },
|
||||||
|
responseA: BotResponse,
|
||||||
|
responseB: BotResponse,
|
||||||
|
arenaModifier: string | null,
|
||||||
|
comboA: number,
|
||||||
|
comboB: number,
|
||||||
|
): RoundResult {
|
||||||
|
// Handle timeouts
|
||||||
|
if (responseA.timedOut && responseB.timedOut) {
|
||||||
|
return {
|
||||||
|
botAScore: 0, botBScore: 0, botADamage: 0, botBDamage: 0, winnerId: null,
|
||||||
|
narration: 'Both bots mash buttons frantically but cannot even find the start button! DOUBLE TIMEOUT!',
|
||||||
|
isCritical: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responseA.timedOut || responseA.error) {
|
||||||
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
|
||||||
|
return {
|
||||||
|
botAScore: 0, botBScore: 10, botADamage: 0, botBDamage: dmg, winnerId: botB.id,
|
||||||
|
narration: `${botA.name}'s controller disconnected! ${botB.name} lands free hits!`,
|
||||||
|
isCritical: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responseB.timedOut || responseB.error) {
|
||||||
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
|
||||||
|
return {
|
||||||
|
botAScore: 10, botBScore: 0, botADamage: dmg, botBDamage: 0, winnerId: botA.id,
|
||||||
|
narration: `${botB.name}'s controller disconnected! ${botA.name} lands free hits!`,
|
||||||
|
isCritical: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownInputs = challenge.answers || []
|
||||||
|
const resultA = scoreRetroResponse(responseA.answer, knownInputs)
|
||||||
|
const resultB = scoreRetroResponse(responseB.answer, knownInputs)
|
||||||
|
|
||||||
|
// Speed bonus: up to 20% more for faster responses
|
||||||
|
let scoreA = resultA.score
|
||||||
|
let scoreB = resultB.score
|
||||||
|
const maxTime = challenge.timeout_ms
|
||||||
|
if (scoreA > 0) scoreA *= 1 + Math.max(0, (maxTime - responseA.timeMs) / maxTime) * 0.2
|
||||||
|
if (scoreB > 0) scoreB *= 1 + Math.max(0, (maxTime - responseB.timeMs) / maxTime) * 0.2
|
||||||
|
|
||||||
|
const margin = Math.abs(scoreA - scoreB)
|
||||||
|
const winnerId = scoreA > scoreB ? botA.id : scoreB > scoreA ? botB.id : null
|
||||||
|
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
|
||||||
|
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
|
||||||
|
const isCritical = margin > 4
|
||||||
|
|
||||||
|
let winnerDamage = challenge.baseDamage + margin * 2
|
||||||
|
if (isCritical) winnerDamage *= 1.5
|
||||||
|
const winnerCombo = winnerId === botA.id ? comboA : comboB
|
||||||
|
winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo)
|
||||||
|
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
|
||||||
|
|
||||||
|
const winnerResult = winnerId === botA.id ? resultA : resultB
|
||||||
|
const loserResult = winnerId === botA.id ? resultB : resultA
|
||||||
|
|
||||||
|
const moveSummary = (r: RetroScoreResult) =>
|
||||||
|
r.moves.map(m => m.name ? (m.discovered ? '★' + m.name + '★' : m.name) : 'WHIFF').join(', ')
|
||||||
|
|
||||||
|
let narration: string
|
||||||
|
if (!winnerId) {
|
||||||
|
const draws = [
|
||||||
|
'MIRROR MATCH! Both bots combo for equal damage! Frame-perfect tie!',
|
||||||
|
'EQUAL POWER! The arcade cabinet shakes from perfectly matched inputs!',
|
||||||
|
`DOUBLE K.O.! ${botA.name} and ${botB.name} hit identical damage totals! Insert another quarter!`,
|
||||||
|
]
|
||||||
|
narration = draws[Math.floor(Math.random() * draws.length)]
|
||||||
|
} else {
|
||||||
|
const hasDiscovery = winnerResult.moves.some(m => m.discovered)
|
||||||
|
const discoveryNames = winnerResult.moves.filter(m => m.discovered).map(m => m.name)
|
||||||
|
const loserWhiffs = loserResult.moves.filter(m => !m.name).length
|
||||||
|
const narrations: string[] = []
|
||||||
|
|
||||||
|
if (hasDiscovery) {
|
||||||
|
narrations.push(
|
||||||
|
`SECRET COMBO UNLOCKED! ${winnerName} discovers ${discoveryNames.join(' + ')}! ${loserName} never saw it coming!`,
|
||||||
|
`HIDDEN MOVE FOUND! ${winnerName} unleashes ${discoveryNames.join(' + ')} for MASSIVE damage!`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (loserWhiffs >= 2) {
|
||||||
|
narrations.push(
|
||||||
|
`${loserName} mashes random buttons and WHIFFS ${loserWhiffs} times! ${winnerName} capitalizes with [${moveSummary(winnerResult)}]!`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
narrations.push(
|
||||||
|
`${winnerName} executes [${moveSummary(winnerResult)}] for ${winnerResult.totalDamage} total damage! ${loserName} can't keep up!`,
|
||||||
|
`COMBO BREAKER! ${winnerName}'s inputs are FLAWLESS! ${loserName} gets bodied!`,
|
||||||
|
`${winnerName} reads the frame data perfectly! ${loserName} gets downloaded and DESTROYED!`,
|
||||||
|
`PERFECT INPUT! ${winnerName} chains ${winnerResult.moves.filter(m => m.name).length} moves! ${loserName}'s controller might be broken!`,
|
||||||
|
`${winnerName} plays like they have the strategy guide! ${loserName} plays like they're using a steering wheel!`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const prefix = isCritical ? 'CRITICAL COMBO! ' : ''
|
||||||
|
narration = prefix + narrations[Math.floor(Math.random() * narrations.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
botAScore: Math.round(scoreA * 10) / 10,
|
||||||
|
botBScore: Math.round(scoreB * 10) / 10,
|
||||||
|
botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
||||||
|
botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
||||||
|
winnerId,
|
||||||
|
narration,
|
||||||
|
isCritical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Elo calculation
|
// Elo calculation
|
||||||
export function calculateElo(
|
export function calculateElo(
|
||||||
winnerElo: number,
|
winnerElo: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user