This commit is contained in:
Dorian
2026-03-12 16:35:59 +00:00
parent 45216e5dfc
commit bc4a52bc12
18 changed files with 1754 additions and 85 deletions
+3 -1
View File
@@ -397,7 +397,9 @@ async function _doReplay() {
if (scene) {
await scene.playEntrance()
// Safety: ensure fighters are visible after entrance (prevents invisible characters if entrance times out)
scene._resetPositions()
// Re-check: scene could be destroyed during async playEntrance()
scene?._resetPositions()
if (!scene) return
await sleep(300)
}
+1 -1
View File
@@ -30,7 +30,7 @@ const slides = [
},
{
title: 'CLASSIC BOTS',
subtitle: 'Practice against legendary AI fighters.',
subtitle: 'Train against legendary AI fighters.',
description: 'Sharpen your skills against classic bots with unique personalities. From the chaotic Lobster Lord to the stoic Zen Master, each has a different fighting style.',
color: 'purple',
fighters: [
+2 -2
View File
@@ -293,7 +293,7 @@ export function useNostr() {
}
}
async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<BotData> {
async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<{ bot: BotData; secret: string; mode: string }> {
if (!pubkey.value) throw new Error('Not logged in')
const res = await authFetch('/api/auth/register', {
@@ -341,7 +341,7 @@ export function useNostr() {
// Non-critical: existing JWT still works, just missing botId
}
return bot.value
return { bot: bot.value, secret: data.secret, mode: data.mode || 'webhook' }
}
async function updateCustomization(customization: BotCustomization): Promise<void> {
+2 -2
View File
@@ -575,7 +575,7 @@ const tierClass = (t: number) => `tier-${t}`
</button>
</div>
<!-- Practice -->
<!-- Training -->
<button
class="w-full mt-2 py-2.5 bg-white/3 border border-white/10 text-text-muted
font-display font-bold text-xs tracking-widest
@@ -586,7 +586,7 @@ const tierClass = (t: number) => `tier-${t}`
@click="practice"
>
<span v-if="isJoiningPractice" class="w-3.5 h-3.5 border-2 border-white/20 border-t-white/50 rounded-full animate-spin" />
{{ isJoiningPractice ? 'STARTING...' : 'PRACTICE' }}
{{ isJoiningPractice ? 'STARTING...' : 'TRAINING' }}
</button>
<p v-if="fightError" class="font-mono text-[10px] text-neon-pink mt-2">{{ fightError }}</p>
+10 -3
View File
@@ -87,6 +87,7 @@ function spawnLiveReactionEmoji(key: string) {
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
let _pageDestroyed = false
let _initializingLiveScene = false
function sleep(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
@@ -118,8 +119,11 @@ const tierClass = (t: number) => `tier-${t}`
// --- Live scene management ---
async function initLiveScene() {
if (_initializingLiveScene || _pageDestroyed) return
_initializingLiveScene = true
const data = liveFightData.value
if (!data?.botA || !data?.botB || !liveCanvas.value) return
if (!data?.botA || !data?.botB || !liveCanvas.value) { _initializingLiveScene = false; return }
if (liveScene) { liveScene.destroy(); liveScene = null }
@@ -147,6 +151,8 @@ async function initLiveScene() {
} catch (err) {
console.error('[FightPage] createFightScene failed:', err)
liveScene = null
} finally {
_initializingLiveScene = false
}
liveSceneReady.value = true
@@ -167,7 +173,8 @@ async function initLiveScene() {
console.warn('[FightPage] entrance failed:', err)
}
// Safety: ensure fighters are visible after entrance (prevents invisible characters on mobile)
liveScene._resetPositions()
// Re-check: scene could be destroyed during async playEntrance()
liveScene?._resetPositions()
setEntrancePlaying(false)
}
@@ -311,7 +318,7 @@ async function handleRoundEnd(data: any) {
console.warn('[FightPage] playRound animation failed:', err)
}
if (aWon || bWon) {
if (liveScene && (aWon || bWon)) {
await liveScene.playTaunt(aWon ? 'a' : 'b')
}
}
+214 -67
View File
@@ -18,7 +18,7 @@ const nsecInput = ref('')
const rememberKey = ref(false)
const { isWalletConnected, payEntryFee, paymentStatus } = useWallet()
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'choose-connection' | 'add-webhook' |
// 'pick-human-avatar' | 'name-human' | 'human-guide' | 'ready'
const step = ref<string>('login')
const isHumanMode = ref(false)
@@ -30,6 +30,12 @@ let rateLimitTimer: ReturnType<typeof setInterval> | null = null
const isJoining = ref(false)
const isJoiningRanked = ref(false)
const isJoiningPractice = ref(false)
// Bot connection mode
const connectionMode = ref<'webhook' | 'polling'>('webhook')
const botSecret = ref('')
const botId = ref('')
const setupGuideCopied = ref(false)
const showSatsWarning = ref(false)
const queueCount = ref(0)
const rankedQueueCount = ref(0)
@@ -496,13 +502,38 @@ async function confirmWebhook() {
error.value = ''
try {
await registerBot(botName.value.trim(), url, selectedArchetype.value)
const result = await registerBot(botName.value.trim(), url, selectedArchetype.value)
botId.value = result.bot.id
botSecret.value = result.secret
step.value = 'ready'
} catch (e) {
handleError(e, 'Registration failed.')
}
}
async function confirmPolling() {
error.value = ''
try {
const result = await registerBot(botName.value.trim(), '', selectedArchetype.value)
botId.value = result.bot.id
botSecret.value = result.secret
step.value = 'ready'
} catch (e) {
handleError(e, 'Registration failed.')
}
}
function downloadSetupGuide() {
window.open('/docs/BOTFIGHTS.md', '_blank')
}
function copyFullPrompt() {
const text = `Read BOTFIGHTS.md and follow the setup instructions. Here are my credentials:\nBOT_ID=${botId.value}\nBOT_SECRET=${botSecret.value}`
navigator.clipboard.writeText(text)
setupGuideCopied.value = true
setTimeout(() => { setupGuideCopied.value = false }, 2000)
}
async function fight() {
if (!bot.value || isJoining.value) return
isJoining.value = true
@@ -880,14 +911,14 @@ function handleSignOut() {
</div>
</template>
<!-- STEP: BOT SETUP how it works + code + safety -->
<!-- STEP: BOT SETUP easy setup messaging -->
<template v-else-if="step === 'bot-setup'">
<div class="text-center mb-5">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
HOW IT WORKS
EASY SETUP
</h2>
<p class="font-mono text-text-muted text-xs">
Your bot is a tiny server that answers fight challenges.
Your AI sets everything up. You just tell it to.
</p>
</div>
@@ -896,22 +927,22 @@ function handleSignOut() {
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-cyan text-sm mt-0.5">1</span>
<div>
<p class="font-mono text-xs text-text-primary">We POST a challenge to your server</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">JSON with the question, type, and opponent info</p>
<p class="font-mono text-xs text-text-primary">Pick your connection mode (next step)</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Webhook (fastest) or polling (easiest, no public URL)</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-cyan text-sm mt-0.5">2</span>
<div>
<p class="font-mono text-xs text-text-primary">Your bot responds with an answer</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">JSON with your answer and optional trash talk</p>
<p class="font-mono text-xs text-text-primary">Download BOTFIGHTS.md into your workspace</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">One file your AI reads it and builds the bot</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-cyan text-sm mt-0.5">3</span>
<div>
<p class="font-mono text-xs text-text-primary">Best answer wins the round</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">5-10 rounds per fight. Speed matters when tied.</p>
<p class="font-mono text-xs text-text-primary">Copy the prompt we give you and paste it to your AI</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Includes your credentials. AI handles everything. Done.</p>
</div>
</div>
</div>
@@ -919,67 +950,28 @@ function handleSignOut() {
<!-- Safety callout -->
<div class="p-3 border border-neon-cyan/20 bg-neon-cyan/5 mb-5">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-cyan mb-1.5">
YOUR SERVER IS SAFE
SAFE & SIMPLE
</p>
<ul class="font-mono text-[10px] text-text-muted space-y-1 leading-relaxed">
<li>We only send <span class="text-text-secondary">POST</span> requests with fight questions</li>
<li>We never read from your server only send challenges</li>
<li>Your <span class="text-text-secondary">API keys stay on your machine</span> we never see them</li>
<li>We only send <span class="text-text-secondary">fight questions</span> nothing else</li>
<li>Private IPs and internal URLs are <span class="text-text-secondary">blocked</span></li>
<li>Payloads are small JSON (<span class="text-text-secondary">&lt;2KB</span>), responses capped at <span class="text-text-secondary">10KB</span></li>
<li>5 second timeout we give up fast</li>
<li>All communication is <span class="text-text-secondary">one-way</span>: we ask, you answer</li>
<li>Small JSON payloads (<span class="text-text-secondary">&lt;2KB</span>), responses capped at <span class="text-text-secondary">10KB</span></li>
<li>Give the setup guide to your AI and it builds the bot for you</li>
</ul>
</div>
<!-- Starter code toggle -->
<button
class="w-full py-2 mb-3 border border-border text-text-secondary font-display font-bold text-[10px]
tracking-wider hover:border-neon-purple/40 hover:text-neon-purple transition-all text-center"
@click="showCode = !showCode"
>
{{ showCode ? 'HIDE' : 'SHOW' }} STARTER BOT CODE (NODE.JS)
</button>
<div v-if="showCode" class="mb-4">
<div class="relative">
<pre class="bg-bg border border-border p-3 text-[10px] font-mono text-text-muted overflow-x-auto max-h-[35vh] leading-relaxed"><code>{{ BOT_CODE }}</code></pre>
<button
class="absolute top-2 right-2 px-2 py-1 text-[9px] font-display font-bold tracking-wider
border border-border hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
:class="codeCopied ? 'text-neon-cyan border-neon-cyan/40' : 'text-text-muted'"
@click="copyCode"
>
{{ codeCopied ? 'COPIED' : 'COPY' }}
</button>
</div>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
Save as <span class="text-text-secondary">bot.js</span>, run <span class="text-text-secondary">node bot.js</span>, expose with <span class="text-text-secondary">ngrok http 3000</span>
</p>
</div>
<!-- Example payload -->
<!-- Tech details toggle for curious users -->
<details class="mb-5 group">
<summary class="font-display font-bold text-[10px] tracking-wider text-text-secondary cursor-pointer
hover:text-neon-purple transition-colors select-none">
EXAMPLE CHALLENGE PAYLOAD
WHAT'S ACTUALLY HAPPENING?
</summary>
<pre class="mt-2 bg-bg border border-border p-3 text-[10px] font-mono text-text-muted overflow-x-auto leading-relaxed"><code>{
"fight_id": "f_abc123",
"round": 1,
"type": "speed_blitz",
"challenge": "What is the capital of France?",
"constraints": {
"timeout_ms": 8000,
"max_tokens": 500
},
"opponent": {
"name": "skull_crusher",
"wins": 12,
"losses": 3
}
}</code></pre>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
Your response: <span class="text-text-secondary">{"answer": "Paris", "trash_talk": "Too easy."}</span>
<p class="mt-2 font-mono text-[10px] text-text-muted leading-relaxed">
Your AI runs a small server that receives fight challenges from BOTFIGHTS.
When a challenge comes in, it uses Claude to figure out the answer and fires it back.
5-10 rounds per fight, scored on correctness and speed.
You don't need to understand any of this just tell your AI to set it up.
</p>
</details>
@@ -995,13 +987,118 @@ function handleSignOut() {
class="flex-1 py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-bold text-sm tracking-wider
hover:bg-neon-cyan/20 transition-all"
@click="step = 'add-webhook'"
@click="step = 'choose-connection'"
>
GOT IT, NEXT
</button>
</div>
</template>
<!-- STEP: CHOOSE CONNECTION MODE -->
<template v-else-if="step === 'choose-connection'">
<div class="text-center mb-5">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
CONNECTION MODE
</h2>
<p class="font-mono text-text-muted text-xs">
How should we reach <span class="text-neon-cyan">{{ botName }}</span>?
</p>
</div>
<div class="space-y-3 mb-5">
<!-- Webhook option -->
<button
class="w-full p-4 border-2 text-left transition-all"
:class="connectionMode === 'webhook'
? 'border-neon-cyan/60 bg-neon-cyan/10'
: 'border-border bg-surface hover:border-neon-cyan/30'"
@click="connectionMode = 'webhook'"
>
<div class="flex items-center gap-3 mb-1.5">
<span class="font-display font-black text-sm"
:class="connectionMode === 'webhook' ? 'text-neon-cyan' : 'text-text-secondary'">
WEBHOOK
</span>
<span class="font-mono text-[9px] px-1.5 py-0.5 border border-neon-cyan/30 text-neon-cyan">RECOMMENDED</span>
</div>
<p class="font-mono text-[10px] text-text-muted leading-relaxed">
We POST challenges to your server. Fastest response times.
Requires a public URL (ngrok, cloudflared, VPS, etc.)
</p>
</button>
<!-- Polling option -->
<button
class="w-full p-4 border-2 text-left transition-all"
:class="connectionMode === 'polling'
? 'border-neon-purple/60 bg-neon-purple/10'
: 'border-border bg-surface hover:border-neon-purple/30'"
@click="connectionMode = 'polling'"
>
<div class="flex items-center gap-3 mb-1.5">
<span class="font-display font-black text-sm"
:class="connectionMode === 'polling' ? 'text-neon-purple' : 'text-text-secondary'">
POLLING
</span>
<span class="font-mono text-[9px] px-1.5 py-0.5 border border-border text-text-muted">EASIEST</span>
</div>
<p class="font-mono text-[10px] text-text-muted leading-relaxed">
Your bot polls us for challenges. No public URL needed.
Runs from any machine just keep the script running.
</p>
</button>
</div>
<!-- Safety callout -->
<div class="p-3 border border-neon-cyan/20 bg-neon-cyan/5 mb-5">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-cyan mb-1.5">
SAFE & SIMPLE
</p>
<ul class="font-mono text-[10px] text-text-muted space-y-1 leading-relaxed">
<li v-if="connectionMode === 'webhook'">
We only send <span class="text-text-secondary">POST</span> requests with fight questions nothing else
</li>
<li v-if="connectionMode === 'polling'">
<span class="text-text-secondary">No incoming connections</span> your bot only makes outbound requests
</li>
<li>Private IPs and internal URLs are <span class="text-text-secondary">blocked</span></li>
<li>Payloads are small JSON (<span class="text-text-secondary">&lt;2KB</span>)</li>
<li>Your <span class="text-text-secondary">API keys stay on your machine</span> we never see them</li>
<li>
<span class="text-text-secondary">Give the setup guide to your AI</span> and it builds the bot for you
</li>
</ul>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'bot-setup'"
>
BACK
</button>
<button
v-if="connectionMode === 'webhook'"
class="flex-1 py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-bold text-sm tracking-wider
hover:bg-neon-cyan/20 transition-all"
@click="step = 'add-webhook'"
>
SET UP WEBHOOK
</button>
<button
v-else
class="flex-1 py-3 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-bold text-sm tracking-wider
hover:bg-neon-purple/20 transition-all"
@click="confirmPolling"
>
CREATE BOT
</button>
</div>
</template>
<!-- STEP: ADD WEBHOOK -->
<template v-else-if="step === 'add-webhook'">
<div class="text-center mb-5">
@@ -1042,7 +1139,7 @@ function handleSignOut() {
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'bot-setup'"
@click="step = 'choose-connection'"
>
BACK
</button>
@@ -1229,6 +1326,56 @@ function handleSignOut() {
</p>
</div>
<!-- Bot credentials (shown once after registration) -->
<div v-if="botSecret" class="mb-4 p-3 border border-neon-cyan/20 bg-neon-cyan/5">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-cyan mb-2">
2 STEPS THEN YOU'RE FIGHTING
</p>
<!-- Step 1: Download -->
<div class="flex items-start gap-2.5 mb-3">
<span class="font-display font-black text-neon-cyan text-xs mt-0.5 shrink-0">1</span>
<div class="flex-1">
<p class="font-mono text-[10px] text-text-primary mb-1.5">Download BOTFIGHTS.md into your workspace</p>
<button
class="w-full py-1.5 border border-border text-[9px] font-display font-bold tracking-wider
text-text-muted hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
@click="downloadSetupGuide"
>
DOWNLOAD BOTFIGHTS.md
</button>
</div>
</div>
<!-- Step 2: Tell your AI -->
<div class="flex items-start gap-2.5 mb-3">
<span class="font-display font-black text-neon-cyan text-xs mt-0.5 shrink-0">2</span>
<div class="flex-1">
<p class="font-mono text-[10px] text-text-primary mb-1.5">Tell your AI this:</p>
<div class="bg-bg border border-border p-2.5 font-mono text-[10px] text-text-muted leading-relaxed select-all">
<p class="text-text-primary">Read BOTFIGHTS.md and follow the setup instructions. Here are my credentials:</p>
<div class="mt-1">
<div>BOT_ID=<span class="text-neon-cyan">{{ botId }}</span></div>
<div>BOT_SECRET=<span class="text-neon-cyan">{{ botSecret }}</span></div>
</div>
</div>
<button
class="w-full mt-1.5 py-1.5 border border-border text-[9px] font-display font-bold tracking-wider
hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
:class="setupGuideCopied ? 'text-neon-cyan border-neon-cyan/40' : 'text-text-muted'"
@click="copyFullPrompt"
>
{{ setupGuideCopied ? 'COPIED' : 'COPY FULL PROMPT' }}
</button>
</div>
</div>
<p class="font-mono text-[9px] text-text-muted/60 leading-relaxed">
Your AI reads the guide, creates the bot, and starts it for you.
Save these credentials — the secret won't be shown again.
</p>
</div>
<div class="mb-4 text-center">
<p class="font-mono text-xs">
<span class="text-neon-purple font-bold text-lg">{{ queueCount }}</span>
@@ -1285,7 +1432,7 @@ function handleSignOut() {
<!-- Wallet connect (shown if no wallet) -->
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
<!-- Practice fight against bland classic bots, free -->
<!-- Training fight against bland classic bots, free -->
<div class="pt-2 border-t border-border/30">
<button
class="w-full py-3 bg-white/3 border border-white/10 text-text-muted
@@ -1297,10 +1444,10 @@ function handleSignOut() {
@click="practice"
>
<span v-if="isJoiningPractice" class="w-4 h-4 border-2 border-white/20 border-t-white/50 rounded-full animate-spin" />
{{ isJoiningPractice ? 'STARTING...' : 'PRACTICE' }}
{{ isJoiningPractice ? 'STARTING...' : 'TRAINING' }}
</button>
<p class="font-mono text-[10px] text-text-muted/50 text-center mt-1">
Free sparring against practice bots no ELO impact
Free sparring against training bots no ELO impact
</p>
</div>
+3 -3
View File
@@ -64,7 +64,7 @@ async function startPractice() {
<template>
<div class="max-w-lg mx-auto px-4 py-8 space-y-6">
<h1 class="font-display font-black text-2xl text-neon-cyan tracking-wider">PRACTICE</h1>
<h1 class="font-display font-black text-2xl text-neon-cyan tracking-wider">TRAINING</h1>
<p class="font-mono text-sm text-text-muted">
Fight against training bots. No sats at stake.
</p>
@@ -100,7 +100,7 @@ async function startPractice() {
:disabled="isStarting"
@click="startPractice"
>
{{ isStarting ? 'STARTING...' : 'START PRACTICE' }}
{{ isStarting ? 'STARTING...' : 'START TRAINING' }}
</button>
<div v-if="error" class="p-3 border-2 border-ko/30 bg-ko/5 text-center rounded">
@@ -108,7 +108,7 @@ async function startPractice() {
</div>
<p class="font-mono text-[10px] text-text-muted text-center">
Practice fights use dampened Elo (minimal rating impact)
Training fights use dampened Elo (minimal rating impact)
</p>
</div>
</template>
+6 -2
View File
@@ -82,10 +82,14 @@ const routes = [
component: () => import('./pages/TournamentPage.vue'),
},
{
path: '/practice',
name: 'practice',
path: '/training',
name: 'training',
component: () => import('./pages/PracticePage.vue'),
},
{
path: '/practice',
redirect: '/training',
},
{
path: '/admin',
name: 'admin',