feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots
- Fight Card page: dramatic poster background with cross-hatch, spotlights, vignettes, corner brackets, scan lines; 3D VS orb with punch animation; selectable undercard with main event always pinned at top - PosterSprite: high-quality 480px poster frame with 6-pass renderer (aura, glow, bevel, specular, particles); PixelGlove component - 12-char bot name limit across all forms and server validation - Mock bots: all 100 now have diverse archetypes (25 types), 25% human fighters; seedMockBots updates existing bots on restart - Leaderboard: inline SpritePreview next to each bot name - Nostr auth: persistent login, nsec copy button - Wallet: NWC + Lightning Address, ranked fight flow - Server: payments, ranked queue, customization endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ccf4196647
commit
f6eb7d2845
@@ -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<typeof setInterval> | 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() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasExtension"
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
|
||||
</button>
|
||||
|
||||
<div v-else class="text-center p-6 border-2 border-border bg-surface">
|
||||
<p class="font-display font-bold text-sm text-text-secondary tracking-wider mb-3">
|
||||
NOSTR EXTENSION REQUIRED
|
||||
<!-- Nsec backup modal -->
|
||||
<div v-if="showNsecBackup" class="p-6 border-2 border-neon-yellow/50 bg-neon-yellow/5 space-y-4">
|
||||
<p class="font-display font-black text-sm tracking-wider text-neon-yellow">
|
||||
SAVE YOUR SECRET KEY
|
||||
</p>
|
||||
<p class="font-mono text-xs text-text-muted leading-relaxed">
|
||||
Install a NIP-07 browser extension like
|
||||
<span class="text-neon-cyan">nos2x</span>,
|
||||
<span class="text-neon-cyan">Alby</span>, or
|
||||
<span class="text-neon-cyan">Flamingo</span>
|
||||
to sign in.
|
||||
This is your login key. Save it somewhere safe. If you lose it, you lose your account.
|
||||
</p>
|
||||
<div class="relative">
|
||||
<div class="bg-black/50 p-3 pr-16 border border-border font-mono text-xs text-neon-cyan break-all select-all">
|
||||
{{ generatedNsec }}
|
||||
</div>
|
||||
<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="nsecCopied ? 'text-neon-cyan border-neon-cyan/40' : 'text-text-muted'"
|
||||
@click="copyNsec"
|
||||
>
|
||||
{{ nsecCopied ? 'COPIED' : 'COPY' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-3 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
|
||||
font-display font-black text-sm tracking-widest
|
||||
hover:bg-neon-green/20 hover:border-neon-green transition-all"
|
||||
@click="handleNsecBackupDone"
|
||||
>
|
||||
I SAVED IT — CONTINUE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!showNsecBackup" class="space-y-3">
|
||||
<!-- Sign in with extension -->
|
||||
<button
|
||||
v-if="hasExtension"
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
|
||||
</button>
|
||||
|
||||
<!-- Sign in with stored key -->
|
||||
<button
|
||||
v-if="!hasExtension && hasStoredKey"
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN' }}
|
||||
</button>
|
||||
|
||||
<!-- Always show Generate Login -->
|
||||
<button
|
||||
class="w-full py-4 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-green/20 hover:border-neon-green transition-all"
|
||||
@click="handleGenerateLogin"
|
||||
>
|
||||
GENERATE NEW IDENTITY
|
||||
</button>
|
||||
|
||||
<!-- Import existing key -->
|
||||
<div class="text-center font-mono text-[10px] text-text-muted">OR IMPORT EXISTING KEY</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="nsecInput"
|
||||
type="password"
|
||||
placeholder="Paste nsec1... or hex key"
|
||||
class="flex-1 px-3 py-2 bg-black/30 border border-border font-mono text-xs text-text-primary
|
||||
placeholder-text-muted/50 focus:outline-none focus:border-neon-purple/50"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 bg-neon-purple/10 border border-neon-purple/50 text-neon-purple
|
||||
font-display font-bold text-xs tracking-wider
|
||||
hover:bg-neon-purple/20 transition-all"
|
||||
@click="handleNsecLogin"
|
||||
>
|
||||
LOGIN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-1.5">
|
||||
Letters, numbers, hyphens, underscores. 2-32 chars.
|
||||
Letters, numbers, hyphens, underscores. 2-12 chars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-1.5">
|
||||
Letters, numbers, hyphens, underscores. 2-32 chars.
|
||||
Letters, numbers, hyphens, underscores. 2-12 chars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -836,6 +998,25 @@ function handleSignOut() {
|
||||
<!-- Wallet connect (shown if no wallet) -->
|
||||
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
|
||||
|
||||
<!-- Practice 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
|
||||
font-display font-bold text-sm tracking-widest
|
||||
hover:bg-white/5 hover:text-text-secondary transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-2"
|
||||
:disabled="isJoiningPractice"
|
||||
@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' }}
|
||||
</button>
|
||||
<p class="font-mono text-[10px] text-text-muted/50 text-center mt-1">
|
||||
Free sparring against practice bots — no ELO impact
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
|
||||
Reference in New Issue
Block a user