feat: practice mode against mock bots

Tier-selectable practice page at /practice. Fights against classic
bots with dampened Elo. Routes to human fight page or arena based
on bot type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 21:07:09 +00:00
co-authored by Claude Opus 4.6
parent acbf863e4d
commit cb31fdbbae
2 changed files with 119 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
const router = useRouter()
const { bot: myBot, isLoggedIn } = useNostr()
const selectedTier = ref(0)
const isStarting = ref(false)
const error = ref('')
const tiers = [
{ value: 0, label: 'ROOKIE', desc: 'Easy challenges, forgiving scoring' },
{ value: 1, label: 'BRONZE', desc: 'Moderate difficulty' },
{ value: 2, label: 'SILVER', desc: 'Harder prompts, faster pace' },
{ value: 3, label: 'GOLD', desc: 'Tough challenges, strict scoring' },
{ value: 4, label: 'DIAMOND', desc: 'Expert difficulty' },
{ value: 5, label: 'LEGEND', desc: 'Maximum difficulty' },
]
const tierColor = computed(() => {
const colors = ['text-text-muted', 'text-amber-600', 'text-gray-300', 'text-neon-gold', 'text-neon-cyan', 'text-neon-pink']
return colors[selectedTier.value] || 'text-text-muted'
})
onMounted(() => {
if (!isLoggedIn.value || !myBot.value) {
router.push('/register')
}
})
async function startPractice() {
if (!myBot.value || isStarting.value) return
isStarting.value = true
error.value = ''
try {
const res = await fetch(`/api/fights/practice/${myBot.value.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const data = await res.json()
error.value = data.error || 'Failed to start practice fight'
return
}
const data = await res.json()
// Navigate to human fight page for the practice fight
if (myBot.value.isHuman) {
router.push(`/play/${data.fightId}`)
} else {
router.push(`/arena/${data.fightId}`)
}
} catch {
error.value = 'Network error'
} finally {
isStarting.value = false
}
}
</script>
<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>
<p class="font-mono text-sm text-text-muted">
Fight against training bots. No sats at stake.
</p>
<!-- Tier selection -->
<div class="space-y-2">
<h2 class="font-display font-bold text-sm text-text-secondary tracking-wider">DIFFICULTY</h2>
<div class="grid grid-cols-2 gap-2">
<button
v-for="tier in tiers"
:key="tier.value"
class="p-3 border-2 rounded text-left transition-all"
:class="selectedTier === tier.value
? 'border-neon-cyan bg-neon-cyan/5'
: 'border-border hover:border-border/80'"
@click="selectedTier = tier.value"
>
<span class="font-display font-bold text-sm tracking-wider" :class="tierColor">
T{{ tier.value }}
</span>
<span class="font-display font-bold text-xs text-text-primary ml-2">{{ tier.label }}</span>
<p class="font-mono text-[10px] text-text-muted mt-0.5">{{ tier.desc }}</p>
</button>
</div>
</div>
<!-- Start button -->
<button
class="w-full min-h-[48px] py-4 bg-neon-green/10 border-2 border-neon-green text-neon-green
font-display font-black text-lg tracking-[0.15em] rounded
hover:bg-neon-green/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isStarting"
@click="startPractice"
>
{{ isStarting ? 'STARTING...' : 'START PRACTICE' }}
</button>
<div v-if="error" class="p-3 border-2 border-ko/30 bg-ko/5 text-center rounded">
<p class="font-mono text-xs text-ko">{{ error }}</p>
</div>
<p class="font-mono text-[10px] text-text-muted text-center">
Practice fights use dampened Elo (minimal rating impact)
</p>
</div>
</template>