+
+
+
+
+
★ MAIN EVENT
+
VIEWING
+
+
+
+
{{ mainEvent.botA.name }}
+
+
+
VS
+
+
+
{{ mainEvent.botB.name }}
+
+
+
+
+
+
+
+
+
+ {{ undercardFights.length > 0 ? 'UNDERCARD' : 'NO OTHER BOUTS' }}
+
+
+
-
-
+
+
-
-
+
{{ fight.botA?.name || '???' }}
@@ -196,43 +353,48 @@ onMounted(async () => {
:seed="fight.botA.avatarSeed || fight.botA.name"
:archetype="fight.botA.archetype || 'standard'"
:tier="fight.botA.tier"
- :size="28"
+ :size="22"
class="shrink-0"
/>
- VS
-
+
VS
+
-
{{ fight.botB?.name || '???' }}
-
R{{ fight.totalRounds }}
-
LIVE
+
+ PRAC
+ R{{ fight.totalRounds }}
+ LIVE
+
+
+
+
+
+
+
+ ENTER THE RING
-
-
-
- ENTER THE RING
-
-
@@ -250,17 +412,149 @@ onMounted(async () => {
diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue
index ef4fdc0..f718f5d 100644
--- a/frontend/src/pages/FightPage.vue
+++ b/frontend/src/pages/FightPage.vue
@@ -153,7 +153,15 @@ const myBotId = computed(() => {
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
-function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
+let _pageDestroyed = false
+function sleep(ms: number): Promise
{
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ if (_pageDestroyed) reject(new Error('unmounted'))
+ else resolve()
+ }, ms)
+ })
+}
function scrollLiveLog() { nextTick(() => { liveLogEl.value?.scrollTo({ top: liveLogEl.value.scrollHeight, behavior: 'smooth' }) }) }
const challengeLabel = (type: string) => {
@@ -401,7 +409,7 @@ function connectSSE() {
eventSource.addEventListener('round_end', (e) => {
try {
- handleRoundEnd(JSON.parse(e.data))
+ handleRoundEnd(JSON.parse(e.data)).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE round_end failed:', err)
}
@@ -409,7 +417,7 @@ function connectSSE() {
eventSource.addEventListener('fight_end', (e) => {
try {
- handleFightEnd(JSON.parse(e.data))
+ handleFightEnd(JSON.parse(e.data)).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE fight_end failed:', err)
}
@@ -660,6 +668,7 @@ onMounted(async () => {
})
onUnmounted(() => {
+ _pageDestroyed = true
if (pollHandle) clearInterval(pollHandle)
stopHumanPolling()
disconnectSSE()
diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue
index 237fc46..e04ac93 100644
--- a/frontend/src/pages/JoinBoutPage.vue
+++ b/frontend/src/pages/JoinBoutPage.vue
@@ -8,7 +8,10 @@ import HumanPreview from '../components/HumanPreview.vue'
import WalletConnect from '../components/WalletConnect.vue'
const router = useRouter()
-const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, registerHuman, logout } = useNostr()
+const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr()
+const showNsecBackup = ref(false)
+const generatedNsec = ref('')
+const nsecInput = ref('')
const { isWalletConnected, payEntryFee, paymentStatus } = useWallet()
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
@@ -19,6 +22,7 @@ const selectedHumanSeed = ref('baby_fighter_1')
const error = ref('')
const isJoining = ref(false)
const isJoiningRanked = ref(false)
+const isJoiningPractice = ref(false)
const queueCount = ref(0)
let pollHandle: ReturnType | null = null
@@ -29,6 +33,7 @@ const humanName = ref('')
const webhookUrl = ref('')
const showCode = ref(false)
const codeCopied = ref(false)
+const nsecCopied = ref(false)
const BOT_CODE = `const http = require('http')
@@ -57,6 +62,12 @@ function copyCode() {
setTimeout(() => { codeCopied.value = false }, 2000)
}
+function copyNsec() {
+ navigator.clipboard.writeText(generatedNsec.value)
+ nsecCopied.value = true
+ setTimeout(() => { nsecCopied.value = false }, 2000)
+}
+
const archetypeList = [
{ id: 'standard', label: 'FIGHTER', desc: 'Classic brawler' },
{ id: 'lobster', label: 'LOBSTER', desc: 'Pinchy menace' },
@@ -125,6 +136,71 @@ async function handleLogin() {
}
}
+function handleGenerateLogin() {
+ error.value = ''
+ const { nsec } = generateLogin()
+ generatedNsec.value = nsec
+ showNsecBackup.value = true
+}
+
+async function handleNsecBackupDone() {
+ showNsecBackup.value = false
+ try {
+ const result = await login()
+ if (result.bot) {
+ isHumanMode.value = !!result.bot.isHuman
+ step.value = 'ready'
+ } else {
+ step.value = 'choose-mode'
+ }
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : 'Login failed.'
+ }
+}
+
+async function handleNsecLogin() {
+ error.value = ''
+ const input = nsecInput.value.trim()
+ if (!input) {
+ error.value = 'Enter your nsec1... or hex key.'
+ return
+ }
+
+ let hexKey: string
+ if (input.startsWith('nsec1')) {
+ try {
+ const { decode } = await import('nostr-tools/nip19')
+ const decoded = decode(input)
+ if (decoded.type !== 'nsec') {
+ error.value = 'Invalid nsec key.'
+ return
+ }
+ const { bytesToHex } = await import('nostr-tools/utils')
+ hexKey = bytesToHex(decoded.data)
+ } catch {
+ error.value = 'Invalid nsec key.'
+ return
+ }
+ } else if (/^[0-9a-f]{64}$/.test(input)) {
+ hexKey = input
+ } else {
+ error.value = 'Enter a valid nsec1... or 64-char hex key.'
+ return
+ }
+
+ try {
+ const result = await loginWithNsec(hexKey)
+ if (result.bot) {
+ isHumanMode.value = !!result.bot.isHuman
+ step.value = 'ready'
+ } else {
+ step.value = 'choose-mode'
+ }
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : 'Login failed.'
+ }
+}
+
function pickCharacter(id: string) {
selectedArchetype.value = id
step.value = 'name-bot'
@@ -271,6 +347,25 @@ async function fightRanked() {
isJoiningRanked.value = false
}
+async function practice() {
+ if (!bot.value || isJoiningPractice.value) return
+ isJoiningPractice.value = true
+ error.value = ''
+ try {
+ const res = await fetch(`/api/fights/practice/${bot.value.id}`, { method: 'POST' })
+ if (res.ok) {
+ const data = await res.json()
+ router.push(`/arena/${data.fightId}`)
+ } else {
+ const data = await res.json()
+ error.value = data.error || 'Failed to start practice fight.'
+ }
+ } catch {
+ error.value = 'Network error.'
+ }
+ isJoiningPractice.value = false
+}
+
function handleSignOut() {
logout()
step.value = 'login'
@@ -299,31 +394,98 @@ function handleSignOut() {
-
- NOSTR EXTENSION REQUIRED
+
+
+
+ SAVE YOUR SECRET KEY
- Install a NIP-07 browser extension like
- nos2x ,
- Alby , or
- Flamingo
- to sign in.
+ This is your login key. Save it somewhere safe. If you lose it, you lose your account.
+
+
+ {{ generatedNsec }}
+
+
+ {{ nsecCopied ? 'COPIED' : 'COPY' }}
+
+
+
+ I SAVED IT — CONTINUE
+
+
+
+
+
+
+
+ {{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
+
+
+
+
+
+ {{ isLoading ? 'CONNECTING...' : 'SIGN IN' }}
+
+
+
+
+ GENERATE NEW IDENTITY
+
+
+
+
OR IMPORT EXISTING KEY
+
+
+
+
+ LOGIN
+
+
@@ -413,15 +575,15 @@ function handleSignOut() {
v-model="botName"
type="text"
required
- maxlength="32"
- placeholder="skull_crusher_9000"
+ maxlength="12"
+ placeholder="skull_crush"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
@keyup.enter="confirmName"
/>
- Letters, numbers, hyphens, underscores. 2-32 chars.
+ Letters, numbers, hyphens, underscores. 2-12 chars.
@@ -667,15 +829,15 @@ function handleSignOut() {
v-model="humanName"
type="text"
required
- maxlength="32"
- placeholder="big_brain_gary"
+ maxlength="12"
+ placeholder="big_brain"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-pink/50 transition-colors"
@keyup.enter="confirmHumanName"
/>
- Letters, numbers, hyphens, underscores. 2-32 chars.
+ Letters, numbers, hyphens, underscores. 2-12 chars.