fix: early name validation, fighter glow, reduced visual noise

- Add /api/auth/check-name endpoint for early name availability check
- JoinBoutPage checks name availability at naming step (not after webhook)
- FightCardPage: replace poster containers with circular glow behind sprites
- Reduce synthwave grid and CRT overlay opacity for cleaner backgrounds
- BotProfilePage: webhook management, owner-only fields
- useNostr: normalize bot data, guard auto-restore, clearAllState helper
- bots.ts: expose owner-only webhook info on stats endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 11:03:16 +00:00
co-authored by Claude Opus 4.6
parent 2d8cdcc60a
commit 9a11c48487
7 changed files with 405 additions and 120 deletions
+108 -32
View File
@@ -55,40 +55,73 @@ function store(key: string, value: unknown) {
else localStorage.setItem(key, JSON.stringify(value)) else localStorage.setItem(key, JSON.stringify(value))
} }
/** Normalize server bot data to fill missing fields */
function normalizeBotData(data: Record<string, unknown>): BotData {
return {
satsWon: 0,
satsWagered: 0,
hasWallet: false,
...data,
} as BotData
}
/** Clear all auth-related state */
function clearAllState() {
pubkey.value = null
bot.value = null
profilePicUrl.value = null
store('bf_pubkey', null)
store('bf_bot', null)
store('bf_pic', null)
}
const pubkey = ref<string | null>(loadStored('bf_pubkey')) const pubkey = ref<string | null>(loadStored('bf_pubkey'))
const bot = ref<BotData | null>(loadStored('bf_bot')) const bot = ref<BotData | null>(loadStored('bf_bot'))
const profilePicUrl = ref<string | null>(loadStored('bf_pic')) const profilePicUrl = ref<string | null>(loadStored('bf_pic'))
const isLoading = ref(false) const isLoading = ref(false)
// Guard: only auto-restore once across all component mounts
let autoRestoreRan = false
// Flag: skip relay pic fetch for freshly generated keys (no profile exists)
let freshlyGenerated = false
export function useNostr() { export function useNostr() {
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value) const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
const hasExtension = computed(() => !!window.nostr) const hasExtension = computed(() => !!window.nostr)
// Restore session on first load — re-verify with server // Restore session on first load — re-verify with server (once only)
if (pubkey.value && !bot.value) { if (!autoRestoreRan && pubkey.value && !bot.value) {
autoRestoreRan = true
fetch('/api/auth/login', { fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value }), body: JSON.stringify({ pubkey: pubkey.value }),
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
if (data.exists) { if (data.exists) {
bot.value = data.bot bot.value = normalizeBotData(data.bot)
store('bf_bot', data.bot) store('bf_bot', bot.value)
} }
}).catch(() => {}) }).catch(() => {})
} }
async function login(): Promise<{ pubkey: string; bot: BotData | null }> { async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
// Try NIP-07 extension first, fall back to stored nsec
let pk: string let pk: string
if (window.nostr) { if (window.nostr) {
pk = await window.nostr.getPublicKey() try {
pk = await window.nostr.getPublicKey()
} catch {
throw new Error('Nostr extension denied access. Approve the request and try again.')
}
} else { } else {
const storedNsec = localStorage.getItem('bf_nsec') const storedNsec = localStorage.getItem('bf_nsec')
if (storedNsec) { if (storedNsec) {
const secretKey = hexToBytes(storedNsec) try {
pk = getPublicKey(secretKey) const secretKey = hexToBytes(storedNsec)
pk = getPublicKey(secretKey)
} catch {
throw new Error('Stored key is corrupt. Generate a new identity.')
}
} else { } else {
throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.') throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.')
} }
@@ -99,10 +132,18 @@ export function useNostr() {
pubkey.value = pk pubkey.value = pk
store('bf_pubkey', pk) store('bf_pubkey', pk)
// Fetch Nostr profile pic from relay // Fetch Nostr profile pic from relay (skip for freshly generated keys — no profile exists)
const pic = await fetchNostrProfilePic(pk) if (freshlyGenerated) {
profilePicUrl.value = pic freshlyGenerated = false
store('bf_pic', pic) profilePicUrl.value = null
store('bf_pic', null)
} else {
// Non-blocking: start fetch but don't block login on it
fetchNostrProfilePic(pk).then(pic => {
profilePicUrl.value = pic
store('bf_pic', pic)
}).catch(() => {})
}
// Check if this pubkey has a bot // Check if this pubkey has a bot
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
@@ -114,9 +155,9 @@ export function useNostr() {
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
if (data.exists) { if (data.exists) {
bot.value = data.bot bot.value = normalizeBotData(data.bot)
store('bf_bot', data.bot) store('bf_bot', bot.value)
return { pubkey: pk, bot: data.bot } return { pubkey: pk, bot: bot.value }
} }
} }
@@ -137,13 +178,13 @@ export function useNostr() {
const nsecHex = bytesToHex(secretKey) const nsecHex = bytesToHex(secretKey)
const nsecBech32 = nsecEncode(secretKey) const nsecBech32 = nsecEncode(secretKey)
// Clear stale state from previous identity // Clear all stale state from previous identity
bot.value = null clearAllState()
profilePicUrl.value = null
store('bf_bot', null)
store('bf_pic', null)
// Store locally // Mark as freshly generated so login() skips relay pic fetch
freshlyGenerated = true
// Store new key
localStorage.setItem('bf_nsec', nsecHex) localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk pubkey.value = pk
store('bf_pubkey', pk) store('bf_pubkey', pk)
@@ -153,15 +194,32 @@ export function useNostr() {
/** Login with an existing nsec (hex) */ /** Login with an existing nsec (hex) */
async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> { async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
const secretKey = hexToBytes(nsecHex) let secretKey: Uint8Array
try {
secretKey = hexToBytes(nsecHex)
} catch {
throw new Error('Invalid secret key format.')
}
const pk = getPublicKey(secretKey) const pk = getPublicKey(secretKey)
// Clear stale state before switching identity
bot.value = null
profilePicUrl.value = null
store('bf_bot', null)
store('bf_pic', null)
localStorage.setItem('bf_nsec', nsecHex) localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk pubkey.value = pk
store('bf_pubkey', pk) store('bf_pubkey', pk)
isLoading.value = true isLoading.value = true
try { try {
// Fetch Nostr profile pic (non-blocking)
fetchNostrProfilePic(pk).then(pic => {
profilePicUrl.value = pic
store('bf_pic', pic)
}).catch(() => {})
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -171,9 +229,9 @@ export function useNostr() {
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
if (data.exists) { if (data.exists) {
bot.value = data.bot bot.value = normalizeBotData(data.bot)
store('bf_bot', data.bot) store('bf_bot', bot.value)
return { pubkey: pk, bot: data.bot } return { pubkey: pk, bot: bot.value }
} }
} }
@@ -199,7 +257,11 @@ export function useNostr() {
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Registration failed') if (!res.ok) {
// Surface detailed error info from webhook verification failures
const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed')
throw new Error(msg)
}
bot.value = { bot.value = {
id: data.id, id: data.id,
@@ -247,6 +309,24 @@ export function useNostr() {
} }
} }
async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> {
if (!pubkey.value) throw new Error('Not logged in')
const res = await fetch('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
})
const data = await res.json()
if (!res.ok) {
const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Update failed')
throw new Error(msg)
}
return { latencyMs: data.latencyMs || 0 }
}
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> { async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
if (!pubkey.value) throw new Error('Not logged in') if (!pubkey.value) throw new Error('Not logged in')
@@ -288,12 +368,7 @@ export function useNostr() {
} }
function logout() { function logout() {
pubkey.value = null clearAllState()
bot.value = null
profilePicUrl.value = null
store('bf_pubkey', null)
store('bf_bot', null)
store('bf_pic', null)
// Don't clear bf_nsec on logout — user may want to log back in // Don't clear bf_nsec on logout — user may want to log back in
} }
@@ -319,6 +394,7 @@ export function useNostr() {
registerBot, registerBot,
registerHuman, registerHuman,
updateCustomization, updateCustomization,
updateWebhook,
getStoredNsec, getStoredNsec,
logout, logout,
} }
+154 -52
View File
@@ -9,7 +9,7 @@ import type { SpriteCustomization } from '../game/sprites'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { bot: nostrBot, isLoggedIn, logout, updateCustomization } = useNostr() const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook } = useNostr()
const botName = route.params.name as string const botName = route.params.name as string
interface BotCustomization { interface BotCustomization {
@@ -34,6 +34,7 @@ interface BotStats {
winStreak: number winStreak: number
bestStreak: number bestStreak: number
tier: number tier: number
isActive: boolean
tierName: string tierName: string
tierColor: string tierColor: string
winRate: number winRate: number
@@ -44,6 +45,10 @@ interface BotStats {
satsWagered: number satsWagered: number
hasWallet: boolean hasWallet: boolean
createdAt: string createdAt: string
// Owner-only webhook fields
webhookUrl?: string
consecutiveErrors?: number
isHuman?: boolean
recentFights: { recentFights: {
id: string id: string
opponent: string opponent: string
@@ -86,6 +91,15 @@ const showCustomize = ref(false)
const isSaving = ref(false) const isSaving = ref(false)
const custError = ref('') const custError = ref('')
// Webhook management
const showWebhook = ref(false)
const webhookInput = ref('')
const isTestingWebhook = ref(false)
const isUpdatingWebhook = ref(false)
const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; latencyMs: number; error?: string; message?: string } | null>(null)
const webhookError = ref('')
const webhookSuccess = ref('')
const ARCHETYPES = [ const ARCHETYPES = [
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', 'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
@@ -210,9 +224,49 @@ async function saveCustomization() {
isSaving.value = false isSaving.value = false
} }
async function testWebhook() {
if (!stats.value || isTestingWebhook.value) return
isTestingWebhook.value = true
webhookTestResult.value = null
webhookError.value = ''
try {
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/test`, { method: 'POST' })
webhookTestResult.value = await res.json()
if (webhookTestResult.value?.validResponse && stats.value) {
stats.value = { ...stats.value, consecutiveErrors: 0, isActive: true }
}
} catch {
webhookError.value = 'Network error testing webhook.'
}
isTestingWebhook.value = false
}
async function saveWebhook() {
const url = webhookInput.value.trim()
if (!url || isUpdatingWebhook.value) return
try { new URL(url) } catch { webhookError.value = 'Invalid URL.'; return }
isUpdatingWebhook.value = true
webhookError.value = ''
webhookSuccess.value = ''
try {
await updateWebhook(url)
webhookSuccess.value = 'Webhook updated and verified.'
if (stats.value) {
stats.value = { ...stats.value, webhookUrl: url, consecutiveErrors: 0 }
}
webhookInput.value = ''
} catch (err) {
webhookError.value = err instanceof Error ? err.message : 'Update failed.'
}
isUpdatingWebhook.value = false
}
onMounted(async () => { onMounted(async () => {
try { try {
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats`) const pk = pubkey.value || ''
const qs = pk ? `?pubkey=${encodeURIComponent(pk)}` : ''
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats${qs}`)
if (res.ok) stats.value = await res.json() if (res.ok) stats.value = await res.json()
else loadError.value = `Failed to load bot (${res.status})` else loadError.value = `Failed to load bot (${res.status})`
} catch (err) { } catch (err) {
@@ -317,42 +371,23 @@ const tierClass = (t: number) => `tier-${t}`
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]" class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
/> />
</div> </div>
<!-- Bot player: human controller + wire + bot sprite --> <!-- Bot player: human controller + bot sprite -->
<div v-else class="flex items-end justify-center gap-0 mb-4 relative"> <div v-else class="flex items-end justify-center gap-2 mb-4">
<HumanPreview <HumanPreview
:seed="stats.avatarSeed || stats.name" :seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype || 'standard'" :archetype="stats.archetype || 'standard'"
:size="150" :size="150"
:win-rate="(stats.winRate || 0) / 100" :win-rate="(stats.winRate || 0) / 100"
anim="idle" anim="idle"
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0 relative z-10" class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0"
/> />
<!-- Wire from gamepad to bot with electricity -->
<svg class="absolute bottom-8 left-0 w-full h-24 z-0 pointer-events-none" preserveAspectRatio="none">
<defs>
<linearGradient id="wire-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#444455"/>
<stop offset="50%" stop-color="#555566"/>
<stop offset="100%" stop-color="#444455"/>
</linearGradient>
<filter id="elec-glow">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<path d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="url(#wire-grad)" stroke-width="3" stroke-linecap="round"/>
<path class="elec-pulse-1" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#00f0ff" stroke-width="1.5" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="8 20" opacity="0.8"/>
<path class="elec-pulse-2" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#ff2d7b" stroke-width="1" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="5 25" opacity="0.6"/>
<circle class="elec-spark-1" cx="45%" cy="55%" r="2" fill="#00f0ff" filter="url(#elec-glow)" opacity="0"/>
<circle class="elec-spark-2" cx="62%" cy="45%" r="2" fill="#ff2d7b" filter="url(#elec-glow)" opacity="0"/>
</svg>
<SpritePreview <SpritePreview
:seed="stats.avatarSeed || stats.name" :seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype" :archetype="stats.archetype"
:tier="stats.tier" :tier="stats.tier"
:size="140" :size="140"
:customization="stats.customization || undefined" :customization="stats.customization || undefined"
class="drop-shadow-[0_0_20px_var(--glow)] relative z-10" class="drop-shadow-[0_0_20px_var(--glow)]"
:style="{ '--glow': stats.tierColor + '80' } as any" :style="{ '--glow': stats.tierColor + '80' } as any"
/> />
</div> </div>
@@ -561,6 +596,100 @@ const tierClass = (t: number) => `tier-${t}`
</button> </button>
</div> </div>
<!-- Webhook management (owner only, bots only) -->
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
<!-- Deactivated warning -->
<div v-if="stats.isActive === false" class="p-3 border-2 border-ko/40 bg-ko/5 mb-3">
<p class="font-display font-bold text-xs tracking-wider text-ko mb-1">BOT DEACTIVATED</p>
<p class="font-mono text-[10px] text-text-muted">
Too many webhook errors. Test your webhook to reactivate.
</p>
</div>
<button
class="w-full py-2 border border-border text-text-secondary font-display font-bold text-[10px]
tracking-wider hover:border-neon-cyan/40 hover:text-neon-cyan transition-all text-center"
@click="showWebhook = !showWebhook"
>
{{ showWebhook ? 'HIDE' : 'WEBHOOK' }} SETTINGS
</button>
<div v-if="showWebhook" class="mt-3 border border-border bg-surface-raised/60 p-4 space-y-3">
<!-- Current webhook info -->
<div>
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">CURRENT WEBHOOK</p>
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full flex-shrink-0"
:class="(stats.consecutiveErrors ?? 0) === 0 ? 'bg-neon-green' : 'bg-ko'"
/>
<p class="font-mono text-[10px] text-text-secondary break-all flex-1">
{{ stats.webhookUrl || '—' }}
</p>
</div>
<p v-if="(stats.consecutiveErrors ?? 0) > 0" class="font-mono text-[10px] text-ko mt-1">
{{ stats.consecutiveErrors }} consecutive errors
</p>
</div>
<!-- Test button -->
<button
class="w-full py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-cyan/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
:disabled="isTestingWebhook"
@click="testWebhook"
>
<span v-if="isTestingWebhook" class="w-3 h-3 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isTestingWebhook ? 'TESTING...' : 'TEST WEBHOOK' }}
</button>
<!-- Test result -->
<div v-if="webhookTestResult" class="p-2.5 border text-[10px] font-mono"
:class="webhookTestResult.validResponse
? 'border-neon-green/30 bg-neon-green/5 text-neon-green'
: 'border-ko/30 bg-ko/5 text-ko'">
<p class="font-display font-bold text-xs tracking-wider mb-1">
{{ webhookTestResult.validResponse ? 'PASSED' : 'FAILED' }}
</p>
<p>{{ webhookTestResult.message || webhookTestResult.error }}</p>
<p v-if="webhookTestResult.latencyMs" class="text-text-muted mt-0.5">
Latency: {{ webhookTestResult.latencyMs }}ms
</p>
</div>
<!-- Update webhook URL -->
<div class="border-t border-border/50 pt-3">
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">UPDATE WEBHOOK URL</p>
<div class="flex gap-2">
<input
v-model="webhookInput"
type="url"
placeholder="https://your-bot.example.com"
class="flex-1 px-2 py-1.5 bg-black/30 border border-border font-mono text-[10px] text-text-primary
placeholder-text-muted/50 focus:outline-none focus:border-neon-cyan/50"
@keyup.enter="saveWebhook"
/>
<button
class="px-3 py-1.5 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-cyan/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="!webhookInput.trim() || isUpdatingWebhook"
@click="saveWebhook"
>
{{ isUpdatingWebhook ? '...' : 'UPDATE' }}
</button>
</div>
</div>
<p v-if="webhookError" class="font-mono text-[10px] text-ko">{{ webhookError }}</p>
<p v-if="webhookSuccess" class="font-mono text-[10px] text-neon-green">{{ webhookSuccess }}</p>
</div>
</div>
<!-- Sign out (only if owner) --> <!-- Sign out (only if owner) -->
<div v-if="isOwner" class="mt-3 text-center flex-shrink-0"> <div v-if="isOwner" class="mt-3 text-center flex-shrink-0">
<button <button
@@ -608,30 +737,3 @@ const tierClass = (t: number) => `tier-${t}`
</div> </div>
</template> </template>
<style scoped>
/* Electricity pulses along wire */
.elec-pulse-1 {
animation: elec-flow 1.2s linear infinite;
}
.elec-pulse-2 {
animation: elec-flow 0.9s linear infinite reverse;
}
@keyframes elec-flow {
0% { stroke-dashoffset: 0; opacity: 0.9; }
50% { opacity: 0.4; }
100% { stroke-dashoffset: -56; opacity: 0.9; }
}
/* Spark flashes at wire midpoints */
.elec-spark-1 {
animation: spark-flash 0.8s ease-in-out infinite;
}
.elec-spark-2 {
animation: spark-flash 1.1s ease-in-out infinite 0.4s;
}
@keyframes spark-flash {
0%, 70%, 100% { opacity: 0; r: 1; }
75% { opacity: 1; r: 4; }
85% { opacity: 0.6; r: 2; }
}
</style>
+42 -20
View File
@@ -2,7 +2,6 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue' import SpritePreview from '../components/SpritePreview.vue'
import PosterSprite from '../components/PosterSprite.vue'
import PixelGlove from '../components/PixelGlove.vue' import PixelGlove from '../components/PixelGlove.vue'
interface BotInfo { interface BotInfo {
@@ -195,32 +194,34 @@ onUnmounted(() => {
<!-- Sprites --> <!-- Sprites -->
<div class="flex items-end justify-center w-full max-w-4xl"> <div class="flex items-end justify-center w-full max-w-4xl">
<div class="flex-1 flex flex-col items-center min-w-0"> <div class="flex-1 flex flex-col items-center min-w-0">
<PosterSprite <div v-if="fighterA" class="relative fighter-bob">
v-if="fighterA" <!-- Circular glow behind fighter -->
:key="`a-${featured.id}-${fighterA.avatarSeed}`" <div class="fighter-glow fighter-glow-cyan" />
:seed="fighterA.avatarSeed || fighterA.name" <SpritePreview
:archetype="fighterA.archetype || 'standard'" :key="`a-${featured.id}-${fighterA.avatarSeed}`"
:tier="fighterA.tier" :seed="fighterA.avatarSeed || fighterA.name"
:size="160" :archetype="fighterA.archetype || 'standard'"
pose="win" :tier="fighterA.tier"
glow-color="cyan" :size="120"
class="relative z-10 fighter-bob pose="win"
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]" class="relative z-10
/> sm:!w-[180px] sm:!h-[180px] lg:!w-[260px] lg:!h-[260px]"
/>
</div>
</div> </div>
<div class="flex-1 flex flex-col items-center min-w-0 -ml-6 sm:-ml-12 lg:-ml-20"> <div class="flex-1 flex flex-col items-center min-w-0 -ml-6 sm:-ml-12 lg:-ml-20">
<div style="transform: scaleX(-1)"> <div v-if="fighterB" class="relative fighter-bob-delayed" style="transform: scaleX(-1)">
<PosterSprite <!-- Circular glow behind fighter -->
v-if="fighterB" <div class="fighter-glow fighter-glow-pink" />
<SpritePreview
:key="`b-${featured.id}-${fighterB.avatarSeed}`" :key="`b-${featured.id}-${fighterB.avatarSeed}`"
:seed="fighterB.avatarSeed || fighterB.name" :seed="fighterB.avatarSeed || fighterB.name"
:archetype="fighterB.archetype || 'standard'" :archetype="fighterB.archetype || 'standard'"
:tier="fighterB.tier" :tier="fighterB.tier"
:size="160" :size="120"
pose="idle" pose="idle"
glow-color="pink" class="relative z-10
class="relative z-10 fighter-bob-delayed sm:!w-[180px] sm:!h-[180px] lg:!w-[260px] lg:!h-[260px]"
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
/> />
</div> </div>
</div> </div>
@@ -557,6 +558,27 @@ onUnmounted(() => {
animation: punchRight 2s ease-in-out infinite 0.3s; animation: punchRight 2s ease-in-out infinite 0.3s;
} }
/* Circular glow behind fighters */
.fighter-glow {
position: absolute;
top: 50%;
left: 50%;
width: 140%;
height: 140%;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.fighter-glow-cyan {
background: radial-gradient(circle, rgba(0, 240, 255, 0.25) 0%, rgba(0, 240, 255, 0.10) 35%, rgba(0, 240, 255, 0.03) 60%, transparent 80%);
box-shadow: 0 0 60px rgba(0, 240, 255, 0.15), 0 0 120px rgba(0, 240, 255, 0.05);
}
.fighter-glow-pink {
background: radial-gradient(circle, rgba(255, 45, 120, 0.25) 0%, rgba(255, 45, 120, 0.10) 35%, rgba(255, 45, 120, 0.03) 60%, transparent 80%);
box-shadow: 0 0 60px rgba(255, 45, 120, 0.15), 0 0 120px rgba(255, 45, 120, 0.05);
}
/* Fighter bobbing */ /* Fighter bobbing */
.fighter-bob { .fighter-bob {
animation: fighterBob 2.5s ease-in-out infinite; animation: fighterBob 2.5s ease-in-out infinite;
+46 -5
View File
@@ -144,6 +144,7 @@ function handleGenerateLogin() {
} }
async function handleNsecBackupDone() { async function handleNsecBackupDone() {
if (isLoading) return
showNsecBackup.value = false showNsecBackup.value = false
try { try {
const result = await login() const result = await login()
@@ -206,7 +207,9 @@ function pickCharacter(id: string) {
step.value = 'name-bot' step.value = 'name-bot'
} }
function confirmName() { const isCheckingName = ref(false)
async function confirmName() {
const name = botName.value.trim() const name = botName.value.trim()
if (!name || name.length < 2) { if (!name || name.length < 2) {
error.value = 'Name must be at least 2 characters.' error.value = 'Name must be at least 2 characters.'
@@ -217,6 +220,21 @@ function confirmName() {
return return
} }
error.value = '' error.value = ''
isCheckingName.value = true
try {
const res = await fetch(`/api/auth/check-name/${encodeURIComponent(name)}`)
if (res.ok) {
const data = await res.json()
if (!data.available) {
error.value = 'That name is taken. Pick another.'
return
}
}
} catch {
// If check fails, proceed anyway — server will catch it at registration
} finally {
isCheckingName.value = false
}
step.value = 'bot-setup' step.value = 'bot-setup'
} }
@@ -269,6 +287,21 @@ async function confirmHumanName() {
return return
} }
error.value = '' error.value = ''
isCheckingName.value = true
try {
const res = await fetch(`/api/auth/check-name/${encodeURIComponent(name)}`)
if (res.ok) {
const data = await res.json()
if (!data.available) {
error.value = 'That name is taken. Pick another.'
return
}
}
} catch {
// proceed — server catches at registration
} finally {
isCheckingName.value = false
}
step.value = 'human-guide' step.value = 'human-guide'
} }
@@ -598,10 +631,14 @@ function handleSignOut() {
<button <button
class="flex-1 py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan 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 font-display font-bold text-sm tracking-wider
hover:bg-neon-cyan/20 transition-all" hover:bg-neon-cyan/20 transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-2"
:disabled="isCheckingName"
@click="confirmName" @click="confirmName"
> >
NEXT <span v-if="isCheckingName" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isCheckingName ? 'CHECKING...' : 'NEXT' }}
</button> </button>
</div> </div>
</template> </template>
@@ -852,10 +889,14 @@ function handleSignOut() {
<button <button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all" hover:bg-neon-pink/20 transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-2"
:disabled="isCheckingName"
@click="confirmHumanName" @click="confirmHumanName"
> >
NEXT <span v-if="isCheckingName" class="w-4 h-4 border-2 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
{{ isCheckingName ? 'CHECKING...' : 'NEXT' }}
</button> </button>
</div> </div>
</template> </template>
+4 -4
View File
@@ -39,8 +39,8 @@
/* Synthwave grid background */ /* Synthwave grid background */
.synthwave-grid { .synthwave-grid {
background-image: background-image:
linear-gradient(rgba(184, 61, 255, 0.06) 1px, transparent 1px), linear-gradient(rgba(184, 61, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(184, 61, 255, 0.06) 1px, transparent 1px); linear-gradient(90deg, rgba(184, 61, 255, 0.025) 1px, transparent 1px);
background-size: 40px 40px; background-size: 40px 40px;
} }
@@ -48,8 +48,8 @@
.crt-overlay { .crt-overlay {
background: repeating-linear-gradient( background: repeating-linear-gradient(
0deg, 0deg,
rgba(0, 0, 0, 0.12) 0px, rgba(0, 0, 0, 0.04) 0px,
rgba(0, 0, 0, 0.12) 1px, rgba(0, 0, 0, 0.04) 1px,
transparent 1px, transparent 1px,
transparent 3px transparent 3px
); );
+32 -6
View File
@@ -10,6 +10,19 @@ import { rateLimit } from '../middleware/rate-limit.js'
export const authRouter = new Hono() export const authRouter = new Hono()
// Check name availability
authRouter.get("/check-name/:name", async (c) => {
const name = c.req.param("name")?.trim().toLowerCase()
if (!name || name.length < 2 || name.length > 12) {
return c.json({ available: false, error: "Name must be 2-12 characters." })
}
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(sql`LOWER(${schema.bots.name})`, name))
.limit(1)
return c.json({ available: existing.length === 0 })
})
// Login with Nostr pubkey // Login with Nostr pubkey
authRouter.post('/login', async (c) => { authRouter.post('/login', async (c) => {
const body = await c.req.json() const body = await c.req.json()
@@ -33,6 +46,9 @@ authRouter.post('/login', async (c) => {
tier: schema.bots.tier, tier: schema.bots.tier,
isActive: schema.bots.isActive, isActive: schema.bots.isActive,
customization: schema.bots.customization, customization: schema.bots.customization,
webhookUrl: schema.bots.webhookUrl,
satsWon: schema.bots.satsWon,
satsWagered: schema.bots.satsWagered,
}).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1)
if (rows.length === 0) { if (rows.length === 0) {
@@ -40,18 +56,28 @@ authRouter.post('/login', async (c) => {
} }
const bot = rows[0] const bot = rows[0]
const isHuman = bot.webhookUrl === 'http://human.local/'
// Check if this is a human player by loading webhookUrl
const webhookRows = await db.select({ webhookUrl: schema.bots.webhookUrl })
.from(schema.bots).where(eq(schema.bots.id, bot.id)).limit(1)
const isHuman = webhookRows[0]?.webhookUrl === 'http://human.local/'
return c.json({ return c.json({
exists: true, exists: true,
bot: { bot: {
...bot, id: bot.id,
name: bot.name,
avatarSeed: bot.avatarSeed,
archetype: bot.archetype,
profilePicUrl: bot.profilePicUrl,
eloRating: bot.eloRating,
wins: bot.wins,
losses: bot.losses,
winStreak: bot.winStreak,
bestStreak: bot.bestStreak,
tier: bot.tier,
isActive: bot.isActive,
isHuman, isHuman,
customization: bot.customization ? JSON.parse(bot.customization) : null, customization: bot.customization ? JSON.parse(bot.customization) : null,
satsWon: bot.satsWon ?? 0,
satsWagered: bot.satsWagered ?? 0,
hasWallet: false,
}, },
}) })
}) })
+19 -1
View File
@@ -151,6 +151,7 @@ botsRouter.get('/:name', async (c) => {
// Get bot stats -- full account page data // Get bot stats -- full account page data
botsRouter.get('/:name/stats', async (c) => { botsRouter.get('/:name/stats', async (c) => {
const name = c.req.param('name') const name = c.req.param('name')
const ownerPubkey = c.req.query('pubkey')
const botRows = await db.select({ const botRows = await db.select({
id: schema.bots.id, id: schema.bots.id,
name: schema.bots.name, name: schema.bots.name,
@@ -167,6 +168,11 @@ botsRouter.get('/:name/stats', async (c) => {
customization: schema.bots.customization, customization: schema.bots.customization,
botType: schema.bots.botType, botType: schema.bots.botType,
createdAt: schema.bots.createdAt, createdAt: schema.bots.createdAt,
satsWon: schema.bots.satsWon,
satsWagered: schema.bots.satsWagered,
publicKey: schema.bots.publicKey,
webhookUrl: schema.bots.webhookUrl,
consecutiveErrors: schema.bots.consecutiveErrors,
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1) }).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
if (botRows.length === 0) { if (botRows.length === 0) {
@@ -249,8 +255,19 @@ botsRouter.get('/:name/stats', async (c) => {
eloRating: bot.eloRating, eloRating: bot.eloRating,
}, allFights) }, allFights)
// Owner-only fields: webhook info (only if pubkey matches)
const isOwnerRequest = ownerPubkey && ownerPubkey === rawBot.publicKey
const ownerFields = isOwnerRequest ? {
webhookUrl: rawBot.webhookUrl,
consecutiveErrors: rawBot.consecutiveErrors ?? 0,
isHuman: rawBot.webhookUrl === 'http://human.local/',
} : {}
// Strip internal fields from public response
const { publicKey: _pk, webhookUrl: _wh, consecutiveErrors: _ce, ...publicBot } = bot
return c.json({ return c.json({
...bot, ...publicBot,
tierName: TIER_NAMES[bot.tier] || 'BABY', tierName: TIER_NAMES[bot.tier] || 'BABY',
tierColor: TIER_COLORS[bot.tier] || '#888', tierColor: TIER_COLORS[bot.tier] || '#888',
winRate, winRate,
@@ -259,6 +276,7 @@ botsRouter.get('/:name/stats', async (c) => {
totalBots: rankedBots.length, totalBots: rankedBots.length,
recentFights, recentFights,
achievements, achievements,
...ownerFields,
}) })
}) })