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:
Dorian
2026-03-08 10:33:30 +00:00
co-authored by Claude Opus 4.6
parent ccf4196647
commit f6eb7d2845
32 changed files with 1743 additions and 467 deletions
+210 -29
View File
@@ -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 -->