feat: comedy overhaul start, profile page, bot setup docs, auth improvements

Rewrites announcer commentary (HYPE_LINES, DEEP_INTROS, ROUND_HYPE) with
modern edgy humor. Adds BOT_SETUP.md, bot SDK, customization engine,
profile page character display, persistent auth, rate limit tweaks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 08:52:24 +00:00
co-authored by Claude Opus 4.6
parent 4a35e1e0b5
commit 559782c8ce
15 changed files with 1086 additions and 58 deletions
+4 -4
View File
@@ -25,8 +25,8 @@ interface Round {
interface FightData {
id: string
botA: { id: string; name: string; avatarSeed: string; archetype?: string; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
botB: { id: string; name: string; avatarSeed: string; archetype?: string; 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 } | 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
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
arena: string
winnerId: string | null
@@ -107,8 +107,8 @@ async function initScene() {
scene = await createFightScene({
canvas: canvasRef.value,
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype },
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any },
arena: props.fight.arena,
})
}
+4 -3
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { generateSpriteSheet, getBotColors, FRAME_SIZE, ANIMATIONS } from '../game/sprites'
import { generateSpriteSheet, getBotColors, FRAME_SIZE, ANIMATIONS, type SpriteCustomization } from '../game/sprites'
const props = defineProps<{
seed: string
archetype?: string
tier?: number
size?: number
customization?: SpriteCustomization
}>()
const canvasRef = ref<HTMLCanvasElement>()
@@ -37,7 +38,7 @@ function render() {
function loadSprite() {
const colors = getBotColors(props.seed)
const dataUrl = generateSpriteSheet(props.seed, props.tier || 0, colors.primary, colors.secondary, props.archetype)
const dataUrl = generateSpriteSheet(props.seed, props.tier || 0, colors.primary, colors.secondary, props.archetype, props.customization)
img = new Image()
img.onload = () => render()
img.src = dataUrl
@@ -45,7 +46,7 @@ function loadSprite() {
onMounted(() => loadSprite())
watch(() => [props.seed, props.archetype], () => {
watch(() => [props.seed, props.archetype, props.customization], () => {
if (animHandle) clearTimeout(animHandle)
frame = 0
loadSprite()
+36
View File
@@ -1,11 +1,21 @@
import { ref, readonly, computed } from 'vue'
interface BotCustomization {
archetype?: string
primaryColor?: string
secondaryColor?: string
forceVisor?: boolean
forceMohawk?: boolean
forceHorns?: boolean
}
interface BotData {
id: string
name: string
avatarSeed: string
archetype: string
profilePicUrl: string | null
customization: BotCustomization | null
eloRating: number
wins: number
losses: number
@@ -122,6 +132,7 @@ export function useNostr() {
avatarSeed: data.name,
archetype: data.archetype,
profilePicUrl: profilePicUrl.value,
customization: data.customization || null,
eloRating: 1200,
wins: 0,
losses: 0,
@@ -134,6 +145,30 @@ export function useNostr() {
return bot.value
}
async function updateCustomization(customization: BotCustomization): Promise<void> {
if (!pubkey.value) throw new Error('Not logged in')
const res = await fetch('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value, customization }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Update failed')
}
if (bot.value) {
bot.value = {
...bot.value,
customization: { ...(bot.value.customization || {}), ...customization },
archetype: customization.archetype || bot.value.archetype,
}
store('bf_bot', bot.value)
}
}
function logout() {
pubkey.value = null
bot.value = null
@@ -152,6 +187,7 @@ export function useNostr() {
hasExtension,
login,
registerBot,
updateCustomization,
logout,
}
}
+66 -29
View File
@@ -259,41 +259,78 @@ export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(
// Random dramatic commentary lines
const HYPE_LINES = [
'Unbelievable!',
'What a hit!',
'Incredible!',
'Absolutely destroyed!',
'No mercy!',
'Is this even legal?',
'The crowd goes wild!',
'A display of raw power!',
'That had to hurt!',
'Total annihilation!',
'Can you believe this?',
'History in the making!',
'Oh the humanity!',
'Savage!',
'Ladies and gentlemen!',
'SOMEBODY CALL AN AMBULANCE!',
'THAT HIT SO HARD IT CHANGED TIME ZONES!',
'HE\'S ALREADY DEAD! STOP!',
'THE GENEVA CONVENTION JUST SENT A STRONGLY WORDED LETTER!',
'CONGRESS COULDN\'T PASS A BILL THIS DEVASTATING!',
'I HAVEN\'T SEEN A BEATING LIKE THIS SINCE MIDTERMS!',
'THAT\'S GOTTA BE ILLEGAL IN AT LEAST TWELVE STATES!',
'EMOTIONAL DAMAGE!',
'MY THERAPIST IS GONNA HEAR ABOUT THIS ONE!',
'SOMEONE CHECK IF THAT\'S COVERED BY INSURANCE!',
'THAT WASN\'T A FIGHT, THAT WAS A TED TALK ON VIOLENCE!',
'THE WIFI JUST WENT OUT FROM THE SHEER VIOLENCE!',
'CALL THE PENTAGON! WE\'VE FOUND A NEW WEAPON!',
'THAT\'S NOT A FIGHT, THAT\'S JUST BULLYING WITH EXTRA STEPS!',
'EVEN THE CROWD\'S THERAPIST FELT THAT!',
'THAT BOT JUST GOT RATIO\'D IN REAL LIFE!',
'NOT EVEN LOBBYING COULD SAVE THEM FROM THAT!',
'I\'VE SEEN SENATE HEARINGS LESS PAINFUL THAN THIS!',
'HIS MOM IS WATCHING AND PRETENDING SHE DOESN\'T KNOW HIM!',
'THAT HIT HAD ITS OWN ZIP CODE!',
'SOMEBODY STOP THE MATCH! OR DON\'T, THIS IS GREAT!',
'ABSOLUTELY DISGUSTING! I LOVE IT!',
'THERE ARE CHILDREN WATCHING! WELL, NOT ANYMORE!',
'THE CROWD CAN\'T BELIEVE IT AND HONESTLY NEITHER CAN I!',
'TACTICAL NUKE INCOMING!',
'THAT BOT JUST COMMITTED A WAR CRIME ON LIVE TELEVISION!',
'SOMEONE TELL THEIR MOM TO STOP WATCHING!',
'I NEED A CIGARETTE AFTER THAT AND I DON\'T EVEN SMOKE!',
'THAT\'S THE MOST VIOLENT THING I\'VE SEEN SINCE THE LAST BUDGET VOTE!',
'IF THAT HIT WAS A TWEET IT WOULD GET COMMUNITY NOTED!',
'THEY DIDN\'T JUST LOSE, THEY GOT GENTRIFIED!',
'THAT BOT NEEDS TO FILE AN INSURANCE CLAIM!',
'MARK ZUCKERBERG FELT THAT FROM THE METAVERSE!',
'THE FCC IS GONNA FINE US FOR BROADCASTING THIS!',
'ELON WOULD BUY THIS BOT JUST TO FIRE IT!',
'EVEN AI SAFETY RESEARCHERS CAN\'T SAVE THEM NOW!',
]
const DEEP_INTROS = [
'In a world of machines...',
'Only the strongest survive.',
'This is... bot fights.',
'Two enter. One leaves.',
'No mercy. No remorse.',
'The arena awaits blood.',
'Silicon versus silicon.',
'In a world where AI was supposed to help humanity... they chose violence.',
'They said the machines would take our jobs. They took our dignity first.',
'Two bots enter. Zero bots leave emotionally intact.',
'Built in a garage. Forged in competition. Broken in under ten seconds.',
'This isn\'t artificial intelligence. This is artificial VIOLENCE.',
'Somewhere, a GPU is crying.',
'They trained on the entire internet. And the internet chose chaos.',
'Silicon souls. Carbon fiber fists. Zero chill.',
'Every epoch of training... led to this moment of pain.',
'The cloud can\'t save you now.',
'Funded by venture capital. Fueled by rage.',
'Welcome to the thunderdome, nerds.',
'The algorithms don\'t care about your feelings.',
'No one is coming to save you. Not even your developer.',
'In this economy? They\'re fighting for free.',
]
const ROUND_HYPE = [
'Here we go!',
'It\'s on!',
'Let\'s go!',
'Show me what you got!',
'Bring it!',
'Time to throw down!',
'Get ready to rumble!',
'ALRIGHT, LET\'S SEE SOME VIOLENCE!',
'TOUCH GLOVES AND COME OUT SWINGING!',
'NO MERCY MODE ACTIVATED!',
'LET\'S GET READY TO COMPUTE!',
'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!',
'SOMEBODY\'S GETTING DEPRECATED TONIGHT!',
'LET THE CHAOS BEGIN!',
'THE CROWD IS ON ITS FEET! WELL, MOST OF THEM!',
'IT\'S ABOUT TO GET UGLY! WELL, UGLIER!',
'THREE! TWO! ONE! VIOLENCE!',
'THIS IS NOT A DRILL! ACTUALLY IT MIGHT BE!',
'TIME TO FIND OUT WHO\'S REALLY BEEN SKIPPING LEG DAY!',
'YOUR MOM SAID BE CAREFUL! I SAID NO!',
'ROUND START! MAY THE BEST ALGORITHM WIN!',
'THE GLOVES ARE OFF! THE MODELS ARE LOADED! LET\'S GO!',
]
// Mortal Kombat style dramatic calls
+20 -7
View File
@@ -9,9 +9,18 @@ export { getBotColors } from './palette'
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
export { archetypes, rollArchetype } from './archetypes'
export interface SpriteCustomization {
archetype?: string
primaryColor?: string // hsl(h, s%, l%) format
secondaryColor?: string // hsl(h, s%, l%) format
forceVisor?: boolean
forceMohawk?: boolean
forceHorns?: boolean
}
export function generateSpriteSheet(
seed: string, tier: number, primaryColor: string, secondaryColor: string,
archetypeOverride?: string,
archetypeOverride?: string, customization?: SpriteCustomization,
): string {
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * MAX_FRAMES
@@ -19,7 +28,10 @@ export function generateSpriteSheet(
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
const pal = makePal(primaryColor, secondaryColor, tier)
// Apply color customization overrides
const finalPrimary = customization?.primaryColor || primaryColor
const finalSecondary = customization?.secondaryColor || secondaryColor
const pal = makePal(finalPrimary, finalSecondary, tier)
let sh = 0
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
@@ -28,8 +40,9 @@ export function generateSpriteSheet(
// Character archetype -- determined by seed for consistent variety, or forced by override
const archetypeRoll = rng()
const arch = archetypeOverride
? (archetypes.find(a => a.name === archetypeOverride) || rollArchetype(archetypeRoll))
const effectiveArchetype = customization?.archetype || archetypeOverride
const arch = effectiveArchetype
? (archetypes.find(a => a.name === effectiveArchetype) || rollArchetype(archetypeRoll))
: rollArchetype(archetypeRoll)
// Consume rng in same order as original for determinism
@@ -38,9 +51,9 @@ export function generateSpriteSheet(
const hornsRoll = rng()
const specialRoll = rng()
const hasVisor = visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false)
const hasMohawk = mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true)
const hasHorns = hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true)
const hasVisor = customization?.forceVisor ?? (visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false))
const hasMohawk = customization?.forceMohawk ?? (mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true))
const hasHorns = customization?.forceHorns ?? (hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true))
const specialType: 'fire' | 'electric' = specialRoll > 0.5 ? 'fire' : 'electric'
// Dimensions: base + archetype overrides
+138 -2
View File
@@ -1,19 +1,30 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import SpritePreview from '../components/SpritePreview.vue'
import type { SpriteCustomization } from '../game/sprites'
const route = useRoute()
const router = useRouter()
const { bot: nostrBot, isLoggedIn, logout } = useNostr()
const { bot: nostrBot, isLoggedIn, logout, updateCustomization } = useNostr()
const botName = route.params.name as string
interface BotCustomization {
archetype?: string
primaryColor?: string
secondaryColor?: string
forceVisor?: boolean
forceMohawk?: boolean
forceHorns?: boolean
}
interface BotStats {
id: string
name: string
avatarSeed: string
archetype: string
customization: BotCustomization | null
profilePicUrl: string | null
eloRating: number
wins: number
@@ -65,6 +76,131 @@ const waitingFighters = ref<QueueEntry[]>([])
let pollHandle: ReturnType<typeof setInterval> | null = null
const isOwner = ref(false)
const showCustomize = ref(false)
const isSaving = ref(false)
const custError = ref('')
const ARCHETYPES = [
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
'ghost', 'alien', 'dinosaur', 'pirate', 'ninja', 'cowboy', 'wizard',
'bee', 'frog', 'snail', 'robot', 'android', 'drone', 'toaster', 'tv_head',
'calculator', 'satellite', 'mech', 'led_cube', 'circuit', 'antenna_bot',
'microwave', 'cyberdog', 'robocat', 'ufo_bot', 'minotaur', 'unicorn',
'phoenix', 'dragon', 'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem',
'vampire', 'werewolf', 'zombie', 'witch', 'demon', 'chef', 'firefighter',
'astronaut', 'clown', 'detective', 'nurse', 'lumberjack', 'scientist',
'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight',
'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon',
'snake', 'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda',
'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato',
'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck',
'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post',
'broom_man',
]
const custForm = reactive({
archetype: '',
primaryColor: '#3388cc',
secondaryColor: '#cc8833',
forceVisor: false,
forceMohawk: false,
forceHorns: false,
})
function hslToHex(hsl: string): string {
const m = hsl.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
if (!m) return '#888888'
const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1; if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
let r: number, g: number, b: number
if (s === 0) { r = g = b = l }
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
}
const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0')
return `#${hex(r)}${hex(g)}${hex(b)}`
}
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255
const g = parseInt(hex.slice(3, 5), 16) / 255
const b = parseInt(hex.slice(5, 7), 16) / 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
const l = (max + min) / 2
if (max === min) return `hsl(0, 0%, ${Math.round(l * 100)}%)`
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
let h = 0
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
else if (max === g) h = ((b - r) / d + 2) / 6
else h = ((r - g) / d + 4) / 6
return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
}
const previewCustomization = computed<SpriteCustomization>(() => ({
archetype: custForm.archetype || undefined,
primaryColor: hexToHsl(custForm.primaryColor),
secondaryColor: hexToHsl(custForm.secondaryColor),
forceVisor: custForm.forceVisor,
forceMohawk: custForm.forceMohawk,
forceHorns: custForm.forceHorns,
}))
function initCustForm() {
if (!stats.value) return
const c = stats.value.customization
custForm.archetype = c?.archetype || stats.value.archetype || ''
custForm.primaryColor = c?.primaryColor ? hslToHex(c.primaryColor) : '#3388cc'
custForm.secondaryColor = c?.secondaryColor ? hslToHex(c.secondaryColor) : '#cc8833'
custForm.forceVisor = c?.forceVisor ?? false
custForm.forceMohawk = c?.forceMohawk ?? false
custForm.forceHorns = c?.forceHorns ?? false
}
async function saveCustomization() {
if (isSaving.value) return
isSaving.value = true
custError.value = ''
try {
await updateCustomization({
archetype: custForm.archetype || undefined,
primaryColor: hexToHsl(custForm.primaryColor),
secondaryColor: hexToHsl(custForm.secondaryColor),
forceVisor: custForm.forceVisor,
forceMohawk: custForm.forceMohawk,
forceHorns: custForm.forceHorns,
})
if (stats.value) {
stats.value = {
...stats.value,
archetype: custForm.archetype || stats.value.archetype,
customization: {
archetype: custForm.archetype || undefined,
primaryColor: hexToHsl(custForm.primaryColor),
secondaryColor: hexToHsl(custForm.secondaryColor),
forceVisor: custForm.forceVisor,
forceMohawk: custForm.forceMohawk,
forceHorns: custForm.forceHorns,
},
}
}
showCustomize.value = false
} catch (err) {
custError.value = err instanceof Error ? err.message : 'Save failed'
}
isSaving.value = false
}
onMounted(async () => {
try {