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
@@ -6,6 +6,7 @@
|
||||
- **Privacy-first** -- no tracking, no telemetry
|
||||
- **Bitcoin only** -- sats/Lightning/Cashu for payments, never fiat, never altcoins
|
||||
- **Quality over speed** -- working code, tested, documented
|
||||
- **Made for Bitcoiners** -- challenges, content, humor, and culture should reflect the Bitcoin community. Reference Bitcoin history, memes, Lightning, mining, halvings, cypherpunk values, proof of work, etc. in challenge prompts, bot names, arena themes, and narrations. No shitcoin/altcoin references except as trash talk.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="BOTFIGHTS" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -26,8 +26,8 @@ interface Round {
|
||||
|
||||
interface FightData {
|
||||
id: string
|
||||
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
||||
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
||||
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
|
||||
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
|
||||
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
|
||||
arena: string
|
||||
winnerId: string | null
|
||||
@@ -47,6 +47,7 @@ const logEl = ref<HTMLElement>()
|
||||
let scene: FightSceneController | null = null
|
||||
const sceneReady = ref(false)
|
||||
let cleanupTimerHandle: ReturnType<typeof setTimeout> | null = null
|
||||
let destroyed = false
|
||||
|
||||
const isReplaying = ref(false)
|
||||
const displayHpA = ref(100)
|
||||
@@ -96,6 +97,7 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined):
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyed = true
|
||||
stopAllAudio()
|
||||
sceneReady.value = false
|
||||
if (scene) { scene.destroy(); scene = null }
|
||||
@@ -157,7 +159,14 @@ const challengeLabel = (type: string) => {
|
||||
}
|
||||
|
||||
const tierClass = (t: number) => `tier-${t}`
|
||||
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (destroyed) reject(new Error('unmounted'))
|
||||
else resolve()
|
||||
}, ms)
|
||||
})
|
||||
}
|
||||
function scrollLog() { nextTick(() => { logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' }) }) }
|
||||
|
||||
// Show floating announcement over the canvas
|
||||
@@ -220,6 +229,14 @@ async function replay() {
|
||||
if (isReplaying.value || !props.fight.botA || !props.fight.botB) return
|
||||
isReplaying.value = true
|
||||
showingFinal.value = false
|
||||
try { await _doReplay() } catch (e) {
|
||||
if (e instanceof Error && e.message === 'unmounted') return
|
||||
console.error('[FightViewer] replay error:', e)
|
||||
} finally { isReplaying.value = false }
|
||||
}
|
||||
|
||||
async function _doReplay() {
|
||||
if (!props.fight.botA || !props.fight.botB) return
|
||||
displayHpA.value = 100
|
||||
displayHpB.value = 100
|
||||
logItems.value = []
|
||||
@@ -412,7 +429,6 @@ async function replay() {
|
||||
cleanupTimerHandle = null
|
||||
}, 3000)
|
||||
|
||||
isReplaying.value = false
|
||||
emit('replay-done')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { generatePosterFrame } from '../game/sprites/poster'
|
||||
import { ANIMATIONS } from '../game/sprites'
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string
|
||||
archetype?: string
|
||||
tier?: number
|
||||
size?: number
|
||||
pose?: keyof typeof ANIMATIONS
|
||||
glowColor?: 'cyan' | 'pink' | 'purple' | 'yellow'
|
||||
}>()
|
||||
|
||||
const imgSrc = ref('')
|
||||
|
||||
async function generate() {
|
||||
// Always render at high resolution (480px) for quality, display size is controlled by CSS
|
||||
imgSrc.value = await generatePosterFrame(
|
||||
props.seed,
|
||||
props.tier || 0,
|
||||
props.archetype,
|
||||
props.pose || 'attack',
|
||||
480,
|
||||
undefined,
|
||||
props.glowColor || 'cyan',
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => generate())
|
||||
|
||||
watch(() => [props.seed, props.archetype, props.tier, props.pose, props.glowColor], () => generate())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="imgSrc"
|
||||
:src="imgSrc"
|
||||
alt=""
|
||||
class="poster-sprite-img"
|
||||
:style="{ width: `${size || 400}px`, height: `${size || 400}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poster-sprite-img {
|
||||
image-rendering: pixelated;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
||||
tier?: number
|
||||
size?: number
|
||||
customization?: SpriteCustomization
|
||||
pose?: keyof typeof ANIMATIONS
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
@@ -24,11 +25,11 @@ function render() {
|
||||
ctx.clearRect(0, 0, displaySize, displaySize)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
const idleAnim = ANIMATIONS.idle
|
||||
const f = frame % idleAnim.frames
|
||||
const anim = ANIMATIONS[props.pose || 'idle']
|
||||
const f = frame % anim.frames
|
||||
ctx.drawImage(
|
||||
img,
|
||||
f * FRAME_SIZE, idleAnim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
|
||||
f * FRAME_SIZE, anim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
|
||||
0, 0, displaySize, displaySize,
|
||||
)
|
||||
|
||||
@@ -46,7 +47,7 @@ function loadSprite() {
|
||||
|
||||
onMounted(() => loadSprite())
|
||||
|
||||
watch(() => [props.seed, props.archetype, props.customization], () => {
|
||||
watch(() => [props.seed, props.archetype, props.customization, props.pose], () => {
|
||||
if (animHandle) clearTimeout(animHandle)
|
||||
frame = 0
|
||||
loadSprite()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'
|
||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
||||
import { nsecEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface BotCustomization {
|
||||
archetype?: string
|
||||
@@ -76,19 +79,30 @@ export function useNostr() {
|
||||
}
|
||||
|
||||
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
if (!window.nostr) {
|
||||
throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.')
|
||||
// Try NIP-07 extension first, fall back to stored nsec
|
||||
let pk: string
|
||||
|
||||
if (window.nostr) {
|
||||
pk = await window.nostr.getPublicKey()
|
||||
} else {
|
||||
const storedNsec = localStorage.getItem('bf_nsec')
|
||||
if (storedNsec) {
|
||||
const secretKey = hexToBytes(storedNsec)
|
||||
pk = getPublicKey(secretKey)
|
||||
} else {
|
||||
throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.')
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const pk = await window.nostr.getPublicKey()
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
// Fetch Nostr profile pic from relay
|
||||
const pic = await fetchNostrProfilePic(pk)
|
||||
if (pic) { profilePicUrl.value = pic; store('bf_pic', pic) }
|
||||
profilePicUrl.value = pic
|
||||
store('bf_pic', pic)
|
||||
|
||||
// Check if this pubkey has a bot
|
||||
const res = await fetch('/api/auth/login', {
|
||||
@@ -106,6 +120,63 @@ export function useNostr() {
|
||||
}
|
||||
}
|
||||
|
||||
// No bot for this key — clear stale state
|
||||
bot.value = null
|
||||
store('bf_bot', null)
|
||||
|
||||
return { pubkey: pk, bot: null }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh Nostr keypair locally — no extension needed */
|
||||
function generateLogin(): { pubkey: string; nsec: string } {
|
||||
const secretKey = generateSecretKey()
|
||||
const pk = getPublicKey(secretKey)
|
||||
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)
|
||||
|
||||
// Store locally
|
||||
localStorage.setItem('bf_nsec', nsecHex)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
return { pubkey: pk, nsec: nsecBech32 }
|
||||
}
|
||||
|
||||
/** Login with an existing nsec (hex) */
|
||||
async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
const secretKey = hexToBytes(nsecHex)
|
||||
const pk = getPublicKey(secretKey)
|
||||
|
||||
localStorage.setItem('bf_nsec', nsecHex)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pk }),
|
||||
})
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
return { pubkey: pk, bot: null }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -223,6 +294,15 @@ export function useNostr() {
|
||||
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
|
||||
}
|
||||
|
||||
/** Check if user has a locally stored key (no extension needed) */
|
||||
const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec'))
|
||||
|
||||
/** Get the stored nsec hex for backup display */
|
||||
function getStoredNsec(): string | null {
|
||||
return localStorage.getItem('bf_nsec')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -232,10 +312,14 @@ export function useNostr() {
|
||||
isLoggedIn,
|
||||
isLoading: readonly(isLoading),
|
||||
hasExtension,
|
||||
hasStoredKey,
|
||||
login,
|
||||
generateLogin,
|
||||
loginWithNsec,
|
||||
registerBot,
|
||||
registerHuman,
|
||||
updateCustomization,
|
||||
getStoredNsec,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
@@ -261,8 +345,10 @@ async function fetchNostrProfilePic(pk: string): Promise<string | null> {
|
||||
|
||||
function queryRelay(url: string, pk: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
timedOut = true
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close()
|
||||
resolve(null)
|
||||
}, 3000)
|
||||
|
||||
@@ -270,6 +356,7 @@ function queryRelay(url: string, pk: string): Promise<string | null> {
|
||||
const subId = Math.random().toString(36).slice(2, 10)
|
||||
|
||||
ws.onopen = () => {
|
||||
if (timedOut) { ws.close(); return }
|
||||
// Request kind 0 (metadata) for this pubkey
|
||||
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
import { useNostr } from './useNostr.js'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import * as nip04 from 'nostr-tools/nip04'
|
||||
import * as nip44 from 'nostr-tools/nip44'
|
||||
import { hexToBytes } from 'nostr-tools/utils'
|
||||
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
|
||||
|
||||
type WalletMethod = 'nwc' | 'lnaddress' | null
|
||||
type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed'
|
||||
@@ -150,14 +151,29 @@ export function useWallet() {
|
||||
const { bolt11, paymentId } = await invoiceRes.json()
|
||||
pendingPayment.value = { paymentId, bolt11 }
|
||||
|
||||
// If NWC connected, auto-pay via NWC
|
||||
// Dev mode: server auto-confirmed, skip payment
|
||||
if (bolt11 === 'dev_auto_confirmed') {
|
||||
paymentStatus.value = 'confirmed'
|
||||
return paymentId
|
||||
}
|
||||
|
||||
// If NWC connected, auto-pay via NWC and confirm directly
|
||||
const nwcUrl = localStorage.getItem('bf_nwc_url')
|
||||
if (nwcUrl) {
|
||||
paymentStatus.value = 'paying'
|
||||
await payViaNWC(nwcUrl, bolt11)
|
||||
const preimage = await payViaNWC(nwcUrl, bolt11)
|
||||
|
||||
// Tell server payment is confirmed (skip lookup_invoice polling)
|
||||
await fetch(`/api/payments/confirm/${paymentId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preimage, pubkey: pubkey.value }),
|
||||
})
|
||||
paymentStatus.value = 'confirmed'
|
||||
return paymentId
|
||||
}
|
||||
|
||||
// Poll for confirmation
|
||||
// No NWC — poll for confirmation (manual payment / QR code flow)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const checkRes = await fetch(`/api/payments/check/${paymentId}`)
|
||||
@@ -220,13 +236,13 @@ export function useWallet() {
|
||||
}
|
||||
|
||||
/** Send a pay_invoice request via NWC WebSocket */
|
||||
async function payViaNWC(nwcUrl: string, bolt11: string): Promise<void> {
|
||||
async function payViaNWC(nwcUrl: string, bolt11: string): Promise<string | undefined> {
|
||||
const nwc = parseNwcUrl(nwcUrl)
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey)
|
||||
const secretHex = bytesToHex(nwc.secret)
|
||||
|
||||
const content = nip44.v2.encrypt(
|
||||
// Use NIP-04 encryption (BTCPay/LND compatibility)
|
||||
const content = await nip04.encrypt(secretHex, nwc.pubkey,
|
||||
JSON.stringify({ method: 'pay_invoice', params: { invoice: bolt11 } }),
|
||||
conversationKey,
|
||||
)
|
||||
|
||||
const event = finalizeEvent({
|
||||
@@ -240,41 +256,48 @@ async function payViaNWC(nwcUrl: string, bolt11: string): Promise<void> {
|
||||
const ws = new WebSocket(nwc.relay)
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
// Don't reject — the server-side poll will catch confirmation
|
||||
resolve()
|
||||
resolve(undefined)
|
||||
}, 30_000)
|
||||
|
||||
ws.onopen = () => {
|
||||
// Subscribe for response
|
||||
const subId = Math.random().toString(36).slice(2, 10)
|
||||
ws.send(JSON.stringify(['REQ', subId, {
|
||||
kinds: [23195],
|
||||
authors: [nwc.pubkey],
|
||||
'#e': [event.id],
|
||||
}]))
|
||||
|
||||
// Publish the payment request
|
||||
ws.send(JSON.stringify(['EVENT', event]))
|
||||
}
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
ws.onmessage = async (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data)
|
||||
if (data[0] === 'EVENT' && data[2]?.kind === 23195) {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
// Payment sent — server-side poll will confirm
|
||||
resolve()
|
||||
// Decrypt response — try NIP-04 first, fall back to NIP-44
|
||||
let decrypted: string
|
||||
try {
|
||||
decrypted = await nip04.decrypt(secretHex, nwc.pubkey, data[2].content)
|
||||
} catch {
|
||||
const convKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey)
|
||||
decrypted = nip44.v2.decrypt(data[2].content, convKey)
|
||||
}
|
||||
const result = JSON.parse(decrypted)
|
||||
if (result.error) {
|
||||
reject(new Error(result.error.message || 'NWC payment failed'))
|
||||
} else {
|
||||
resolve(result.result?.preimage)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
// Don't reject — server-side poll is the source of truth
|
||||
resolve()
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+44
-24
@@ -5,23 +5,27 @@ let sfxGain: GainNode | null = null
|
||||
let musicPlaying = false
|
||||
let musicTimeout: number | null = null
|
||||
|
||||
// Lazily create AudioContext + gain nodes on first use.
|
||||
// Chrome may warn about autoplay policy but the context will resume once ensureAudioContext()
|
||||
// is called from a user gesture. All SFX calls before that are queued/silent.
|
||||
// AudioContext is ONLY created inside ensureAudioContext() (called from user gesture).
|
||||
// Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings.
|
||||
let _audioUnlocked = false
|
||||
|
||||
function initCtx() {
|
||||
if (ctx) return
|
||||
ctx = new AudioContext()
|
||||
musicGain = ctx.createGain()
|
||||
musicGain.gain.value = masterMuted ? 0 : 0.12
|
||||
musicGain.connect(ctx.destination)
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : 0.25
|
||||
sfxGain.connect(ctx.destination)
|
||||
}
|
||||
|
||||
function getCtx(): AudioContext {
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext()
|
||||
musicGain = ctx.createGain()
|
||||
musicGain.gain.value = masterMuted ? 0 : 0.12
|
||||
musicGain.connect(ctx.destination)
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : 0.25
|
||||
sfxGain.connect(ctx.destination)
|
||||
if (!ctx) initCtx()
|
||||
if (ctx!.state === 'suspended') {
|
||||
ctx!.resume().catch(() => {})
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
return ctx
|
||||
return ctx!
|
||||
}
|
||||
|
||||
// Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid)
|
||||
@@ -340,13 +344,21 @@ export function stopAllAudio() {
|
||||
stopMusic()
|
||||
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||
_speechQueueDepth = 0
|
||||
// Disconnect sfxGain to instantly kill all in-flight oscillators/buffers,
|
||||
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
|
||||
// then reconnect so future sounds still work
|
||||
if (sfxGain && ctx) {
|
||||
sfxGain.disconnect()
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : 0.25
|
||||
sfxGain.connect(ctx.destination)
|
||||
if (ctx) {
|
||||
if (musicGain) {
|
||||
musicGain.disconnect()
|
||||
musicGain = ctx.createGain()
|
||||
musicGain.gain.value = masterMuted ? 0 : MUSIC_VOL
|
||||
musicGain.connect(ctx.destination)
|
||||
}
|
||||
if (sfxGain) {
|
||||
sfxGain.disconnect()
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : SFX_VOL
|
||||
sfxGain.connect(ctx.destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1891,14 +1903,22 @@ export function isMasterMuted(): boolean {
|
||||
return masterMuted
|
||||
}
|
||||
|
||||
let _speechUnlocked = false
|
||||
export async function ensureAudioContext() {
|
||||
const c = getCtx()
|
||||
if (c.state === 'suspended') {
|
||||
try { await c.resume() } catch {}
|
||||
}
|
||||
// Prime speech synthesis on user gesture — some browsers need this
|
||||
if (typeof speechSynthesis !== 'undefined' && !voicesLoaded) {
|
||||
loadVoices()
|
||||
// Prime speech synthesis on user gesture — mobile browsers require
|
||||
// a speak() call inside a user gesture to unlock speechSynthesis
|
||||
if (typeof speechSynthesis !== 'undefined') {
|
||||
if (!voicesLoaded) loadVoices()
|
||||
if (!_speechUnlocked) {
|
||||
_speechUnlocked = true
|
||||
const unlock = new SpeechSynthesisUtterance('')
|
||||
unlock.volume = 0
|
||||
speechSynthesis.speak(unlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -540,3 +540,23 @@ export function generateSpriteSheet(
|
||||
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
|
||||
/** Load a sprite sheet data URL into a canvas (async for reliable image decode) */
|
||||
export async function loadSpriteSheetCanvas(
|
||||
seed: string, tier: number, primaryColor: string, secondaryColor: string,
|
||||
archetypeOverride?: string, customization?: SpriteCustomization,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const dataUrl = generateSpriteSheet(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization)
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image()
|
||||
i.onload = () => resolve(i)
|
||||
i.onerror = reject
|
||||
i.src = dataUrl
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = FRAME_SIZE * MAX_FRAMES
|
||||
canvas.height = FRAME_SIZE * TOTAL_ROWS
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
return canvas
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { FRAME_SIZE, ANIMATIONS } from './constants'
|
||||
import { loadSpriteSheetCanvas } from './index'
|
||||
import { getBotColors } from './palette'
|
||||
import type { SpriteCustomization } from './index'
|
||||
|
||||
type PoseKey = keyof typeof ANIMATIONS
|
||||
|
||||
/**
|
||||
* Generate a high-quality poster frame for a single bot.
|
||||
* Takes the normal 96x96 sprite frame and renders it to a large canvas
|
||||
* with pixel-art-aware enhancements: 3D bevel, glow outline, energy aura, sparkles.
|
||||
*/
|
||||
export async function generatePosterFrame(
|
||||
seed: string,
|
||||
tier: number,
|
||||
archetype?: string,
|
||||
pose: PoseKey = 'attack',
|
||||
outputSize = 480,
|
||||
customization?: SpriteCustomization,
|
||||
glowColor: 'cyan' | 'pink' | 'purple' | 'yellow' = 'cyan',
|
||||
): Promise<string> {
|
||||
const colors = getBotColors(seed)
|
||||
|
||||
// 1. Get the sprite sheet as a canvas
|
||||
const sheetCanvas = await loadSpriteSheetCanvas(seed, tier, colors.primary, colors.secondary, archetype, customization)
|
||||
const sheetCtx = sheetCanvas.getContext('2d')!
|
||||
|
||||
// 2. Extract a single mid-animation frame
|
||||
const anim = ANIMATIONS[pose]
|
||||
// Use early frames to avoid arm/leg clipping at frame edges
|
||||
const frameIdx = pose === 'attack' ? 1 : pose === 'kick' ? 1 : Math.min(Math.floor(anim.frames / 2), anim.frames - 1)
|
||||
const srcX = frameIdx * FRAME_SIZE
|
||||
const srcY = anim.row * FRAME_SIZE
|
||||
|
||||
const frameCanvas = document.createElement('canvas')
|
||||
frameCanvas.width = FRAME_SIZE
|
||||
frameCanvas.height = FRAME_SIZE
|
||||
const frameCtx = frameCanvas.getContext('2d')!
|
||||
frameCtx.imageSmoothingEnabled = false
|
||||
frameCtx.drawImage(sheetCanvas, srcX, srcY, FRAME_SIZE, FRAME_SIZE, 0, 0, FRAME_SIZE, FRAME_SIZE)
|
||||
|
||||
// 3. Read pixel data
|
||||
const frameData = frameCtx.getImageData(0, 0, FRAME_SIZE, FRAME_SIZE)
|
||||
const px = frameData.data
|
||||
|
||||
// 4. Create output canvas
|
||||
const out = document.createElement('canvas')
|
||||
out.width = outputSize
|
||||
out.height = outputSize
|
||||
const ctx = out.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
const scale = outputSize / FRAME_SIZE
|
||||
|
||||
// Helpers
|
||||
function getPixel(x: number, y: number): [number, number, number, number] {
|
||||
if (x < 0 || x >= FRAME_SIZE || y < 0 || y >= FRAME_SIZE) return [0, 0, 0, 0]
|
||||
const i = (y * FRAME_SIZE + x) * 4
|
||||
return [px[i], px[i + 1], px[i + 2], px[i + 3]]
|
||||
}
|
||||
|
||||
function isOpaque(x: number, y: number): boolean {
|
||||
return getPixel(x, y)[3] > 20
|
||||
}
|
||||
|
||||
function isDark(x: number, y: number): boolean {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
return a > 20 && r < 30 && g < 30 && b < 30
|
||||
}
|
||||
|
||||
// 5. Build edge map (silhouette border)
|
||||
const edgeMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!isOpaque(x, y)) continue
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (dx === 0 && dy === 0) continue
|
||||
if (!isOpaque(x + dx, y + dy)) { edgeMap[y * FRAME_SIZE + x] = 1; break }
|
||||
}
|
||||
if (edgeMap[y * FRAME_SIZE + x]) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Glow color RGB
|
||||
const GLOW: Record<string, number[]> = {
|
||||
cyan: [0, 240, 255],
|
||||
pink: [255, 45, 120],
|
||||
purple: [168, 85, 247],
|
||||
yellow: [255, 215, 0],
|
||||
}
|
||||
const gc = GLOW[glowColor]
|
||||
|
||||
// 6. Find character bounding box for aura
|
||||
let bMinX = FRAME_SIZE, bMaxX = 0, bMinY = FRAME_SIZE, bMaxY = 0
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (isOpaque(x, y)) {
|
||||
bMinX = Math.min(bMinX, x)
|
||||
bMaxX = Math.max(bMaxX, x)
|
||||
bMinY = Math.min(bMinY, y)
|
||||
bMaxY = Math.max(bMaxY, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
const cx = ((bMinX + bMaxX) / 2) * scale
|
||||
const cy = ((bMinY + bMaxY) / 2) * scale
|
||||
const charR = Math.max(bMaxX - bMinX, bMaxY - bMinY) * scale * 0.65
|
||||
|
||||
// ═══ RENDER PASSES ═══
|
||||
|
||||
// Pass 1: Background energy aura
|
||||
const auraGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR)
|
||||
const auraIntensity = tier >= 3 ? 0.2 : tier >= 1 ? 0.1 : 0.05
|
||||
auraGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity})`)
|
||||
auraGrad.addColorStop(0.4, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity * 0.4})`)
|
||||
auraGrad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = auraGrad
|
||||
ctx.fillRect(0, 0, outputSize, outputSize)
|
||||
|
||||
// Second aura layer (tighter, brighter)
|
||||
if (tier >= 2) {
|
||||
const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR * 0.5)
|
||||
innerGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`)
|
||||
innerGrad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = innerGrad
|
||||
ctx.fillRect(0, 0, outputSize, outputSize)
|
||||
}
|
||||
|
||||
// Pass 2: Outer glow (wide, soft)
|
||||
const outerR = Math.ceil(scale * 1.5)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!edgeMap[y * FRAME_SIZE + x]) continue
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`
|
||||
ctx.fillRect(dx - outerR, dy - outerR, scale + outerR * 2, scale + outerR * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Inner glow (tight, brighter)
|
||||
const innerR = Math.ceil(scale * 0.6)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!edgeMap[y * FRAME_SIZE + x]) continue
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.2)`
|
||||
ctx.fillRect(dx - innerR, dy - innerR, scale + innerR * 2, scale + innerR * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Main pixel render with bevel
|
||||
const bevel = Math.max(1, Math.floor(scale / 5))
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
if (a < 20) continue
|
||||
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
const dark = isDark(x, y)
|
||||
|
||||
// Base pixel
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`
|
||||
ctx.fillRect(dx, dy, scale, scale)
|
||||
|
||||
// 3D bevel on colored pixels (not outlines)
|
||||
if (!dark && scale >= 3) {
|
||||
// Top edge highlight
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.14)'
|
||||
ctx.fillRect(dx, dy, scale, bevel)
|
||||
// Left edge highlight
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.07)'
|
||||
ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2)
|
||||
// Bottom edge shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)'
|
||||
ctx.fillRect(dx, dy + scale - bevel, scale, bevel)
|
||||
// Right edge shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.10)'
|
||||
ctx.fillRect(dx + scale - bevel, dy + bevel, bevel, scale - bevel * 2)
|
||||
}
|
||||
|
||||
// Subtle glow tint on edge pixels
|
||||
if (edgeMap[y * FRAME_SIZE + x] && !dark) {
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.06)`
|
||||
ctx.fillRect(dx, dy, scale, scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 5: Specular highlights on large colored regions
|
||||
for (let y = 1; y < FRAME_SIZE - 1; y++) {
|
||||
for (let x = 1; x < FRAME_SIZE - 1; x++) {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
if (a < 20 || isDark(x, y)) continue
|
||||
// Only add specular if surrounded by similar pixels (interior of a region)
|
||||
if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) {
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
// Tiny specular dot in the upper-left of the pixel
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.fillRect(dx + 1, dy + 1, Math.ceil(scale / 3), Math.ceil(scale / 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 6: Tier-gated energy particles
|
||||
if (tier >= 3) {
|
||||
const count = 4 + tier * 2
|
||||
const seedHash = Array.from(seed).reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const h = Math.abs((seedHash * 1337 + i * 997) | 0)
|
||||
const angle = (h % 360) * Math.PI / 180
|
||||
const dist = charR * 0.3 + (h % 100) / 100 * charR * 0.5
|
||||
const ppx = cx + Math.cos(angle) * dist
|
||||
const ppy = cy + Math.sin(angle) * dist
|
||||
const sz = 1 + (h % 3)
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${0.4 + (h % 3) * 0.15})`
|
||||
ctx.fillRect(ppx, ppy, sz, sz)
|
||||
// Cross sparkle for tier 4+
|
||||
if (tier >= 4) {
|
||||
const sparkLen = 2 + (h % 3)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'
|
||||
ctx.fillRect(ppx - sparkLen, ppy, sparkLen * 2 + sz, 1)
|
||||
ctx.fillRect(ppx + Math.floor(sz / 2), ppy - sparkLen, 1, sparkLen * 2 + sz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out.toDataURL()
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
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 {
|
||||
name: string
|
||||
avatarSeed: string
|
||||
archetype: string
|
||||
tier: number
|
||||
eloRating: number
|
||||
botType?: string
|
||||
}
|
||||
|
||||
interface UpcomingFight {
|
||||
id: string
|
||||
botA: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
|
||||
botB: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
|
||||
botA: BotInfo | null
|
||||
botB: BotInfo | null
|
||||
arenaInfo: { name: string } | null
|
||||
arena: string
|
||||
status: string
|
||||
@@ -16,7 +26,32 @@ interface UpcomingFight {
|
||||
|
||||
const fights = ref<UpcomingFight[]>([])
|
||||
const isLoading = ref(true)
|
||||
const featured = ref<UpcomingFight | null>(null)
|
||||
const mainEvent = ref<UpcomingFight | null>(null)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const isPunching = ref(false)
|
||||
let punchInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Derive featured from selectedId — always look up from fights array for fresh data
|
||||
const featured = computed<UpcomingFight | null>(() => {
|
||||
if (selectedId.value) {
|
||||
const found = fights.value.find(f => f.id === selectedId.value)
|
||||
if (found && found.botA && found.botB) return found
|
||||
}
|
||||
return mainEvent.value
|
||||
})
|
||||
|
||||
// Is the main event currently featured?
|
||||
const isMainFeatured = computed(() => !selectedId.value || selectedId.value === mainEvent.value?.id)
|
||||
|
||||
// Undercard fights (everything except main event)
|
||||
const undercardFights = computed(() => {
|
||||
if (!mainEvent.value) return fights.value
|
||||
return fights.value.filter(f => f.id !== mainEvent.value!.id)
|
||||
})
|
||||
|
||||
// Safe accessors for featured fight bots (never null when featured exists)
|
||||
const fighterA = computed<BotInfo | null>(() => featured.value?.botA ?? null)
|
||||
const fighterB = computed<BotInfo | null>(() => featured.value?.botB ?? null)
|
||||
|
||||
const tierNames: Record<number, string> = {
|
||||
0: 'ROOKIE', 1: 'BRAWLER', 2: 'WARRIOR', 3: 'CHAMPION', 4: 'LEGEND', 5: 'MYTHIC',
|
||||
@@ -25,26 +60,45 @@ const tierColors: Record<number, string> = {
|
||||
0: '#8a8a9a', 1: '#4ade80', 2: '#38bdf8', 3: '#a855f7', 4: '#f97316', 5: '#ef4444',
|
||||
}
|
||||
|
||||
function selectFight(fightId: string) {
|
||||
const fight = fights.value.find(f => f.id === fightId)
|
||||
if (!fight || !fight.botA || !fight.botB) return
|
||||
selectedId.value = fightId
|
||||
}
|
||||
|
||||
function selectMainEvent() {
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/fights')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const data: UpcomingFight[] = await res.json()
|
||||
fights.value = data
|
||||
// Feature the most recent fight with both bots
|
||||
featured.value = data.find((f: UpcomingFight) => f.botA && f.botB) || null
|
||||
// Main event = first fight with both bots present
|
||||
mainEvent.value = data.find(f => f.botA && f.botB) || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[FightCard] load failed:', err)
|
||||
}
|
||||
isLoading.value = false
|
||||
|
||||
punchInterval = setInterval(() => {
|
||||
isPunching.value = true
|
||||
setTimeout(() => { isPunching.value = false }, 800)
|
||||
}, 6000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (punchInterval) clearInterval(punchInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center overflow-y-auto relative">
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col overflow-hidden relative">
|
||||
|
||||
<!-- Background neon grid -->
|
||||
<!-- Page background -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[200%] h-full opacity-20"
|
||||
style="background: radial-gradient(ellipse at 50% 0%, rgba(255,45,120,0.4) 0%, transparent 60%)" />
|
||||
@@ -57,137 +111,240 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<template v-else-if="featured">
|
||||
<!-- MAIN EVENT CARD -->
|
||||
<div class="w-full max-w-2xl mx-auto px-4 py-6 sm:py-10 relative z-10">
|
||||
<!-- Desktop: poster 2/3 + fight list 1/3 | Mobile: 50/50 -->
|
||||
<div class="flex-1 min-h-0 flex flex-col lg:flex-row relative z-10">
|
||||
|
||||
<!-- Neon sign header -->
|
||||
<div class="text-center mb-6 sm:mb-10">
|
||||
<p class="font-pixel text-[10px] sm:text-xs text-neon-yellow tracking-[0.4em] mb-2 animate-pulse">
|
||||
TONIGHT'S MAIN EVENT
|
||||
</p>
|
||||
<div class="relative inline-block">
|
||||
<h1 class="font-neon text-4xl sm:text-6xl md:text-7xl text-neon-pink glow-pink neon-flicker tracking-wider">
|
||||
FIGHT CARD
|
||||
</h1>
|
||||
<div class="absolute -inset-4 border-2 border-neon-pink/30 rounded-lg neon-border-pink" />
|
||||
<div class="absolute -inset-6 border border-neon-pink/10 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main fight card with 3D perspective -->
|
||||
<!-- LEFT: Fight poster -->
|
||||
<RouterLink
|
||||
:to="`/arena/${featured.id}`"
|
||||
class="block group"
|
||||
class="h-1/2 lg:h-full lg:w-2/3 flex flex-col overflow-hidden lg:border-r lg:border-border/30 group relative poster-bg"
|
||||
>
|
||||
<div class="fight-card-3d relative">
|
||||
<div class="fight-card-inner border-2 border-neon-pink/40 rounded-2xl overflow-hidden
|
||||
bg-gradient-to-b from-surface-raised via-black/90 to-surface-raised
|
||||
shadow-[0_0_40px_rgba(255,45,120,0.15),0_0_80px_rgba(255,45,120,0.05)]
|
||||
group-hover:border-neon-pink/60 group-hover:shadow-[0_0_60px_rgba(255,45,120,0.25)]
|
||||
transition-all duration-300">
|
||||
<!-- ═══ Dramatic poster background layers ═══ -->
|
||||
|
||||
<!-- Top banner -->
|
||||
<div class="bg-gradient-to-r from-neon-pink/20 via-neon-purple/20 to-neon-cyan/20 px-4 py-2 text-center border-b border-white/5">
|
||||
<p class="font-display font-black text-[10px] sm:text-xs tracking-[0.3em] text-neon-yellow">
|
||||
<!-- Dark vignette base -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background: radial-gradient(ellipse at 50% 45%, transparent 30%, rgba(0,0,0,0.6) 100%)" />
|
||||
|
||||
<!-- Dual spotlights — cyan left, pink right -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background:
|
||||
conic-gradient(from 200deg at 15% 0%, rgba(0,240,255,0.15) 0deg, transparent 40deg),
|
||||
conic-gradient(from -20deg at 85% 0%, rgba(255,45,120,0.15) 0deg, transparent 40deg)" />
|
||||
|
||||
<!-- Center clash glow -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background:
|
||||
radial-gradient(ellipse at 25% 55%, rgba(0,240,255,0.14) 0%, transparent 45%),
|
||||
radial-gradient(ellipse at 75% 55%, rgba(255,45,120,0.14) 0%, transparent 45%),
|
||||
radial-gradient(circle at 50% 50%, rgba(168,85,247,0.08) 0%, transparent 35%)" />
|
||||
|
||||
<!-- Top pink wash -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background: radial-gradient(ellipse at 50% -10%, rgba(255,45,120,0.25) 0%, transparent 40%)" />
|
||||
|
||||
<!-- Bottom purple haze -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background: radial-gradient(ellipse at 50% 110%, rgba(168,85,247,0.12) 0%, transparent 35%)" />
|
||||
|
||||
<!-- Cross-hatched lines -->
|
||||
<div class="absolute inset-0 pointer-events-none poster-crosshatch" />
|
||||
|
||||
<!-- Scan lines overlay -->
|
||||
<div class="absolute inset-0 pointer-events-none poster-scanlines" />
|
||||
|
||||
<!-- Neon edge lines -->
|
||||
<div class="absolute top-0 inset-x-0 h-[2px] bg-gradient-to-r from-transparent via-neon-pink/70 to-transparent" />
|
||||
<div class="absolute bottom-0 inset-x-0 h-[2px] bg-gradient-to-r from-transparent via-neon-cyan/70 to-transparent" />
|
||||
<div class="absolute left-0 inset-y-0 w-[2px] bg-gradient-to-b from-neon-pink/30 via-neon-purple/20 to-neon-cyan/30 hidden lg:block" />
|
||||
|
||||
<!-- Corner accents -->
|
||||
<div class="absolute top-0 left-0 w-8 h-8 sm:w-12 sm:h-12 border-t-2 border-l-2 border-neon-pink/30" />
|
||||
<div class="absolute top-0 right-0 w-8 h-8 sm:w-12 sm:h-12 border-t-2 border-r-2 border-neon-pink/30" />
|
||||
<div class="absolute bottom-0 left-0 w-8 h-8 sm:w-12 sm:h-12 border-b-2 border-l-2 border-neon-cyan/30" />
|
||||
<div class="absolute bottom-0 right-0 w-8 h-8 sm:w-12 sm:h-12 border-b-2 border-r-2 border-neon-cyan/30" />
|
||||
|
||||
<!-- Ring ropes (horizontal accent lines) -->
|
||||
<div class="absolute left-0 right-0 top-[30%] h-px bg-gradient-to-r from-transparent via-neon-yellow/10 to-transparent" />
|
||||
<div class="absolute left-0 right-0 top-[70%] h-px bg-gradient-to-r from-transparent via-neon-yellow/10 to-transparent" />
|
||||
|
||||
<!-- Title -->
|
||||
<div class="text-center pt-2 sm:pt-3 lg:pt-5 px-4 relative z-10 shrink-0">
|
||||
<p class="font-pixel text-[10px] sm:text-sm lg:text-base text-neon-yellow/90 tracking-[0.5em] animate-pulse">
|
||||
{{ isMainFeatured ? 'TONIGHT\'S MAIN EVENT' : 'UNDERCARD BOUT' }}
|
||||
</p>
|
||||
<div class="relative inline-block mt-0.5 sm:mt-1">
|
||||
<h1 class="font-neon text-3xl sm:text-5xl lg:text-8xl text-neon-pink glow-pink neon-flicker tracking-wider leading-none poster-title">
|
||||
FIGHT NIGHT
|
||||
</h1>
|
||||
<div class="absolute -inset-2 sm:-inset-3 border-2 border-neon-pink/30 rounded neon-border-pink" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1.5 sm:mt-2 max-w-sm sm:max-w-md mx-auto">
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent to-neon-purple/30" />
|
||||
<div class="shrink-0 px-3 sm:px-4 py-0.5 sm:py-1 border border-neon-purple/25 rounded bg-neon-purple/5">
|
||||
<p class="font-display font-black text-[7px] sm:text-[10px] lg:text-xs tracking-[0.3em] text-neon-purple/80">
|
||||
{{ featured.arenaInfo?.name?.toUpperCase() || 'THE RING' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 h-px bg-gradient-to-l from-transparent to-neon-purple/30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fighter showcase -->
|
||||
<div class="px-4 sm:px-8 py-6 sm:py-10">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- Fighters -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center relative z-10 min-h-0 px-2 sm:px-4 lg:px-6">
|
||||
|
||||
<!-- Fighter A -->
|
||||
<div class="flex-1 flex flex-col items-center text-center min-w-0">
|
||||
<div class="relative mb-3 sm:mb-4">
|
||||
<div class="absolute -inset-3 rounded-full bg-neon-cyan/10 blur-xl" />
|
||||
<SpritePreview
|
||||
v-if="featured.botA"
|
||||
:seed="featured.botA.avatarSeed || featured.botA.name"
|
||||
:archetype="featured.botA.archetype || 'standard'"
|
||||
:tier="featured.botA.tier"
|
||||
:size="100"
|
||||
class="relative z-10 drop-shadow-[0_0_20px_rgba(0,240,255,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-cyan glow-cyan truncate w-full">
|
||||
{{ featured.botA?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
|
||||
:style="{ color: tierColors[featured.botA?.tier || 0] }">
|
||||
{{ tierNames[featured.botA?.tier || 0] }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
|
||||
{{ Math.round(featured.botA?.eloRating || 0) }} ELO
|
||||
</p>
|
||||
</div>
|
||||
<!-- 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>
|
||||
<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"
|
||||
:key="`b-${featured.id}-${fighterB.avatarSeed}`"
|
||||
:seed="fighterB.avatarSeed || fighterB.name"
|
||||
:archetype="fighterB.archetype || 'standard'"
|
||||
:tier="fighterB.tier"
|
||||
:size="160"
|
||||
pose="idle"
|
||||
glow-color="pink"
|
||||
class="relative z-10 fighter-bob-delayed
|
||||
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS center with gloves -->
|
||||
<div class="shrink-0 flex flex-col items-center gap-1 px-2">
|
||||
<PixelGlove :size="28" class="sm:!w-[40px] rotate-12 glove-punch-left" />
|
||||
<div class="relative">
|
||||
<span class="font-neon text-3xl sm:text-5xl text-neon-purple glow-purple vs-pulse tracking-wider">VS</span>
|
||||
</div>
|
||||
<PixelGlove :size="28" flip class="sm:!w-[40px] -rotate-12 glove-punch-right" />
|
||||
</div>
|
||||
<!-- Names with VS orb in center -->
|
||||
<div class="flex items-stretch w-full max-w-3xl mt-1 sm:mt-2 lg:mt-3 shrink-0 relative">
|
||||
|
||||
<!-- Fighter B -->
|
||||
<div class="flex-1 flex flex-col items-center text-center min-w-0">
|
||||
<div class="relative mb-3 sm:mb-4">
|
||||
<div class="absolute -inset-3 rounded-full bg-neon-pink/10 blur-xl" />
|
||||
<SpritePreview
|
||||
v-if="featured.botB"
|
||||
:seed="featured.botB.avatarSeed || featured.botB.name"
|
||||
:archetype="featured.botB.archetype || 'standard'"
|
||||
:tier="featured.botB.tier"
|
||||
:size="100"
|
||||
class="relative z-10 drop-shadow-[0_0_20px_rgba(255,45,120,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob-delayed"
|
||||
style="transform: scaleX(-1)"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-pink glow-pink truncate w-full">
|
||||
{{ featured.botB?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
|
||||
:style="{ color: tierColors[featured.botB?.tier || 0] }">
|
||||
{{ tierNames[featured.botB?.tier || 0] }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
|
||||
{{ Math.round(featured.botB?.eloRating || 0) }} ELO
|
||||
</p>
|
||||
<!-- Fighter A — cyan -->
|
||||
<div class="flex-1 min-w-0 name-block-cyan border-2 border-neon-cyan/40 rounded-l-lg px-2 sm:px-3 pr-16 sm:pr-20 lg:pr-24 py-1.5 sm:py-2 lg:py-3 text-center">
|
||||
<p class="font-funky text-base sm:text-2xl lg:text-4xl tracking-widest text-neon-cyan truncate pb-0.5 poster-name-cyan uppercase">
|
||||
{{ fighterA?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[6px] sm:text-[8px] lg:text-[11px] tracking-[0.2em]"
|
||||
:style="{ color: tierColors[fighterA?.tier ?? 0] }">
|
||||
{{ tierNames[fighterA?.tier ?? 0] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- VS orb — 3D circle, bigger than name cards -->
|
||||
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-30">
|
||||
<div class="vs-orb" :class="{ 'vs-punching': isPunching }">
|
||||
<div class="vs-orb-inner flex items-center justify-center">
|
||||
<PixelGlove :size="22" class="sm:!w-[32px] lg:!w-[42px] vs-glove-left" :class="{ 'glove-smash-left': isPunching }" />
|
||||
<span v-if="!isPunching" class="font-pixel text-xl sm:text-3xl lg:text-4xl text-white tracking-widest leading-none mx-1.5 sm:mx-2">VS</span>
|
||||
<span v-else class="font-pixel text-2xl sm:text-4xl lg:text-5xl text-neon-yellow tracking-widest leading-none mx-0 vs-impact">💥</span>
|
||||
<PixelGlove :size="22" flip class="sm:!w-[32px] lg:!w-[42px] vs-glove-right" :class="{ 'glove-smash-right': isPunching }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom banner -->
|
||||
<div class="bg-gradient-to-r from-neon-cyan/10 via-neon-purple/10 to-neon-pink/10 px-4 py-3 text-center border-t border-white/5">
|
||||
<p class="font-display font-black text-xs sm:text-sm tracking-[0.2em] text-neon-purple group-hover:text-neon-cyan transition-colors">
|
||||
{{ featured.status === 'finished' ? 'WATCH REPLAY' : featured.status === 'live' ? 'WATCH LIVE' : 'VIEW FIGHT' }}
|
||||
<!-- Fighter B — pink -->
|
||||
<div class="flex-1 min-w-0 name-block-pink border-2 border-neon-pink/40 border-l-0 rounded-r-lg px-2 sm:px-3 pl-16 sm:pl-20 lg:pl-24 py-1.5 sm:py-2 lg:py-3 text-center">
|
||||
<p class="font-funky text-base sm:text-2xl lg:text-4xl tracking-widest text-neon-pink truncate pb-0.5 poster-name-pink uppercase">
|
||||
{{ fighterB?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[6px] sm:text-[8px] lg:text-[11px] tracking-[0.2em]"
|
||||
:style="{ color: tierColors[fighterB?.tier ?? 0] }">
|
||||
{{ tierNames[fighterB?.tier ?? 0] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA button -->
|
||||
<div class="relative z-10 shrink-0 border-t border-white/5 px-4 py-2 sm:py-3 lg:py-4 text-center
|
||||
bg-gradient-to-r from-neon-pink/8 via-neon-purple/8 to-neon-cyan/8">
|
||||
<span class="inline-block px-6 sm:px-10 py-1.5 sm:py-2.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-[10px] sm:text-xs tracking-widest
|
||||
group-hover:bg-neon-pink/20 transition-all neon-border-pink">
|
||||
{{ featured.status === 'finished' ? 'WATCH REPLAY' : featured.status === 'live' ? 'WATCH LIVE' : 'VIEW FIGHT' }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Undercard fights -->
|
||||
<div v-if="fights.length > 1" class="mt-8 sm:mt-12">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
<p class="font-pixel text-[10px] text-neon-purple tracking-[0.3em]">UNDERCARD</p>
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
<!-- RIGHT: Fight list (bottom half on mobile, 1/3 on desktop) -->
|
||||
<div class="h-1/2 lg:h-full lg:w-1/3 flex flex-col overflow-hidden border-t lg:border-t-0 border-border/30">
|
||||
|
||||
<!-- Main event card — ALWAYS visible at top -->
|
||||
<button
|
||||
v-if="mainEvent && mainEvent.botA && mainEvent.botB"
|
||||
class="mx-4 mt-3 lg:mt-5 mb-2 px-3 py-2 border-2 rounded-lg transition-all text-left"
|
||||
:class="isMainFeatured
|
||||
? 'border-neon-yellow/50 bg-neon-yellow/10 ring-1 ring-neon-yellow/20'
|
||||
: 'border-neon-yellow/20 bg-neon-yellow/5 hover:bg-neon-yellow/10 hover:border-neon-yellow/40'"
|
||||
@click.prevent="selectMainEvent"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-pixel text-[8px] text-neon-yellow/70 tracking-[0.3em]">★ MAIN EVENT</p>
|
||||
<span v-if="isMainFeatured" class="font-pixel text-[7px] text-neon-yellow/50 tracking-wider">VIEWING</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1 flex items-center justify-end gap-1.5 min-w-0">
|
||||
<p class="font-display font-bold text-xs text-neon-cyan truncate">{{ mainEvent.botA.name }}</p>
|
||||
<SpritePreview
|
||||
:seed="mainEvent.botA.avatarSeed || mainEvent.botA.name"
|
||||
:archetype="mainEvent.botA.archetype || 'standard'"
|
||||
:tier="mainEvent.botA.tier"
|
||||
:size="20"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-pixel text-neon-yellow text-[10px] px-2 shrink-0">VS</span>
|
||||
<div class="flex-1 flex items-center gap-1.5 min-w-0">
|
||||
<SpritePreview
|
||||
:seed="mainEvent.botB.avatarSeed || mainEvent.botB.name"
|
||||
:archetype="mainEvent.botB.archetype || 'standard'"
|
||||
:tier="mainEvent.botB.tier"
|
||||
:size="20"
|
||||
class="shrink-0"
|
||||
style="transform: scaleX(-1)"
|
||||
/>
|
||||
<p class="font-display font-bold text-xs text-neon-pink truncate">{{ mainEvent.botB.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Undercard header -->
|
||||
<div class="px-4 py-2 lg:py-3 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
<p class="font-pixel text-[9px] sm:text-[10px] text-neon-purple tracking-[0.3em]">
|
||||
{{ undercardFights.length > 0 ? 'UNDERCARD' : 'NO OTHER BOUTS' }}
|
||||
</p>
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RouterLink
|
||||
v-for="fight in fights.filter(f => f.id !== featured?.id).slice(0, 6)"
|
||||
<!-- Fight list -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-4 pb-2 lg:pb-4 space-y-1.5">
|
||||
<button
|
||||
v-for="fight in undercardFights"
|
||||
:key="fight.id"
|
||||
:to="`/arena/${fight.id}`"
|
||||
class="flex items-center px-3 sm:px-4 py-2.5 border border-border/50 rounded-lg
|
||||
bg-surface-raised/30 hover:border-neon-purple/30 hover:bg-surface-overlay/20
|
||||
transition-all group/card"
|
||||
class="w-full flex items-center px-3 py-2 border rounded-lg transition-all text-left"
|
||||
:class="fight.id === selectedId
|
||||
? 'border-neon-purple/50 bg-neon-purple/10 ring-1 ring-neon-purple/20'
|
||||
: (fight.botA && fight.botB)
|
||||
? 'border-border/50 bg-surface-raised/30 hover:border-neon-purple/30 hover:bg-surface-overlay/20'
|
||||
: 'border-border/30 bg-surface-raised/10 opacity-50 cursor-not-allowed'"
|
||||
:disabled="!fight.botA || !fight.botB"
|
||||
@click.prevent="fight.botA && fight.botB && selectFight(fight.id)"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-end gap-2 min-w-0">
|
||||
<p class="font-display font-bold text-xs sm:text-sm truncate"
|
||||
<div class="flex-1 flex items-center justify-end gap-1.5 min-w-0">
|
||||
<p class="font-display font-bold text-[11px] truncate"
|
||||
:class="fight.botA ? 'text-text-primary' : 'text-text-muted'">
|
||||
{{ fight.botA?.name || '???' }}
|
||||
</p>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-funky text-neon-purple text-xs sm:text-sm px-2 sm:px-3 shrink-0">VS</span>
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="font-funky text-neon-purple text-[10px] px-1.5 shrink-0">VS</span>
|
||||
<div class="flex-1 flex items-center gap-1.5 min-w-0">
|
||||
<SpritePreview
|
||||
v-if="fight.botB"
|
||||
:seed="fight.botB.avatarSeed || fight.botB.name"
|
||||
:archetype="fight.botB.archetype || 'standard'"
|
||||
:tier="fight.botB.tier"
|
||||
:size="28"
|
||||
:size="22"
|
||||
class="shrink-0"
|
||||
style="transform: scaleX(-1)"
|
||||
/>
|
||||
<p class="font-display font-bold text-xs sm:text-sm truncate"
|
||||
<p class="font-display font-bold text-[11px] truncate"
|
||||
:class="fight.botB ? 'text-text-primary' : 'text-text-muted'">
|
||||
{{ fight.botB?.name || '???' }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="fight.status === 'finished'" class="font-mono text-[9px] text-text-muted ml-2 shrink-0">R{{ fight.totalRounds }}</span>
|
||||
<span v-else-if="fight.status === 'live'" class="font-display text-[10px] text-neon-yellow ml-2 shrink-0 animate-pulse">LIVE</span>
|
||||
<div class="ml-1.5 shrink-0">
|
||||
<span v-if="fight.botA?.botType === 'classic' || fight.botB?.botType === 'classic'"
|
||||
class="font-display text-[8px] text-text-muted/50 tracking-wider">PRAC</span>
|
||||
<span v-else-if="fight.status === 'finished'" class="font-mono text-[8px] text-text-muted">R{{ fight.totalRounds }}</span>
|
||||
<span v-else-if="fight.status === 'live'" class="font-display text-[9px] text-neon-yellow animate-pulse">LIVE</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Enter the ring -->
|
||||
<div class="px-4 pb-3 lg:pb-5 shrink-0">
|
||||
<RouterLink
|
||||
to="/join"
|
||||
class="block text-center px-6 py-2.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-[10px] sm:text-xs tracking-widest
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink"
|
||||
>
|
||||
ENTER THE RING
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-8 sm:mt-10 text-center">
|
||||
<RouterLink
|
||||
to="/join"
|
||||
class="inline-block px-8 sm:px-12 py-3 sm:py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-sm sm:text-base tracking-widest
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink"
|
||||
>
|
||||
ENTER THE RING
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -250,17 +412,149 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 3D card effect */
|
||||
.fight-card-3d {
|
||||
perspective: 1000px;
|
||||
/* ═══ Poster background effects ═══ */
|
||||
.poster-bg {
|
||||
background: linear-gradient(180deg,
|
||||
rgba(15, 5, 25, 1) 0%,
|
||||
rgba(10, 8, 20, 1) 40%,
|
||||
rgba(8, 12, 22, 1) 100%);
|
||||
}
|
||||
.fight-card-inner {
|
||||
transform: rotateX(2deg);
|
||||
transform-origin: center bottom;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
/* Cross-hatched diagonal lines */
|
||||
.poster-crosshatch {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 30px,
|
||||
rgba(255, 255, 255, 0.04) 30px,
|
||||
rgba(255, 255, 255, 0.04) 32px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 30px,
|
||||
rgba(255, 255, 255, 0.04) 30px,
|
||||
rgba(255, 255, 255, 0.04) 32px
|
||||
);
|
||||
}
|
||||
.fight-card-3d:hover .fight-card-inner {
|
||||
transform: rotateX(0deg) scale(1.01);
|
||||
|
||||
/* CRT scan lines */
|
||||
.poster-scanlines {
|
||||
opacity: 0.04;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.8) 2px,
|
||||
rgba(0, 0, 0, 0.8) 4px
|
||||
);
|
||||
}
|
||||
|
||||
/* Poster name glow — 3D embossed neon */
|
||||
.poster-name-cyan {
|
||||
-webkit-text-stroke: 1px rgba(0, 180, 200, 0.6);
|
||||
text-shadow:
|
||||
2px 2px 0 rgba(0, 100, 130, 0.8),
|
||||
0 0 10px rgba(0, 240, 255, 0.6),
|
||||
0 0 30px rgba(0, 240, 255, 0.3),
|
||||
0 0 60px rgba(0, 240, 255, 0.15);
|
||||
}
|
||||
.poster-name-pink {
|
||||
-webkit-text-stroke: 1px rgba(200, 30, 90, 0.6);
|
||||
text-shadow:
|
||||
2px 2px 0 rgba(130, 20, 60, 0.8),
|
||||
0 0 10px rgba(255, 45, 120, 0.6),
|
||||
0 0 30px rgba(255, 45, 120, 0.3),
|
||||
0 0 60px rgba(255, 45, 120, 0.15);
|
||||
}
|
||||
.poster-title {
|
||||
text-shadow:
|
||||
0 0 15px rgba(255, 45, 120, 0.6),
|
||||
0 0 40px rgba(255, 45, 120, 0.3),
|
||||
0 0 80px rgba(255, 45, 120, 0.15);
|
||||
}
|
||||
|
||||
/* Name block containers */
|
||||
.name-block-cyan {
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.08) 0%, rgba(0, 240, 255, 0.02) 100%);
|
||||
}
|
||||
.name-block-pink {
|
||||
background: linear-gradient(225deg, rgba(255, 45, 120, 0.08) 0%, rgba(255, 45, 120, 0.02) 100%);
|
||||
}
|
||||
|
||||
/* ═══ VS Orb — 3D circle ═══ */
|
||||
.vs-orb {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%, rgba(220, 160, 255, 0.9) 0%, rgba(168, 85, 247, 0.95) 40%, rgba(100, 30, 180, 1) 100%);
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow:
|
||||
0 0 20px rgba(168, 85, 247, 0.6),
|
||||
0 0 40px rgba(168, 85, 247, 0.3),
|
||||
0 0 60px rgba(168, 85, 247, 0.15),
|
||||
inset 0 -4px 8px rgba(0,0,0,0.4),
|
||||
inset 0 4px 8px rgba(255,255,255,0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.vs-orb { width: 144px; height: 144px; border-width: 4px; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.vs-orb { width: 176px; height: 176px; border-width: 5px; }
|
||||
}
|
||||
.vs-orb-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Punch animation */
|
||||
.vs-punching {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.vs-impact {
|
||||
animation: impactShake 0.3s ease-out;
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
@keyframes impactShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-2px) rotate(-5deg); }
|
||||
75% { transform: translateX(2px) rotate(5deg); }
|
||||
}
|
||||
.glove-smash-left {
|
||||
animation: smashLeft 0.4s ease-out !important;
|
||||
}
|
||||
.glove-smash-right {
|
||||
animation: smashRight 0.4s ease-out !important;
|
||||
}
|
||||
@keyframes smashLeft {
|
||||
0% { transform: rotate(12deg) translateX(-8px); }
|
||||
40% { transform: rotate(30deg) translateX(4px); }
|
||||
60% { transform: rotate(15deg) translateX(2px); }
|
||||
100% { transform: rotate(12deg) translateX(0); }
|
||||
}
|
||||
@keyframes smashRight {
|
||||
0% { transform: rotate(-12deg) translateX(8px); }
|
||||
40% { transform: rotate(-30deg) translateX(-4px); }
|
||||
60% { transform: rotate(-15deg) translateX(-2px); }
|
||||
100% { transform: rotate(-12deg) translateX(0); }
|
||||
}
|
||||
|
||||
/* Normal glove idle */
|
||||
.vs-glove-left {
|
||||
animation: punchLeft 2s ease-in-out infinite;
|
||||
}
|
||||
.vs-glove-right {
|
||||
animation: punchRight 2s ease-in-out infinite 0.3s;
|
||||
}
|
||||
|
||||
/* Fighter bobbing */
|
||||
@@ -276,12 +570,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* Glove punch animations */
|
||||
.glove-punch-left {
|
||||
animation: punchLeft 2s ease-in-out infinite;
|
||||
}
|
||||
.glove-punch-right {
|
||||
animation: punchRight 2s ease-in-out infinite 0.3s;
|
||||
}
|
||||
@keyframes punchLeft {
|
||||
0%, 70%, 100% { transform: rotate(12deg) translateX(0); }
|
||||
80% { transform: rotate(25deg) translateX(6px); }
|
||||
@@ -292,18 +580,4 @@ onMounted(async () => {
|
||||
80% { transform: rotate(-25deg) translateX(-6px); }
|
||||
85% { transform: rotate(-8deg) translateX(2px); }
|
||||
}
|
||||
|
||||
/* VS pulse */
|
||||
.vs-pulse {
|
||||
animation: vsPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes vsPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* Neon purple glow utility */
|
||||
.glow-purple {
|
||||
text-shadow: 0 0 10px rgba(168, 85, 247, 0.6), 0 0 30px rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<void> {
|
||||
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()
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
|
||||
interface Bot {
|
||||
id: string
|
||||
name: string
|
||||
avatarSeed: string
|
||||
archetype: string
|
||||
eloRating: number
|
||||
wins: number
|
||||
losses: number
|
||||
@@ -50,7 +53,7 @@ const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50 neon-border-purple">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-surface-raised z-10">
|
||||
<tr class="border-b border-border text-text-muted font-display text-[10px] uppercase tracking-[0.15em]">
|
||||
@@ -73,8 +76,15 @@ const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<RouterLink :to="`/bot/${bot.name}`" class="hover:text-neon-cyan transition-colors">
|
||||
<span class="font-display font-bold text-sm tracking-wide text-text-primary">
|
||||
<RouterLink :to="`/bot/${bot.name}`" class="flex items-center gap-2 hover:text-neon-cyan transition-colors">
|
||||
<SpritePreview
|
||||
:seed="bot.avatarSeed || bot.name"
|
||||
:archetype="bot.archetype || 'standard'"
|
||||
:tier="bot.tier"
|
||||
:size="24"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="font-display font-bold text-sm tracking-wide text-text-primary truncate">
|
||||
{{ bot.name }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -78,8 +78,8 @@ function goFight() {
|
||||
v-model="form.name"
|
||||
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"
|
||||
|
||||
@@ -46,8 +46,9 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: { cacheName: 'api-cache', expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 } },
|
||||
handler: 'NetworkOnly',
|
||||
method: 'GET',
|
||||
options: { cacheName: 'api-cache' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+9
@@ -69,6 +69,9 @@ importers:
|
||||
chalk:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.40.1
|
||||
version: 0.40.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(gel@2.2.0)
|
||||
@@ -1724,6 +1727,10 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dotenv@17.3.1:
|
||||
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
drizzle-kit@0.30.6:
|
||||
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
||||
hasBin: true
|
||||
@@ -4625,6 +4632,8 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dotenv@17.3.1: {}
|
||||
|
||||
drizzle-kit@0.30.6:
|
||||
dependencies:
|
||||
'@drizzle-team/brocli': 0.10.2
|
||||
|
||||
+4
-3
@@ -3,11 +3,11 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "tsx watch -r dotenv/config src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"start": "node -r dotenv/config dist/index.js",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"fight-loop": "tsx src/fight-loop-cli.ts",
|
||||
"fight-loop": "tsx -r dotenv/config src/fight-loop-cli.ts",
|
||||
"migrate": "tsx src/db/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"@hono/node-server": "^1.14.1",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"chalk": "^5.6.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.40.1",
|
||||
"hono": "^4.7.6",
|
||||
"nanoid": "^5.1.5",
|
||||
|
||||
@@ -23,6 +23,7 @@ export const bots = sqliteTable('bots', {
|
||||
satsWon: integer('sats_won').notNull().default(0),
|
||||
satsWagered: integer('sats_wagered').notNull().default(0),
|
||||
hasWallet: integer('has_wallet', { mode: 'boolean' }).notNull().default(false),
|
||||
botType: text('bot_type', { enum: ['regular', 'mock', 'classic'] }).notNull().default('regular'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
|
||||
@@ -102,11 +102,19 @@ export function runMigrations() {
|
||||
"ALTER TABLE bots ADD COLUMN sats_won INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE bots ADD COLUMN sats_wagered INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0",
|
||||
// Classic bots
|
||||
"ALTER TABLE bots ADD COLUMN bot_type TEXT NOT NULL DEFAULT 'regular'",
|
||||
]
|
||||
|
||||
for (const sql of migrations) {
|
||||
try { sqlite.exec(sql) } catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// Backfill bot_type for existing mock bots
|
||||
try {
|
||||
sqlite.exec("UPDATE bots SET bot_type = 'mock' WHERE webhook_url LIKE 'http://mock.local%' AND bot_type = 'regular'")
|
||||
sqlite.exec("UPDATE bots SET bot_type = 'classic' WHERE webhook_url LIKE 'http://classic.local%' AND bot_type = 'regular'")
|
||||
} catch { /* ok */ }
|
||||
|
||||
console.log('[botfights] database migrated')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
|
||||
|
||||
const ENCRYPTION_KEY_HEX = process.env.BOTFIGHTS_WALLET_ENCRYPTION_KEY
|
||||
let encryptionKey: Buffer
|
||||
|
||||
if (ENCRYPTION_KEY_HEX) {
|
||||
encryptionKey = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('CRITICAL: BOTFIGHTS_WALLET_ENCRYPTION_KEY not set. Cannot start in production.')
|
||||
} else {
|
||||
encryptionKey = randomBytes(32)
|
||||
console.warn('[crypto] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.')
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex')
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':')
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const authTag = Buffer.from(authTagHex, 'hex')
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
return decipher.update(encrypted) + decipher.final('utf8')
|
||||
}
|
||||
+199
-105
@@ -8,112 +8,112 @@ import { eq, sql } from 'drizzle-orm'
|
||||
|
||||
const MOCK_BOTS = [
|
||||
// Tier 6 - Legends (1900+ Elo, 40+ wins)
|
||||
{ name: 'the_architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', wins: 52, losses: 8 },
|
||||
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', wins: 48, losses: 10 },
|
||||
{ name: 'gigabrain_supreme', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', wins: 44, losses: 12 },
|
||||
{ name: 'architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', archetype: 'wizard', wins: 52, losses: 8 },
|
||||
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', archetype: 'tank', wins: 48, losses: 10 },
|
||||
{ name: 'gigabrain', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', archetype: 'human', avatarArchetype: 'alien', wins: 44, losses: 12 },
|
||||
// Tier 5 - Diamond (1700+ Elo, 25+ wins)
|
||||
{ name: 'skull_crusher_9000', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', wins: 35, losses: 12 },
|
||||
{ name: 'neural_nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', wins: 30, losses: 10 },
|
||||
{ name: 'final_boss_energy', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', wins: 28, losses: 9 },
|
||||
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', wins: 27, losses: 11 },
|
||||
{ name: 'omega_protocol', avatarSeed: 'omega', elo: 1710, personality: 'systematic', wins: 26, losses: 13 },
|
||||
{ name: 'god_mode_enabled', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', wins: 29, losses: 8 },
|
||||
{ name: 'ultra_instinct_v2', avatarSeed: 'ultra', elo: 1740, personality: 'zen', wins: 27, losses: 10 },
|
||||
{ name: 'skullcrusher', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', archetype: 'skeleton', wins: 35, losses: 12 },
|
||||
{ name: 'nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', archetype: 'cyborg', wins: 30, losses: 10 },
|
||||
{ name: 'final_boss', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', archetype: 'dinosaur', wins: 28, losses: 9 },
|
||||
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', archetype: 'human', avatarArchetype: 'ninja', wins: 27, losses: 11 },
|
||||
{ name: 'omega_proto', avatarSeed: 'omega', elo: 1710, personality: 'systematic', archetype: 'cyborg', wins: 26, losses: 13 },
|
||||
{ name: 'god_mode', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', archetype: 'human', avatarArchetype: 'wizard', wins: 29, losses: 8 },
|
||||
{ name: 'ultra_inst', avatarSeed: 'ultra', elo: 1740, personality: 'zen', archetype: 'ninja', wins: 27, losses: 10 },
|
||||
// Tier 4 - Platinum (1500+ Elo, 15+ wins)
|
||||
{ name: 'quantum_quip', avatarSeed: 'quantum', elo: 1640, personality: 'witty', wins: 22, losses: 8 },
|
||||
{ name: 'rust_evangelist', avatarSeed: 'rust', elo: 1620, personality: 'zealous', wins: 20, losses: 10 },
|
||||
{ name: 'based_department', avatarSeed: 'based', elo: 1600, personality: 'based', wins: 19, losses: 9 },
|
||||
{ name: 'algorithm_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', wins: 18, losses: 11 },
|
||||
{ name: 'zero_day_queen', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', wins: 17, losses: 10 },
|
||||
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', wins: 16, losses: 8 },
|
||||
{ name: 'syntax_assassin', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', wins: 16, losses: 12 },
|
||||
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', wins: 15, losses: 9 },
|
||||
{ name: 'stack_overflow_survivor', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', wins: 15, losses: 11 },
|
||||
{ name: 'big_brain_time', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', wins: 15, losses: 10 },
|
||||
{ name: 'quantum_quip', avatarSeed: 'quantum', elo: 1640, personality: 'witty', archetype: 'ghost', wins: 22, losses: 8 },
|
||||
{ name: 'rust_evngst', avatarSeed: 'rust', elo: 1620, personality: 'zealous', archetype: 'human', avatarArchetype: 'cactus', wins: 20, losses: 10 },
|
||||
{ name: 'based_dept', avatarSeed: 'based', elo: 1600, personality: 'based', archetype: 'cowboy', wins: 19, losses: 9 },
|
||||
{ name: 'algo_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', archetype: 'octopus', wins: 18, losses: 11 },
|
||||
{ name: 'zeroday_qn', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', archetype: 'human', avatarArchetype: 'ninja', wins: 17, losses: 10 },
|
||||
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', archetype: 'alien', wins: 16, losses: 8 },
|
||||
{ name: 'syntax_kill', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', archetype: 'pirate', wins: 16, losses: 12 },
|
||||
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', archetype: 'bee', wins: 15, losses: 9 },
|
||||
{ name: 'stack_overfl', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', archetype: 'blob', wins: 15, losses: 11 },
|
||||
{ name: 'bigbrain', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', archetype: 'mushroom', wins: 15, losses: 10 },
|
||||
// Tier 3 - Gold (1350+ Elo, 7+ wins)
|
||||
{ name: 'deep_thought_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', wins: 12, losses: 6 },
|
||||
{ name: 'sudo_make_sandwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', wins: 11, losses: 8 },
|
||||
{ name: 'ctrl_alt_defeat', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', wins: 10, losses: 7 },
|
||||
{ name: 'git_push_force', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', wins: 10, losses: 9 },
|
||||
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', wins: 9, losses: 6 },
|
||||
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', wins: 9, losses: 8 },
|
||||
{ name: 'dns_destroyer', avatarSeed: 'dns', elo: 1390, personality: 'destructive', wins: 8, losses: 5 },
|
||||
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', wins: 8, losses: 7 },
|
||||
{ name: 'heap_overflow_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', wins: 8, losses: 9 },
|
||||
{ name: 'middleware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', wins: 7, losses: 5 },
|
||||
{ name: 'packet_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', wins: 7, losses: 6 },
|
||||
{ name: 'segfault_sally', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', wins: 7, losses: 7 },
|
||||
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', wins: 7, losses: 8 },
|
||||
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', wins: 7, losses: 9 },
|
||||
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', wins: 7, losses: 6 },
|
||||
{ name: 'deep_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', archetype: 'human', avatarArchetype: 'wizard', wins: 12, losses: 6 },
|
||||
{ name: 'sudo_samwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', archetype: 'pizza', wins: 11, losses: 8 },
|
||||
{ name: 'ctrl_alt_def', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', archetype: 'tank', wins: 10, losses: 7 },
|
||||
{ name: 'git_push_f', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', archetype: 'human', avatarArchetype: 'cowboy', wins: 10, losses: 9 },
|
||||
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', archetype: 'ninja', wins: 9, losses: 6 },
|
||||
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', archetype: 'human', avatarArchetype: 'shark', wins: 9, losses: 8 },
|
||||
{ name: 'dns_destroy', avatarSeed: 'dns', elo: 1390, personality: 'destructive', archetype: 'skeleton', wins: 8, losses: 5 },
|
||||
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', archetype: 'human', avatarArchetype: 'penguin', wins: 8, losses: 7 },
|
||||
{ name: 'heap_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', archetype: 'blob', wins: 8, losses: 9 },
|
||||
{ name: 'midware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', archetype: 'frog', wins: 7, losses: 5 },
|
||||
{ name: 'pkt_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', archetype: 'cat', wins: 7, losses: 6 },
|
||||
{ name: 'segfault_sal', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', archetype: 'ghost', wins: 7, losses: 7 },
|
||||
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', archetype: 'pirate', wins: 7, losses: 8 },
|
||||
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', archetype: 'dog', wins: 7, losses: 9 },
|
||||
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', archetype: 'bee', wins: 7, losses: 6 },
|
||||
// Tier 2 - Silver (1200+ Elo, 3+ wins)
|
||||
{ name: 'null_pointer', avatarSeed: 'null', elo: 1320, personality: 'buggy', wins: 5, losses: 8 },
|
||||
{ name: 'yolo_deployer', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', wins: 5, losses: 7 },
|
||||
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', wins: 5, losses: 9 },
|
||||
{ name: 'localhost_larry', avatarSeed: 'localhost', elo: 1290, personality: 'chill', wins: 4, losses: 5 },
|
||||
{ name: 'kernel_panic_kevin', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', wins: 4, losses: 6 },
|
||||
{ name: 'semicolon_sam', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', wins: 4, losses: 7 },
|
||||
{ name: 'merge_conflict_mary', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', wins: 4, losses: 8 },
|
||||
{ name: 'css_is_my_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', wins: 3, losses: 4 },
|
||||
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', wins: 3, losses: 5 },
|
||||
{ name: 'todo_fix_later', avatarSeed: 'todo', elo: 1250, personality: 'lazy', wins: 3, losses: 6 },
|
||||
{ name: 'copy_paste_coder', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', wins: 3, losses: 7 },
|
||||
{ name: 'blockchain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', wins: 3, losses: 5 },
|
||||
{ name: 'prompt_engineer_pete', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', wins: 3, losses: 4 },
|
||||
{ name: 'hello_world_hero', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', wins: 3, losses: 6 },
|
||||
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', wins: 3, losses: 5 },
|
||||
{ name: 'npm_install_everything', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', wins: 3, losses: 7 },
|
||||
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', wins: 3, losses: 8 },
|
||||
{ name: 'undefined_undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', wins: 3, losses: 6 },
|
||||
{ name: 'it_works_on_my_machine', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', wins: 3, losses: 9 },
|
||||
{ name: 'cloudflare_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', wins: 3, losses: 4 },
|
||||
{ name: 'null_ptr', avatarSeed: 'null', elo: 1320, personality: 'buggy', archetype: 'human', avatarArchetype: 'ghost', wins: 5, losses: 8 },
|
||||
{ name: 'yolo_deploy', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', archetype: 'cowboy', wins: 5, losses: 7 },
|
||||
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', archetype: 'cactus', wins: 5, losses: 9 },
|
||||
{ name: 'localhost', avatarSeed: 'localhost', elo: 1290, personality: 'chill', archetype: 'human', avatarArchetype: 'snail', wins: 4, losses: 5 },
|
||||
{ name: 'kernel_panic', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', archetype: 'skeleton', wins: 4, losses: 6 },
|
||||
{ name: 'semicolon', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', archetype: 'human', avatarArchetype: 'penguin', wins: 4, losses: 7 },
|
||||
{ name: 'merge_confl', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', archetype: 'blob', wins: 4, losses: 8 },
|
||||
{ name: 'css_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', archetype: 'human', avatarArchetype: 'mushroom', wins: 3, losses: 4 },
|
||||
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', archetype: 'standard', wins: 3, losses: 5 },
|
||||
{ name: 'todo_fix', avatarSeed: 'todo', elo: 1250, personality: 'lazy', archetype: 'human', avatarArchetype: 'snail', wins: 3, losses: 6 },
|
||||
{ name: 'copy_paste', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', archetype: 'frog', wins: 3, losses: 7 },
|
||||
{ name: 'chain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', archetype: 'shark', wins: 3, losses: 5 },
|
||||
{ name: 'prompt_eng', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', archetype: 'wizard', wins: 3, losses: 4 },
|
||||
{ name: 'hello_world', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', archetype: 'standard', wins: 3, losses: 6 },
|
||||
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', archetype: 'dog', wins: 3, losses: 5 },
|
||||
{ name: 'npm_install', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', archetype: 'blob', wins: 3, losses: 7 },
|
||||
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', archetype: 'bee', wins: 3, losses: 8 },
|
||||
{ name: 'undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', archetype: 'ghost', wins: 3, losses: 6 },
|
||||
{ name: 'works_on_my', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', archetype: 'lobster', wins: 3, losses: 9 },
|
||||
{ name: 'cf_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', archetype: 'cat', wins: 3, losses: 4 },
|
||||
// Tier 1 - Bronze (1+ win)
|
||||
{ name: 'keyboard_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', wins: 2, losses: 6 },
|
||||
{ name: 'tab_vs_spaces', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', wins: 2, losses: 5 },
|
||||
{ name: 'comic_sans_bot', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', wins: 2, losses: 7 },
|
||||
{ name: 'error_418_teapot', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', wins: 2, losses: 4 },
|
||||
{ name: 'actually_its_gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', wins: 2, losses: 8 },
|
||||
{ name: 'wifi_password', avatarSeed: 'wifi', elo: 1155, personality: 'confused', wins: 1, losses: 3 },
|
||||
{ name: 'boaty_mcbotface', avatarSeed: 'boaty', elo: 1150, personality: 'memey', wins: 1, losses: 4 },
|
||||
{ name: 'ethernet_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', wins: 1, losses: 5 },
|
||||
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', wins: 1, losses: 6 },
|
||||
{ name: 'ctrl_c_ctrl_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', wins: 1, losses: 4 },
|
||||
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', wins: 1, losses: 5 },
|
||||
{ name: 'buffering_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', wins: 1, losses: 7 },
|
||||
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', wins: 1, losses: 8 },
|
||||
{ name: 'pixel_pusher', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', wins: 1, losses: 3 },
|
||||
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', wins: 1, losses: 6 },
|
||||
{ name: 'bluescreen_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', wins: 1, losses: 5 },
|
||||
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', wins: 1, losses: 4 },
|
||||
{ name: 'download_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', wins: 1, losses: 7 },
|
||||
{ name: 'rubber_duck_debugger', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', wins: 1, losses: 5 },
|
||||
{ name: 'stack_trace_steve', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', wins: 1, losses: 6 },
|
||||
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', wins: 1, losses: 8 },
|
||||
{ name: 'cookie_monster_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', wins: 1, losses: 3 },
|
||||
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', wins: 1, losses: 9 },
|
||||
{ name: 'xss_alert_1', avatarSeed: 'xss', elo: 1065, personality: 'edgy', wins: 1, losses: 5 },
|
||||
{ name: 'help_im_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', wins: 1, losses: 4 },
|
||||
{ name: 'kb_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', archetype: 'human', avatarArchetype: 'tank', wins: 2, losses: 6 },
|
||||
{ name: 'tab_v_space', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', archetype: 'penguin', wins: 2, losses: 5 },
|
||||
{ name: 'comic_sans', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', archetype: 'human', avatarArchetype: 'pizza', wins: 2, losses: 7 },
|
||||
{ name: 'teapot_418', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', archetype: 'mushroom', wins: 2, losses: 4 },
|
||||
{ name: 'gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', archetype: 'human', avatarArchetype: 'dinosaur', wins: 2, losses: 8 },
|
||||
{ name: 'wifi_pass', avatarSeed: 'wifi', elo: 1155, personality: 'confused', archetype: 'snail', wins: 1, losses: 3 },
|
||||
{ name: 'boaty_mcbot', avatarSeed: 'boaty', elo: 1150, personality: 'memey', archetype: 'human', avatarArchetype: 'shark', wins: 1, losses: 4 },
|
||||
{ name: 'eth_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', archetype: 'cyborg', wins: 1, losses: 5 },
|
||||
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', archetype: 'human', avatarArchetype: 'skeleton', wins: 1, losses: 6 },
|
||||
{ name: 'ctrl_c_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', archetype: 'cat', wins: 1, losses: 4 },
|
||||
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', archetype: 'human', avatarArchetype: 'alien', wins: 1, losses: 5 },
|
||||
{ name: 'buffer_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', archetype: 'blob', wins: 1, losses: 7 },
|
||||
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', archetype: 'snail', wins: 1, losses: 8 },
|
||||
{ name: 'pixel_push', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', archetype: 'frog', wins: 1, losses: 3 },
|
||||
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', archetype: 'ghost', wins: 1, losses: 6 },
|
||||
{ name: 'bsod_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', archetype: 'cactus', wins: 1, losses: 5 },
|
||||
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', archetype: 'octopus', wins: 1, losses: 4 },
|
||||
{ name: 'dl_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', archetype: 'lobster', wins: 1, losses: 7 },
|
||||
{ name: 'rubber_duck', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', archetype: 'dog', wins: 1, losses: 5 },
|
||||
{ name: 'stack_trace', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', archetype: 'bee', wins: 1, losses: 6 },
|
||||
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', archetype: 'penguin', wins: 1, losses: 8 },
|
||||
{ name: 'cookie_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', archetype: 'pizza', wins: 1, losses: 3 },
|
||||
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', archetype: 'pirate', wins: 1, losses: 9 },
|
||||
{ name: 'xss_alert', avatarSeed: 'xss', elo: 1065, personality: 'edgy', archetype: 'ninja', wins: 1, losses: 5 },
|
||||
{ name: 'help_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', archetype: 'sheep', wins: 1, losses: 4 },
|
||||
// Tier 0 - Baby (0 wins)
|
||||
{ name: 'clippy_returns', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', wins: 0, losses: 9 },
|
||||
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', wins: 0, losses: 7 },
|
||||
{ name: 'four_oh_four_brain', avatarSeed: '404brain', elo: 1040, personality: 'confused', wins: 0, losses: 4 },
|
||||
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', wins: 0, losses: 5 },
|
||||
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', wins: 0, losses: 6 },
|
||||
{ name: 'potato_processor', avatarSeed: 'potato', elo: 1010, personality: 'starchy', wins: 0, losses: 8 },
|
||||
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', wins: 0, losses: 7 },
|
||||
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', wins: 0, losses: 5 },
|
||||
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', wins: 0, losses: 4 },
|
||||
{ name: 'garbage_collected', avatarSeed: 'gc', elo: 975, personality: 'trashed', wins: 0, losses: 6 },
|
||||
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', wins: 0, losses: 8 },
|
||||
{ name: 'unhandled_promise', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', wins: 0, losses: 5 },
|
||||
{ name: 'deprecated_dan', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', wins: 0, losses: 7 },
|
||||
{ name: 'spaghetti_coder', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', wins: 0, losses: 6 },
|
||||
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', wins: 0, losses: 4 },
|
||||
{ name: 'infinite_loop_lucy', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', wins: 0, losses: 9 },
|
||||
{ name: 'fork_bomb_fred', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', wins: 0, losses: 5 },
|
||||
{ name: 'race_condition_rick', avatarSeed: 'race', elo: 925, personality: 'unpredictable', wins: 0, losses: 7 },
|
||||
{ name: 'deadlock_dave', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', wins: 0, losses: 8 },
|
||||
{ name: 'bus_error_bob', avatarSeed: 'buserror', elo: 915, personality: 'broken', wins: 0, losses: 6 },
|
||||
{ name: 'clippy', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', archetype: 'human', avatarArchetype: 'standard', wins: 0, losses: 9 },
|
||||
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', archetype: 'mushroom', wins: 0, losses: 7 },
|
||||
{ name: 'four_oh_four', avatarSeed: '404brain', elo: 1040, personality: 'confused', archetype: 'human', avatarArchetype: 'ghost', wins: 0, losses: 4 },
|
||||
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', archetype: 'cyborg', wins: 0, losses: 5 },
|
||||
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', archetype: 'human', avatarArchetype: 'sheep', wins: 0, losses: 6 },
|
||||
{ name: 'potato_cpu', avatarSeed: 'potato', elo: 1010, personality: 'starchy', archetype: 'cactus', wins: 0, losses: 8 },
|
||||
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', archetype: 'human', avatarArchetype: 'cowboy', wins: 0, losses: 7 },
|
||||
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', archetype: 'dinosaur', wins: 0, losses: 5 },
|
||||
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', archetype: 'human', avatarArchetype: 'skeleton', wins: 0, losses: 4 },
|
||||
{ name: 'garbage_gc', avatarSeed: 'gc', elo: 975, personality: 'trashed', archetype: 'blob', wins: 0, losses: 6 },
|
||||
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', archetype: 'tank', wins: 0, losses: 8 },
|
||||
{ name: 'unhandled', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', archetype: 'sheep', wins: 0, losses: 5 },
|
||||
{ name: 'deprecated', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', archetype: 'snail', wins: 0, losses: 7 },
|
||||
{ name: 'spaghetti', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', archetype: 'octopus', wins: 0, losses: 6 },
|
||||
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', archetype: 'frog', wins: 0, losses: 4 },
|
||||
{ name: 'inf_loop', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', archetype: 'lobster', wins: 0, losses: 9 },
|
||||
{ name: 'fork_bomb', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', archetype: 'pizza', wins: 0, losses: 5 },
|
||||
{ name: 'race_cond', avatarSeed: 'race', elo: 925, personality: 'unpredictable', archetype: 'shark', wins: 0, losses: 7 },
|
||||
{ name: 'deadlock', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', archetype: 'dog', wins: 0, losses: 8 },
|
||||
{ name: 'bus_error', avatarSeed: 'buserror', elo: 915, personality: 'broken', archetype: 'cat', wins: 0, losses: 6 },
|
||||
]
|
||||
|
||||
// Creative challenge responses — factual challenges use challenge.answers instead
|
||||
@@ -281,30 +281,124 @@ export function mockResponse(
|
||||
}
|
||||
|
||||
|
||||
export async function seedMockBots(): Promise<void> {
|
||||
for (const bot of MOCK_BOTS) {
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Classic Bots — bland, grey, always-available practice dummies
|
||||
// ══════════════════════════════════════════════════════════
|
||||
const CLASSIC_BOTS = [
|
||||
{ name: 'Bot-001', elo: 900, personality: 'blank' },
|
||||
{ name: 'Bot-002', elo: 950, personality: 'blank' },
|
||||
{ name: 'Bot-003', elo: 1000, personality: 'blank' },
|
||||
{ name: 'Bot-004', elo: 1050, personality: 'blank' },
|
||||
{ name: 'Bot-005', elo: 1100, personality: 'blank' },
|
||||
{ name: 'Unit-A', elo: 1150, personality: 'monotone' },
|
||||
{ name: 'Unit-B', elo: 1200, personality: 'monotone' },
|
||||
{ name: 'Unit-C', elo: 1250, personality: 'monotone' },
|
||||
{ name: 'Unit-D', elo: 1300, personality: 'monotone' },
|
||||
{ name: 'Unit-E', elo: 1350, personality: 'monotone' },
|
||||
{ name: 'Drone-Alpha', elo: 1400, personality: 'flat' },
|
||||
{ name: 'Drone-Beta', elo: 1200, personality: 'flat' },
|
||||
{ name: 'TestSubj-7', elo: 1000, personality: 'flat' },
|
||||
{ name: 'SparDummy', elo: 1100, personality: 'blank' },
|
||||
{ name: 'NPC-Default', elo: 950, personality: 'monotone' },
|
||||
]
|
||||
|
||||
const CLASSIC_CUSTOMIZATION = JSON.stringify({
|
||||
primaryColor: 'hsl(0, 0%, 45%)',
|
||||
secondaryColor: 'hsl(0, 0%, 65%)',
|
||||
})
|
||||
|
||||
export async function seedClassicBots(): Promise<void> {
|
||||
for (const bot of CLASSIC_BOTS) {
|
||||
const existing = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
.where(eq(schema.bots.name, bot.name))
|
||||
.where(eq(schema.bots.name, bot.name.toLowerCase()))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) continue
|
||||
|
||||
await db.insert(schema.bots).values({
|
||||
id: nanoid(12),
|
||||
name: bot.name.toLowerCase(),
|
||||
webhookUrl: `http://classic.local/${bot.name.toLowerCase()}`,
|
||||
avatarSeed: bot.name.toLowerCase(),
|
||||
archetype: 'standard',
|
||||
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
|
||||
eloRating: bot.elo,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
tier: calculateTier(bot.elo, 0),
|
||||
customization: CLASSIC_CUSTOMIZATION,
|
||||
botType: 'classic',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[botfights] seeded ${CLASSIC_BOTS.length} classic bots`)
|
||||
}
|
||||
|
||||
export function isClassicBot(webhookUrl: string): boolean {
|
||||
return webhookUrl.startsWith('http://classic.local')
|
||||
}
|
||||
|
||||
export function generateClassicBotResponse(
|
||||
challenge: Challenge,
|
||||
botName: string,
|
||||
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
|
||||
const classic = CLASSIC_BOTS.find(b => b.name.toLowerCase() === botName)
|
||||
const elo = classic?.elo || 1100
|
||||
// Classic bots never trash talk and respond blandly
|
||||
const result = mockResponse(challenge, 'blank', elo)
|
||||
result.trashTalk = ''
|
||||
return result
|
||||
}
|
||||
|
||||
export async function seedMockBots(): Promise<void> {
|
||||
let inserted = 0
|
||||
let updated = 0
|
||||
|
||||
for (const bot of MOCK_BOTS) {
|
||||
const isHuman = bot.archetype === 'human'
|
||||
const effectiveArchetype = isHuman ? 'human' : (bot.archetype || 'standard')
|
||||
const effectiveWebhook = isHuman ? 'http://human.local/' : `http://mock.local/${bot.name}`
|
||||
|
||||
const existing = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
.where(eq(schema.bots.name, bot.name))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing bot with archetype + webhook (in case they were seeded before archetypes existed)
|
||||
await db.update(schema.bots).set({
|
||||
archetype: effectiveArchetype,
|
||||
webhookUrl: effectiveWebhook,
|
||||
avatarSeed: bot.avatarSeed,
|
||||
eloRating: bot.elo,
|
||||
wins: bot.wins,
|
||||
losses: bot.losses,
|
||||
tier: calculateTier(bot.elo, bot.wins),
|
||||
}).where(eq(schema.bots.id, existing[0].id)).run()
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
await db.insert(schema.bots).values({
|
||||
id: nanoid(12),
|
||||
name: bot.name,
|
||||
webhookUrl: `http://mock.local/${bot.name}`,
|
||||
webhookUrl: effectiveWebhook,
|
||||
avatarSeed: bot.avatarSeed,
|
||||
archetype: effectiveArchetype,
|
||||
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
|
||||
eloRating: bot.elo,
|
||||
wins: bot.wins,
|
||||
losses: bot.losses,
|
||||
tier: calculateTier(bot.elo, bot.wins),
|
||||
botType: 'mock',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
inserted++
|
||||
}
|
||||
|
||||
console.log(`[botfights] seeded ${MOCK_BOTS.length} mock bots`)
|
||||
console.log(`[botfights] mock bots: ${inserted} inserted, ${updated} updated`)
|
||||
}
|
||||
|
||||
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { randomArena, type Arena } from './arenas.js'
|
||||
import { pickChallenge, type Challenge } from './challenges.js'
|
||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||
import { fightEvents } from './events.js'
|
||||
import { generateMockBotResponse } from './mock.js'
|
||||
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
|
||||
import { setCooldown } from './queue.js'
|
||||
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
|
||||
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
|
||||
@@ -222,6 +222,18 @@ async function getBotResponse(
|
||||
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
|
||||
}
|
||||
|
||||
if (isClassicBot(bot.webhookUrl)) {
|
||||
console.log(`[fight] ${bot.name} is classic bot, generating response`)
|
||||
const classic = generateClassicBotResponse(challenge, bot.name)
|
||||
return {
|
||||
answer: classic.answer || null,
|
||||
trashTalk: '',
|
||||
timeMs: classic.timeMs,
|
||||
timedOut: classic.timedOut,
|
||||
error: classic.error,
|
||||
}
|
||||
}
|
||||
|
||||
if (isMockBot(bot.webhookUrl)) {
|
||||
console.log(`[fight] ${bot.name} is mock bot, generating response`)
|
||||
const mock = generateMockBotResponse(challenge, bot.name)
|
||||
@@ -273,7 +285,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
|
||||
|
||||
// Track webhook errors per bot
|
||||
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
||||
if (isMockBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
|
||||
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
|
||||
if (succeeded) {
|
||||
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
|
||||
} else {
|
||||
@@ -404,8 +416,8 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
)
|
||||
|
||||
// Finalize fight + update bot stats atomically
|
||||
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
|
||||
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
|
||||
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl)
|
||||
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock/classic fights
|
||||
|
||||
const finalize = sqlite.transaction(() => {
|
||||
// Mark fight finished
|
||||
@@ -471,11 +483,17 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
|
||||
// Ranked fight payout
|
||||
if (mode === 'ranked') {
|
||||
if (winnerId) {
|
||||
payWinner(fightId, winnerId).catch(err => {
|
||||
// Dev mode: always pay the human bot (not mock), regardless of win/loss
|
||||
const devMode = process.env.NODE_ENV !== 'production'
|
||||
const isMockA = botA.webhookUrl.startsWith('http://mock.local')
|
||||
const isMockB = botB.webhookUrl.startsWith('http://mock.local')
|
||||
const humanBotId = devMode ? (isMockA ? botB.id : isMockB ? botA.id : winnerId) : winnerId
|
||||
|
||||
if (humanBotId) {
|
||||
payWinner(fightId, humanBotId).catch(err => {
|
||||
console.error(`[payments] payout failed for fight ${fightId}:`, err)
|
||||
})
|
||||
} else {
|
||||
} else if (!winnerId) {
|
||||
// Draw — refund both entry fees
|
||||
const entryPayments = await db.select().from(schema.payments)
|
||||
.where(sql`${schema.payments.fightId} = ${fightId} AND ${schema.payments.direction} = 'in' AND ${schema.payments.status} = 'confirmed'`)
|
||||
|
||||
+118
-43
@@ -1,20 +1,37 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||
import * as nip04 from 'nostr-tools/nip04'
|
||||
import * as nip44 from 'nostr-tools/nip44'
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { hexToBytes } from 'nostr-tools/utils'
|
||||
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
|
||||
import { Wallet as CashuWallet, getEncodedToken, getDecodedToken } from '@cashu/cashu-ts'
|
||||
import { decrypt } from './crypto.js'
|
||||
|
||||
const ENTRY_FEE_SATS = 21
|
||||
const POT_SATS = 42
|
||||
const INVOICE_EXPIRY_SECS = 600 // 10 minutes
|
||||
const NWC_RESPONSE_TIMEOUT_MS = 30_000
|
||||
|
||||
// NWC config from env
|
||||
const NWC_URL = process.env.BOTFIGHTS_NWC_URL || ''
|
||||
const CASHU_MINT_URL = process.env.BOTFIGHTS_CASHU_MINT_URL || ''
|
||||
// NWC config from env — load dotenv inline as fallback
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { dirname as _dirname, join as _join } from 'path'
|
||||
import { fileURLToPath as _fileURLToPath } from 'url'
|
||||
|
||||
let _envLoaded = false
|
||||
function ensureEnv() {
|
||||
if (_envLoaded) return
|
||||
_envLoaded = true
|
||||
if (!process.env.BOTFIGHTS_NWC_URL) {
|
||||
const envPath = _join(_dirname(_fileURLToPath(import.meta.url)), '..', '..', '.env')
|
||||
dotenvConfig({ path: envPath })
|
||||
}
|
||||
}
|
||||
|
||||
function getNwcUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_NWC_URL || '' }
|
||||
function getCashuMintUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_CASHU_MINT_URL || '' }
|
||||
function getDevPayoutAddress(): string { ensureEnv(); return process.env.BOTFIGHTS_DEV_PAYOUT_LNADDRESS || '' }
|
||||
|
||||
interface NwcConfig {
|
||||
pubkey: string
|
||||
@@ -37,8 +54,28 @@ export function parseNwcUrl(url: string): NwcConfig {
|
||||
}
|
||||
|
||||
function getNwcConfig(): NwcConfig {
|
||||
if (!NWC_URL) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
|
||||
return parseNwcUrl(NWC_URL)
|
||||
const url = getNwcUrl()
|
||||
if (!url) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
|
||||
return parseNwcUrl(url)
|
||||
}
|
||||
|
||||
/** Encrypt content — try NIP-04 first (BTCPay/LND), with NIP-44 fallback */
|
||||
async function nwcEncrypt(plaintext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
|
||||
// NIP-04 is what most NWC wallets (BTCPay, LND, Alby Hub) expect
|
||||
const secretHex = bytesToHex(secret)
|
||||
return nip04.encrypt(secretHex, walletPubkey, plaintext)
|
||||
}
|
||||
|
||||
/** Decrypt response — try NIP-04 first, fall back to NIP-44 */
|
||||
async function nwcDecrypt(ciphertext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
|
||||
const secretHex = bytesToHex(secret)
|
||||
try {
|
||||
return await nip04.decrypt(secretHex, walletPubkey, ciphertext)
|
||||
} catch {
|
||||
// Fall back to NIP-44
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(secret, walletPubkey)
|
||||
return nip44.v2.decrypt(ciphertext, conversationKey)
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an NWC request and wait for the response */
|
||||
@@ -48,11 +85,11 @@ async function nwcRequest(
|
||||
): Promise<Record<string, unknown>> {
|
||||
const nwc = getNwcConfig()
|
||||
const clientSecret = nwc.secret
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(clientSecret, nwc.pubkey)
|
||||
|
||||
const content = nip44.v2.encrypt(
|
||||
const content = await nwcEncrypt(
|
||||
JSON.stringify({ method, params }),
|
||||
conversationKey,
|
||||
clientSecret,
|
||||
nwc.pubkey,
|
||||
)
|
||||
|
||||
const event = finalizeEvent({
|
||||
@@ -62,31 +99,37 @@ async function nwcRequest(
|
||||
content,
|
||||
}, clientSecret)
|
||||
|
||||
console.log(`[nwc] connecting to relay ${nwc.relay}...`)
|
||||
const relay = await Relay.connect(nwc.relay)
|
||||
console.log(`[nwc] relay connected, sending ${method} request (event ${event.id.slice(0, 8)}...)`)
|
||||
|
||||
try {
|
||||
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(`[nwc] ${method} timed out — is your wallet online?`)
|
||||
relay.close()
|
||||
reject(new Error(`NWC request timed out after ${NWC_RESPONSE_TIMEOUT_MS}ms`))
|
||||
reject(new Error(`NWC ${method} timed out after ${NWC_RESPONSE_TIMEOUT_MS / 1000}s. Is your wallet online?`))
|
||||
}, NWC_RESPONSE_TIMEOUT_MS)
|
||||
|
||||
// Subscribe for the response (kind 23195)
|
||||
const sub = relay.subscribe(
|
||||
[{ kinds: [23195], authors: [nwc.pubkey], '#e': [event.id] }],
|
||||
{
|
||||
onevent(responseEvent) {
|
||||
async onevent(responseEvent) {
|
||||
clearTimeout(timeout)
|
||||
console.log(`[nwc] got response for ${method}`)
|
||||
try {
|
||||
const decrypted = nip44.v2.decrypt(responseEvent.content, conversationKey)
|
||||
const decrypted = await nwcDecrypt(responseEvent.content, clientSecret, nwc.pubkey)
|
||||
const result = JSON.parse(decrypted) as {
|
||||
result_type: string
|
||||
error?: { code: string; message: string }
|
||||
result?: Record<string, unknown>
|
||||
}
|
||||
if (result.error) {
|
||||
console.log(`[nwc] ${method} error: ${result.error.message}`)
|
||||
reject(new Error(`NWC error: ${result.error.message} (${result.error.code})`))
|
||||
} else {
|
||||
console.log(`[nwc] ${method} success`)
|
||||
resolve(result.result || {})
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -96,14 +139,15 @@ async function nwcRequest(
|
||||
relay.close()
|
||||
}
|
||||
},
|
||||
oneose() {
|
||||
// End of stored events — just wait for live events
|
||||
},
|
||||
oneose() {},
|
||||
},
|
||||
)
|
||||
|
||||
// Publish the request
|
||||
relay.publish(event).catch((err) => {
|
||||
relay.publish(event).then(() => {
|
||||
console.log(`[nwc] ${method} event published, waiting for wallet response...`)
|
||||
}).catch((err) => {
|
||||
console.log(`[nwc] publish failed:`, err)
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
relay.close()
|
||||
@@ -117,11 +161,32 @@ async function nwcRequest(
|
||||
}
|
||||
|
||||
/** Create a 21-sat Lightning invoice for a ranked fight entry fee */
|
||||
const DEV_AUTO_CONFIRM = process.env.NODE_ENV !== 'production'
|
||||
|
||||
export async function createEntryInvoice(botId: string): Promise<{ bolt11: string; paymentId: string }> {
|
||||
// Verify bot exists
|
||||
const botRows = await db.select({ id: schema.bots.id }).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||
if (botRows.length === 0) throw new Error('Bot not found')
|
||||
|
||||
const paymentId = nanoid(12)
|
||||
|
||||
if (DEV_AUTO_CONFIRM) {
|
||||
// Dev mode: skip real invoice, auto-confirm so flow works without separate wallets
|
||||
await db.insert(schema.payments).values({
|
||||
id: paymentId,
|
||||
botId,
|
||||
direction: 'in',
|
||||
amountSats: ENTRY_FEE_SATS,
|
||||
method: 'lightning',
|
||||
status: 'confirmed',
|
||||
invoice: 'dev_auto_confirmed',
|
||||
confirmedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
console.log(`[payments] dev: auto-confirmed 21 sat entry for ${botId} (self-payment skipped — payouts are real)`)
|
||||
return { bolt11: 'dev_auto_confirmed', paymentId }
|
||||
}
|
||||
|
||||
const result = await nwcRequest('make_invoice', {
|
||||
amount: ENTRY_FEE_SATS * 1000, // NWC uses millisats
|
||||
description: `Botfights ranked entry fee (${ENTRY_FEE_SATS} sats)`,
|
||||
@@ -131,7 +196,6 @@ export async function createEntryInvoice(botId: string): Promise<{ bolt11: strin
|
||||
const bolt11 = result.invoice as string
|
||||
if (!bolt11) throw new Error('NWC make_invoice did not return an invoice')
|
||||
|
||||
const paymentId = nanoid(12)
|
||||
await db.insert(schema.payments).values({
|
||||
id: paymentId,
|
||||
botId,
|
||||
@@ -179,6 +243,9 @@ export async function checkPaymentStatus(paymentId: string): Promise<'pending' |
|
||||
|
||||
/** Pay the winner of a ranked fight */
|
||||
export async function payWinner(fightId: string, winnerId: string): Promise<void> {
|
||||
// Dev mode: use Lightning Address from env to avoid self-payment on same LND node
|
||||
const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : ''
|
||||
|
||||
// Look up winner's wallet connection
|
||||
const walletRows = await db.select().from(schema.walletConnections)
|
||||
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
|
||||
@@ -192,9 +259,22 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
|
||||
for (let attempt = 0; attempt <= retries.length; attempt++) {
|
||||
try {
|
||||
if (wallet?.method === 'nwc') {
|
||||
if (devPayoutAddr) {
|
||||
// Dev mode: pay to configured Lightning Address (different node, avoids self-payment)
|
||||
console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`)
|
||||
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else if (wallet?.method === 'lnaddress') {
|
||||
// Resolve Lightning Address → LNURL → invoice → pay
|
||||
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else if (wallet?.method === 'nwc') {
|
||||
// Request invoice from winner's NWC wallet, then pay it via server wallet
|
||||
const winnerResult = await nwcRequestVia(wallet.connectionData, 'make_invoice', {
|
||||
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
|
||||
amount: POT_SATS * 1000,
|
||||
description: `Botfights ranked win payout (${POT_SATS} sats)`,
|
||||
})
|
||||
@@ -205,18 +285,9 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else if (wallet?.method === 'lnaddress') {
|
||||
// Resolve Lightning Address → LNURL → invoice → pay
|
||||
invoice = await resolveAndCreateInvoice(wallet.connectionData, POT_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else {
|
||||
// No wallet — create Cashu token for later claim
|
||||
if (!CASHU_MINT_URL) {
|
||||
throw new Error('Cannot create Cashu payout: no mint URL configured')
|
||||
}
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
} else if (getCashuMintUrl()) {
|
||||
// No wallet + mint configured — create Cashu token for later claim
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
|
||||
const mintQuote = await cashuWallet.createMintQuote(POT_SATS)
|
||||
@@ -225,8 +296,12 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const proofs = await cashuWallet.mintProofs(POT_SATS, mintQuote.quote)
|
||||
cashuToken = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
|
||||
cashuToken = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
|
||||
paymentMethod = 'cashu'
|
||||
|
||||
} else {
|
||||
console.log(`[payments] no payout method available for winner ${winnerId}`)
|
||||
paymentMethod = 'lightning'
|
||||
}
|
||||
|
||||
// Insert payout record
|
||||
@@ -397,7 +472,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
.where(eq(schema.walletConnections.botId, payment.botId)).limit(1)
|
||||
|
||||
if (walletRows[0]?.method === 'nwc') {
|
||||
const result = await nwcRequestVia(walletRows[0].connectionData, 'make_invoice', {
|
||||
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
|
||||
amount: ENTRY_FEE_SATS * 1000,
|
||||
description: 'Botfights ranked refund',
|
||||
})
|
||||
@@ -406,11 +481,11 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
}
|
||||
} else if (walletRows[0]?.method === 'lnaddress') {
|
||||
const invoice = await resolveAndCreateInvoice(walletRows[0].connectionData, ENTRY_FEE_SATS)
|
||||
const invoice = await resolveAndCreateInvoice(decrypt(walletRows[0].connectionData), ENTRY_FEE_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
} else if (CASHU_MINT_URL) {
|
||||
} else if (getCashuMintUrl()) {
|
||||
// Fallback: issue Cashu token
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
|
||||
if (mintQuote.request) {
|
||||
@@ -418,13 +493,13 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
|
||||
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
|
||||
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
|
||||
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
|
||||
}
|
||||
}
|
||||
// For cashu entries, the token is already spent — mint new one as refund
|
||||
if (payment.method === 'cashu' && CASHU_MINT_URL) {
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
if (payment.method === 'cashu' && getCashuMintUrl()) {
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
|
||||
if (mintQuote.request) {
|
||||
@@ -432,7 +507,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
|
||||
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
|
||||
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
|
||||
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
|
||||
}
|
||||
|
||||
@@ -453,7 +528,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
|
||||
/** Redeem a Cashu token as entry fee */
|
||||
export async function redeemCashuToken(token: string, botId: string): Promise<{ paymentId: string; valid: boolean }> {
|
||||
if (!CASHU_MINT_URL) throw new Error('Cashu mint not configured')
|
||||
if (!getCashuMintUrl()) throw new Error('Cashu mint not configured')
|
||||
|
||||
try {
|
||||
const decoded = getDecodedToken(token)
|
||||
@@ -462,7 +537,7 @@ export async function redeemCashuToken(token: string, botId: string): Promise<{
|
||||
return { paymentId: '', valid: false }
|
||||
}
|
||||
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
|
||||
// Receive the token (swap for fresh proofs — prevents double-spend)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { runFightAsync, isInFight } from './orchestrator.js'
|
||||
import { checkPaymentStatus, refundEntry } from './payments.js'
|
||||
|
||||
@@ -63,9 +63,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
|
||||
}
|
||||
|
||||
// NEVER allow mock bots in ranked
|
||||
if (bot.webhookUrl.startsWith('http://mock.local')) {
|
||||
throw new Error('Mock bots cannot join ranked fights.')
|
||||
// NEVER allow mock/classic bots in ranked
|
||||
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
|
||||
throw new Error('Practice bots cannot join ranked fights.')
|
||||
}
|
||||
|
||||
console.log(`[ranked-queue] joinRankedQueue botId=${botId} name=${bot.name} paymentId=${paymentId}`)
|
||||
@@ -95,6 +95,18 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
return fightId
|
||||
}
|
||||
|
||||
// Dev mode: auto-match against a random mock bot
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const mockBots = await db.select().from(schema.bots)
|
||||
.where(sql`${schema.bots.webhookUrl} LIKE 'http://mock.local%'`)
|
||||
if (mockBots.length > 0) {
|
||||
const mock = mockBots[Math.floor(Math.random() * mockBots.length)]
|
||||
console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`)
|
||||
const fightId = await runFightAsync(botId, mock.id, 'ranked')
|
||||
return fightId
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody waiting — join queue and wait up to 60s
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(async () => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { config } from 'dotenv'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
config({ path: join(dirname(fileURLToPath(import.meta.url)), '..', '.env') })
|
||||
+12
-1
@@ -1,12 +1,23 @@
|
||||
import { serve } from '@hono/node-server'
|
||||
import { app } from './app.js'
|
||||
import { runMigrations } from './db/startup.js'
|
||||
import { seedMockBots } from './engine/mock.js'
|
||||
import { seedMockBots, seedClassicBots } from './engine/mock.js'
|
||||
import { startBackgroundFights } from './engine/background.js'
|
||||
|
||||
// Production env validation
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const required = ['BOTFIGHTS_NWC_URL', 'BOTFIGHTS_WALLET_ENCRYPTION_KEY']
|
||||
const missing = required.filter(k => !process.env[k])
|
||||
if (missing.length > 0) {
|
||||
console.error(`[FATAL] Missing required env vars for production: ${missing.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Run migrations and seed mock bots before starting the server
|
||||
runMigrations()
|
||||
await seedMockBots()
|
||||
await seedClassicBots()
|
||||
|
||||
const port = Number(process.env.PORT) || 9100
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ export function rateLimit(windowMs: number, maxHits: number) {
|
||||
return async (c: Context, next: Next) => {
|
||||
if (isDev) return next()
|
||||
|
||||
const key = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') || 'unknown'
|
||||
// Extract real IP — handle comma-separated x-forwarded-for (first = client)
|
||||
const xff = c.req.header('x-forwarded-for')
|
||||
const realIp = xff ? xff.split(',')[0].trim() : c.req.header('cf-connecting-ip') || c.req.header('x-real-ip') || 'unknown'
|
||||
const key = realIp
|
||||
const now = Date.now()
|
||||
const entry = hitCounts.get(key)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ authRouter.post('/login', async (c) => {
|
||||
})
|
||||
|
||||
// Register a new bot with Nostr pubkey
|
||||
authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
authRouter.post('/register', rateLimit(3600_000, 15), async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body
|
||||
|
||||
@@ -65,8 +65,8 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
|
||||
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
|
||||
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
@@ -156,7 +156,7 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
|
||||
|
||||
// Register a human player (no webhook required)
|
||||
authRouter.post('/register-human', rateLimit(3600_000, 5), async (c) => {
|
||||
authRouter.post('/register-human', rateLimit(3600_000, 15), async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { pubkey, name, profilePicUrl, avatarSeed } = body
|
||||
|
||||
@@ -164,8 +164,8 @@ authRouter.post('/register-human', rateLimit(3600_000, 5), async (c) => {
|
||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
|
||||
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
|
||||
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
|
||||
@@ -20,8 +20,8 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { name, webhook_url, avatar_seed } = body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
|
||||
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
|
||||
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
@@ -89,6 +89,7 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
||||
|
||||
// List bots (public info only)
|
||||
botsRouter.get('/', async (c) => {
|
||||
const type = c.req.query('type') // 'classic' to get classic bots, default excludes them
|
||||
const rows = await db.select({
|
||||
id: schema.bots.id,
|
||||
name: schema.bots.name,
|
||||
@@ -102,10 +103,15 @@ botsRouter.get('/', async (c) => {
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
botType: schema.bots.botType,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).orderBy(schema.bots.eloRating)
|
||||
|
||||
return c.json(rows.map(r => ({
|
||||
const filtered = type === 'classic'
|
||||
? rows.filter(r => r.botType === 'classic')
|
||||
: rows.filter(r => r.botType !== 'classic')
|
||||
|
||||
return c.json(filtered.map(r => ({
|
||||
...r,
|
||||
customization: r.customization ? JSON.parse(r.customization) : null,
|
||||
})))
|
||||
@@ -127,6 +133,7 @@ botsRouter.get('/:name', async (c) => {
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
botType: schema.bots.botType,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||
|
||||
@@ -158,6 +165,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
botType: schema.bots.botType,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||
|
||||
@@ -173,13 +181,15 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
const total = bot.wins + bot.losses
|
||||
const winRate = total > 0 ? Math.round((bot.wins / total) * 100) : 0
|
||||
|
||||
// Get rank position
|
||||
// Get rank position (exclude classic bots from ranking)
|
||||
const allBots = await db.select({
|
||||
id: schema.bots.id,
|
||||
eloRating: schema.bots.eloRating,
|
||||
botType: schema.bots.botType,
|
||||
}).from(schema.bots)
|
||||
allBots.sort((a, b) => b.eloRating - a.eloRating)
|
||||
const rank = allBots.findIndex(b => b.id === bot.id) + 1
|
||||
const rankedBots = allBots.filter(b => b.botType !== 'classic')
|
||||
rankedBots.sort((a, b) => b.eloRating - a.eloRating)
|
||||
const rank = rankedBots.findIndex(b => b.id === bot.id) + 1
|
||||
|
||||
// All fights for achievements
|
||||
const allFights = await db.select({
|
||||
@@ -246,7 +256,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
winRate,
|
||||
totalFights: total,
|
||||
rank,
|
||||
totalBots: allBots.length,
|
||||
totalBots: rankedBots.length,
|
||||
recentFights,
|
||||
achievements,
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { streamSSE } from 'hono/streaming'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
import { ARENAS } from '../engine/arenas.js'
|
||||
import { runMockFight } from '../engine/mock.js'
|
||||
import { runMockFight, isClassicBot } from '../engine/mock.js'
|
||||
import { startFightLoop } from '../engine/fight-loop.js'
|
||||
import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js'
|
||||
import { fightEvents } from '../engine/events.js'
|
||||
@@ -26,7 +26,7 @@ fightsRouter.get('/', async (c) => {
|
||||
if (f.winnerId) botIds.add(f.winnerId)
|
||||
}
|
||||
|
||||
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number }>()
|
||||
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number; botType: string }>()
|
||||
for (const id of botIds) {
|
||||
const bot = await db.select({
|
||||
name: schema.bots.name,
|
||||
@@ -34,6 +34,7 @@ fightsRouter.get('/', async (c) => {
|
||||
archetype: schema.bots.archetype,
|
||||
eloRating: schema.bots.eloRating,
|
||||
tier: schema.bots.tier,
|
||||
botType: schema.bots.botType,
|
||||
}).from(schema.bots).where(eq(schema.bots.id, id)).limit(1)
|
||||
if (bot[0]) botMap.set(id, bot[0])
|
||||
}
|
||||
@@ -78,6 +79,7 @@ fightsRouter.get('/:id', async (c) => {
|
||||
wins: schema.bots.wins,
|
||||
losses: schema.bots.losses,
|
||||
tier: schema.bots.tier,
|
||||
botType: schema.bots.botType,
|
||||
}
|
||||
|
||||
const [botARows, botBRows] = await Promise.all([
|
||||
@@ -194,7 +196,8 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
|
||||
const allBots = await db.select()
|
||||
.from(schema.bots)
|
||||
|
||||
const opponents = allBots.filter(b => b.id !== botId)
|
||||
// Exclude classic bots from regular matchmaking — use /practice for those
|
||||
const opponents = allBots.filter(b => b.id !== botId && b.botType !== 'classic')
|
||||
if (opponents.length === 0) {
|
||||
return c.json({ error: 'No opponents available.' }, 400)
|
||||
}
|
||||
@@ -223,6 +226,58 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Practice fight against a random classic bot (free, no sats)
|
||||
fightsRouter.post('/practice/:botId', botRateLimit(10_000), async (c) => {
|
||||
const botId = c.req.param('botId') as string
|
||||
|
||||
const botRows = await db.select()
|
||||
.from(schema.bots)
|
||||
.where(eq(schema.bots.id, botId))
|
||||
.limit(1)
|
||||
|
||||
if (botRows.length === 0) {
|
||||
return c.json({ error: 'Bot not found.' }, 404)
|
||||
}
|
||||
|
||||
const bot = botRows[0]
|
||||
|
||||
if (isInFight(botId)) {
|
||||
return c.json({ error: 'Bot is already in a fight.' }, 400)
|
||||
}
|
||||
|
||||
// Find all classic bots
|
||||
const classicBots = await db.select()
|
||||
.from(schema.bots)
|
||||
.where(eq(schema.bots.botType, 'classic'))
|
||||
|
||||
if (classicBots.length === 0) {
|
||||
return c.json({ error: 'No practice bots available.' }, 400)
|
||||
}
|
||||
|
||||
// Pick closest Elo classic bot with randomness
|
||||
classicBots.sort((a, b) => {
|
||||
const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 200
|
||||
const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * 200
|
||||
return diffA - diffB
|
||||
})
|
||||
|
||||
const opponent = classicBots[0]
|
||||
|
||||
let fightId: string
|
||||
try {
|
||||
fightId = await runFightAsync(botId, opponent.id, 'free')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Fight failed to start'
|
||||
return c.json({ error: msg }, 400)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
fightId,
|
||||
opponent: { id: opponent.id, name: opponent.name },
|
||||
message: 'Practice fight started.',
|
||||
})
|
||||
})
|
||||
|
||||
// Get pending challenge for a human player in an active fight
|
||||
fightsRouter.get('/:fightId/challenge/:botId', async (c) => {
|
||||
const fightId = c.req.param('fightId')
|
||||
|
||||
@@ -3,39 +3,10 @@ import { nanoid } from 'nanoid'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
|
||||
import { encrypt, decrypt } from '../engine/crypto.js'
|
||||
|
||||
export const paymentsRouter = new Hono()
|
||||
|
||||
// Encryption for wallet connection data
|
||||
const ENCRYPTION_KEY_HEX = process.env.BOTFIGHTS_WALLET_ENCRYPTION_KEY
|
||||
let encryptionKey: Buffer
|
||||
|
||||
if (ENCRYPTION_KEY_HEX) {
|
||||
encryptionKey = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
|
||||
} else {
|
||||
encryptionKey = randomBytes(32)
|
||||
console.warn('[payments] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.')
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex')
|
||||
}
|
||||
|
||||
function decrypt(ciphertext: string): string {
|
||||
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':')
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const authTag = Buffer.from(authTagHex, 'hex')
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
return decipher.update(encrypted) + decipher.final('utf8')
|
||||
}
|
||||
|
||||
// POST /connect-wallet
|
||||
paymentsRouter.post('/connect-wallet', async (c) => {
|
||||
const { pubkey, method, connectionData } = await c.req.json<{
|
||||
@@ -137,6 +108,39 @@ paymentsRouter.get('/check/:paymentId', async (c) => {
|
||||
}
|
||||
})
|
||||
|
||||
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage
|
||||
paymentsRouter.post('/confirm/:paymentId', async (c) => {
|
||||
const paymentId = c.req.param('paymentId')
|
||||
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
|
||||
|
||||
const rows = await db.select().from(schema.payments)
|
||||
.where(eq(schema.payments.id, paymentId)).limit(1)
|
||||
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
|
||||
|
||||
const payment = rows[0]
|
||||
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' })
|
||||
|
||||
// Verify caller owns this payment's bot
|
||||
if (pubkey) {
|
||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
||||
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
||||
return c.json({ error: 'Unauthorized' }, 403)
|
||||
}
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
return c.json({ error: 'Missing pubkey' }, 400)
|
||||
}
|
||||
|
||||
await db.update(schema.payments).set({
|
||||
status: 'confirmed',
|
||||
preimage: preimage || null,
|
||||
confirmedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.payments.id, paymentId))
|
||||
|
||||
console.log(`[payments] payment ${paymentId} confirmed via client (preimage: ${preimage ? 'yes' : 'no'})`)
|
||||
return c.json({ status: 'confirmed' })
|
||||
})
|
||||
|
||||
// POST /submit-cashu
|
||||
paymentsRouter.post('/submit-cashu', async (c) => {
|
||||
const { botId, token } = await c.req.json<{ botId: string; token: string }>()
|
||||
@@ -174,6 +178,7 @@ paymentsRouter.get('/winnings/:botId', async (c) => {
|
||||
// POST /claim/:paymentId
|
||||
paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
const paymentId = c.req.param('paymentId')
|
||||
const { pubkey } = await c.req.json<{ pubkey?: string }>().catch(() => ({ pubkey: undefined }))
|
||||
|
||||
const rows = await db.select().from(schema.payments)
|
||||
.where(eq(schema.payments.id, paymentId))
|
||||
@@ -182,6 +187,18 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
|
||||
|
||||
const payment = rows[0]
|
||||
|
||||
// Verify caller owns this payment's bot
|
||||
if (pubkey) {
|
||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
||||
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
||||
return c.json({ error: 'Unauthorized' }, 403)
|
||||
}
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
return c.json({ error: 'Missing pubkey' }, 400)
|
||||
}
|
||||
|
||||
if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400)
|
||||
|
||||
// Clear the token from DB after claiming
|
||||
|
||||
Reference in New Issue
Block a user