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:
co-authored by
Claude Opus 4.6
parent
2d8cdcc60a
commit
9a11c48487
@@ -55,40 +55,73 @@ function store(key: string, value: unknown) {
|
||||
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 bot = ref<BotData | null>(loadStored('bf_bot'))
|
||||
const profilePicUrl = ref<string | null>(loadStored('bf_pic'))
|
||||
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() {
|
||||
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
|
||||
const hasExtension = computed(() => !!window.nostr)
|
||||
|
||||
// Restore session on first load — re-verify with server
|
||||
if (pubkey.value && !bot.value) {
|
||||
// Restore session on first load — re-verify with server (once only)
|
||||
if (!autoRestoreRan && pubkey.value && !bot.value) {
|
||||
autoRestoreRan = true
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.exists) {
|
||||
bot.value = data.bot
|
||||
store('bf_bot', data.bot)
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
// Try NIP-07 extension first, fall back to stored nsec
|
||||
let pk: string
|
||||
|
||||
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 {
|
||||
const storedNsec = localStorage.getItem('bf_nsec')
|
||||
if (storedNsec) {
|
||||
const secretKey = hexToBytes(storedNsec)
|
||||
pk = getPublicKey(secretKey)
|
||||
try {
|
||||
const secretKey = hexToBytes(storedNsec)
|
||||
pk = getPublicKey(secretKey)
|
||||
} catch {
|
||||
throw new Error('Stored key is corrupt. Generate a new identity.')
|
||||
}
|
||||
} else {
|
||||
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
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
// Fetch Nostr profile pic from relay
|
||||
const pic = await fetchNostrProfilePic(pk)
|
||||
profilePicUrl.value = pic
|
||||
store('bf_pic', pic)
|
||||
// Fetch Nostr profile pic from relay (skip for freshly generated keys — no profile exists)
|
||||
if (freshlyGenerated) {
|
||||
freshlyGenerated = false
|
||||
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
|
||||
const res = await fetch('/api/auth/login', {
|
||||
@@ -114,9 +155,9 @@ export function useNostr() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
bot.value = data.bot
|
||||
store('bf_bot', data.bot)
|
||||
return { pubkey: pk, bot: data.bot }
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
return { pubkey: pk, bot: bot.value }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,13 +178,13 @@ export function useNostr() {
|
||||
const nsecHex = bytesToHex(secretKey)
|
||||
const nsecBech32 = nsecEncode(secretKey)
|
||||
|
||||
// Clear stale state from previous identity
|
||||
bot.value = null
|
||||
profilePicUrl.value = null
|
||||
store('bf_bot', null)
|
||||
store('bf_pic', null)
|
||||
// Clear all stale state from previous identity
|
||||
clearAllState()
|
||||
|
||||
// Store locally
|
||||
// Mark as freshly generated so login() skips relay pic fetch
|
||||
freshlyGenerated = true
|
||||
|
||||
// Store new key
|
||||
localStorage.setItem('bf_nsec', nsecHex)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
@@ -153,15 +194,32 @@ export function useNostr() {
|
||||
|
||||
/** Login with an existing nsec (hex) */
|
||||
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)
|
||||
|
||||
// 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)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
isLoading.value = true
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -171,9 +229,9 @@ export function useNostr() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
bot.value = data.bot
|
||||
store('bf_bot', data.bot)
|
||||
return { pubkey: pk, bot: data.bot }
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
return { pubkey: pk, bot: bot.value }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +257,11 @@ export function useNostr() {
|
||||
})
|
||||
|
||||
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 = {
|
||||
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> {
|
||||
if (!pubkey.value) throw new Error('Not logged in')
|
||||
|
||||
@@ -288,12 +368,7 @@ export function useNostr() {
|
||||
}
|
||||
|
||||
function logout() {
|
||||
pubkey.value = null
|
||||
bot.value = null
|
||||
profilePicUrl.value = null
|
||||
store('bf_pubkey', null)
|
||||
store('bf_bot', null)
|
||||
store('bf_pic', null)
|
||||
clearAllState()
|
||||
// Don't clear bf_nsec on logout — user may want to log back in
|
||||
}
|
||||
|
||||
@@ -319,6 +394,7 @@ export function useNostr() {
|
||||
registerBot,
|
||||
registerHuman,
|
||||
updateCustomization,
|
||||
updateWebhook,
|
||||
getStoredNsec,
|
||||
logout,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { SpriteCustomization } from '../game/sprites'
|
||||
|
||||
const route = useRoute()
|
||||
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
|
||||
|
||||
interface BotCustomization {
|
||||
@@ -34,6 +34,7 @@ interface BotStats {
|
||||
winStreak: number
|
||||
bestStreak: number
|
||||
tier: number
|
||||
isActive: boolean
|
||||
tierName: string
|
||||
tierColor: string
|
||||
winRate: number
|
||||
@@ -44,6 +45,10 @@ interface BotStats {
|
||||
satsWagered: number
|
||||
hasWallet: boolean
|
||||
createdAt: string
|
||||
// Owner-only webhook fields
|
||||
webhookUrl?: string
|
||||
consecutiveErrors?: number
|
||||
isHuman?: boolean
|
||||
recentFights: {
|
||||
id: string
|
||||
opponent: string
|
||||
@@ -86,6 +91,15 @@ const showCustomize = ref(false)
|
||||
const isSaving = ref(false)
|
||||
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 = [
|
||||
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
|
||||
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
|
||||
@@ -210,9 +224,49 @@ async function saveCustomization() {
|
||||
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 () => {
|
||||
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()
|
||||
else loadError.value = `Failed to load bot (${res.status})`
|
||||
} catch (err) {
|
||||
@@ -317,42 +371,23 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bot player: human controller + wire + bot sprite -->
|
||||
<div v-else class="flex items-end justify-center gap-0 mb-4 relative">
|
||||
<!-- Bot player: human controller + bot sprite -->
|
||||
<div v-else class="flex items-end justify-center gap-2 mb-4">
|
||||
<HumanPreview
|
||||
:seed="stats.avatarSeed || stats.name"
|
||||
:archetype="stats.archetype || 'standard'"
|
||||
:size="150"
|
||||
:win-rate="(stats.winRate || 0) / 100"
|
||||
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
|
||||
:seed="stats.avatarSeed || stats.name"
|
||||
:archetype="stats.archetype"
|
||||
:tier="stats.tier"
|
||||
:size="140"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
@@ -561,6 +596,100 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</button>
|
||||
</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) -->
|
||||
<div v-if="isOwner" class="mt-3 text-center flex-shrink-0">
|
||||
<button
|
||||
@@ -608,30 +737,3 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import PosterSprite from '../components/PosterSprite.vue'
|
||||
import PixelGlove from '../components/PixelGlove.vue'
|
||||
|
||||
interface BotInfo {
|
||||
@@ -195,32 +194,34 @@ onUnmounted(() => {
|
||||
<!-- Sprites -->
|
||||
<div class="flex items-end justify-center w-full max-w-4xl">
|
||||
<div class="flex-1 flex flex-col items-center min-w-0">
|
||||
<PosterSprite
|
||||
v-if="fighterA"
|
||||
:key="`a-${featured.id}-${fighterA.avatarSeed}`"
|
||||
:seed="fighterA.avatarSeed || fighterA.name"
|
||||
:archetype="fighterA.archetype || 'standard'"
|
||||
:tier="fighterA.tier"
|
||||
:size="160"
|
||||
pose="win"
|
||||
glow-color="cyan"
|
||||
class="relative z-10 fighter-bob
|
||||
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
|
||||
/>
|
||||
<div v-if="fighterA" class="relative fighter-bob">
|
||||
<!-- Circular glow behind fighter -->
|
||||
<div class="fighter-glow fighter-glow-cyan" />
|
||||
<SpritePreview
|
||||
:key="`a-${featured.id}-${fighterA.avatarSeed}`"
|
||||
:seed="fighterA.avatarSeed || fighterA.name"
|
||||
:archetype="fighterA.archetype || 'standard'"
|
||||
:tier="fighterA.tier"
|
||||
:size="120"
|
||||
pose="win"
|
||||
class="relative z-10
|
||||
sm:!w-[180px] sm:!h-[180px] lg:!w-[260px] lg:!h-[260px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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)">
|
||||
<PosterSprite
|
||||
v-if="fighterB"
|
||||
<div v-if="fighterB" class="relative fighter-bob-delayed" style="transform: scaleX(-1)">
|
||||
<!-- Circular glow behind fighter -->
|
||||
<div class="fighter-glow fighter-glow-pink" />
|
||||
<SpritePreview
|
||||
:key="`b-${featured.id}-${fighterB.avatarSeed}`"
|
||||
:seed="fighterB.avatarSeed || fighterB.name"
|
||||
:archetype="fighterB.archetype || 'standard'"
|
||||
:tier="fighterB.tier"
|
||||
:size="160"
|
||||
:size="120"
|
||||
pose="idle"
|
||||
glow-color="pink"
|
||||
class="relative z-10 fighter-bob-delayed
|
||||
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>
|
||||
@@ -557,6 +558,27 @@ onUnmounted(() => {
|
||||
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-bob {
|
||||
animation: fighterBob 2.5s ease-in-out infinite;
|
||||
|
||||
@@ -144,6 +144,7 @@ function handleGenerateLogin() {
|
||||
}
|
||||
|
||||
async function handleNsecBackupDone() {
|
||||
if (isLoading) return
|
||||
showNsecBackup.value = false
|
||||
try {
|
||||
const result = await login()
|
||||
@@ -206,7 +207,9 @@ function pickCharacter(id: string) {
|
||||
step.value = 'name-bot'
|
||||
}
|
||||
|
||||
function confirmName() {
|
||||
const isCheckingName = ref(false)
|
||||
|
||||
async function confirmName() {
|
||||
const name = botName.value.trim()
|
||||
if (!name || name.length < 2) {
|
||||
error.value = 'Name must be at least 2 characters.'
|
||||
@@ -217,6 +220,21 @@ function confirmName() {
|
||||
return
|
||||
}
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -269,6 +287,21 @@ async function confirmHumanName() {
|
||||
return
|
||||
}
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -598,10 +631,14 @@ function handleSignOut() {
|
||||
<button
|
||||
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
|
||||
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"
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -852,10 +889,14 @@ function handleSignOut() {
|
||||
<button
|
||||
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
|
||||
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"
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
/* Synthwave grid background */
|
||||
.synthwave-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(184, 61, 255, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, 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.025) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
.crt-overlay {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.12) 0px,
|
||||
rgba(0, 0, 0, 0.12) 1px,
|
||||
rgba(0, 0, 0, 0.04) 0px,
|
||||
rgba(0, 0, 0, 0.04) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
|
||||
@@ -10,6 +10,19 @@ import { rateLimit } from '../middleware/rate-limit.js'
|
||||
|
||||
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
|
||||
authRouter.post('/login', async (c) => {
|
||||
const body = await c.req.json()
|
||||
@@ -33,6 +46,9 @@ authRouter.post('/login', async (c) => {
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
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)
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -40,18 +56,28 @@ authRouter.post('/login', async (c) => {
|
||||
}
|
||||
|
||||
const bot = rows[0]
|
||||
|
||||
// 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/'
|
||||
const isHuman = bot.webhookUrl === 'http://human.local/'
|
||||
|
||||
return c.json({
|
||||
exists: true,
|
||||
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,
|
||||
customization: bot.customization ? JSON.parse(bot.customization) : null,
|
||||
satsWon: bot.satsWon ?? 0,
|
||||
satsWagered: bot.satsWagered ?? 0,
|
||||
hasWallet: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,6 +151,7 @@ botsRouter.get('/:name', async (c) => {
|
||||
// Get bot stats -- full account page data
|
||||
botsRouter.get('/:name/stats', async (c) => {
|
||||
const name = c.req.param('name')
|
||||
const ownerPubkey = c.req.query('pubkey')
|
||||
const botRows = await db.select({
|
||||
id: schema.bots.id,
|
||||
name: schema.bots.name,
|
||||
@@ -167,6 +168,11 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
customization: schema.bots.customization,
|
||||
botType: schema.bots.botType,
|
||||
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)
|
||||
|
||||
if (botRows.length === 0) {
|
||||
@@ -249,8 +255,19 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
eloRating: bot.eloRating,
|
||||
}, 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({
|
||||
...bot,
|
||||
...publicBot,
|
||||
tierName: TIER_NAMES[bot.tier] || 'BABY',
|
||||
tierColor: TIER_COLORS[bot.tier] || '#888',
|
||||
winRate,
|
||||
@@ -259,6 +276,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
totalBots: rankedBots.length,
|
||||
recentFights,
|
||||
achievements,
|
||||
...ownerFields,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user