fix: fight restart bug, mobile TTS, character sizing, bitcoin symbols
- Fix fight replay showing empty scene: validate rounds before playing, retry loading fight data up to 5 times when rounds are missing - Add COOP/COEP headers to production server for SharedArrayBuffer (required by Kokoro TTS WASM threading on mobile) - Add blob: to scriptSrc/workerSrc CSP for Web Worker support - Fix mobile character cutoff: raise ground line to 0.82, add MOBILE_SCALE_CAP (0.55) so fighters never shrink too small - Add ₿ bitcoin chest badge to all fighter sprites (scales with tier) - Add sats coin stack on bot profile page proportional to satsWon - Seed dev tournament with 8 mock bots on startup (dev mode only) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a10c1c9cb6
commit
5ee6b6c88c
@@ -322,6 +322,11 @@ async function replay() {
|
|||||||
|
|
||||||
async function _doReplay() {
|
async function _doReplay() {
|
||||||
if (!props.fight.botA || !props.fight.botB) return
|
if (!props.fight.botA || !props.fight.botB) return
|
||||||
|
if (!props.fight.rounds || props.fight.rounds.length === 0) {
|
||||||
|
console.warn('[FightViewer] No rounds to play — skipping replay')
|
||||||
|
emit('replay-done')
|
||||||
|
return
|
||||||
|
}
|
||||||
displayHpA.value = 100
|
displayHpA.value = 100
|
||||||
displayHpB.value = 100
|
displayHpB.value = 100
|
||||||
logItems.value = []
|
logItems.value = []
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { pickChoreography } from './fight/choreography/picker'
|
|||||||
export { pickChoreography }
|
export { pickChoreography }
|
||||||
import {
|
import {
|
||||||
isPerfMode, perfParticles, isReducedMotion, haptic,
|
isPerfMode, perfParticles, isReducedMotion, haptic,
|
||||||
GROUND_Y_RATIO, HOME_A_RATIO, HOME_B_RATIO, REFERENCE_HEIGHT, SPRITE_LOAD_TIMEOUT_MS,
|
GROUND_Y_RATIO, HOME_A_RATIO, HOME_B_RATIO, REFERENCE_HEIGHT, MOBILE_SCALE_CAP, SPRITE_LOAD_TIMEOUT_MS,
|
||||||
FIGHTER_BASE_SCALE, FIGHTER_TIER_SCALE, JUDGE_SCALE, HUMAN_COACH_SCALE, HUMAN_RUN_SCALE,
|
FIGHTER_BASE_SCALE, FIGHTER_TIER_SCALE, JUDGE_SCALE, HUMAN_COACH_SCALE, HUMAN_RUN_SCALE,
|
||||||
AMBIENT_PARTICLE_COUNT, SKY_GRADIENT_BANDS, GROUND_DEPTH_LINES, PERSPECTIVE_GRID_LINES, PILLAR_WIDTH,
|
AMBIENT_PARTICLE_COUNT, SKY_GRADIENT_BANDS, GROUND_DEPTH_LINES, PERSPECTIVE_GRID_LINES, PILLAR_WIDTH,
|
||||||
SPEECH_BUBBLE_MAX_CHARS, SPEECH_BUBBLE_WRAP, SPEECH_BUBBLE_MAX_LINES, SPEECH_BUBBLE_FONT_SIZE, SPEECH_BUBBLE_DEFAULT_DURATION,
|
SPEECH_BUBBLE_MAX_CHARS, SPEECH_BUBBLE_WRAP, SPEECH_BUBBLE_MAX_LINES, SPEECH_BUBBLE_FONT_SIZE, SPEECH_BUBBLE_DEFAULT_DURATION,
|
||||||
@@ -193,7 +193,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
const vhsTracking = (duration: number = 0.4) => _vhsTracking(fightCtx, duration)
|
const vhsTracking = (duration: number = 0.4) => _vhsTracking(fightCtx, duration)
|
||||||
const dimensionalShift = (duration: number = 0.6) => _dimensionalShift(fightCtx, duration)
|
const dimensionalShift = (duration: number = 0.6) => _dimensionalShift(fightCtx, duration)
|
||||||
|
|
||||||
const SF = Math.min(1, H / REFERENCE_HEIGHT)
|
const SF = Math.max(MOBILE_SCALE_CAP, Math.min(1, H / REFERENCE_HEIGHT))
|
||||||
const GROUND_Y = H * GROUND_Y_RATIO
|
const GROUND_Y = H * GROUND_Y_RATIO
|
||||||
const HOME_A = W * HOME_A_RATIO
|
const HOME_A = W * HOME_A_RATIO
|
||||||
const HOME_B = W * HOME_B_RATIO
|
const HOME_B = W * HOME_B_RATIO
|
||||||
|
|||||||
@@ -87,10 +87,11 @@ export const HYPERDETAIL_BASE_CHANCE = 0.05 // Base hyperdetail chance
|
|||||||
export const HYPERDETAIL_INTENSITY_SCALE = 0.45 // Scales with intensity
|
export const HYPERDETAIL_INTENSITY_SCALE = 0.45 // Scales with intensity
|
||||||
|
|
||||||
// --- Scene layout ---
|
// --- Scene layout ---
|
||||||
export const GROUND_Y_RATIO = 0.78 // Ground line as fraction of canvas height
|
export const GROUND_Y_RATIO = 0.82 // Ground line as fraction of canvas height
|
||||||
export const HOME_A_RATIO = 0.28 // Fighter A home X position ratio
|
export const HOME_A_RATIO = 0.28 // Fighter A home X position ratio
|
||||||
export const HOME_B_RATIO = 0.72 // Fighter B home X position ratio
|
export const HOME_B_RATIO = 0.72 // Fighter B home X position ratio
|
||||||
export const REFERENCE_HEIGHT = 500 // Reference canvas height for scale factor
|
export const REFERENCE_HEIGHT = 500 // Reference canvas height for scale factor
|
||||||
|
export const MOBILE_SCALE_CAP = 0.55 // Cap SF on small screens so fighters aren't too tiny
|
||||||
export const SPRITE_LOAD_TIMEOUT_MS = 5_000 // Max wait for sprite loading (mobile hang prevention)
|
export const SPRITE_LOAD_TIMEOUT_MS = 5_000 // Max wait for sprite loading (mobile hang prevention)
|
||||||
|
|
||||||
// --- Fighter scaling ---
|
// --- Fighter scaling ---
|
||||||
|
|||||||
@@ -201,14 +201,35 @@ export function generateSpriteSheet(
|
|||||||
if (tier >= 3) { px(cx + hOff, beltY, '#ffd700', ox, oy); px(cx + hOff + 1, beltY, '#ffd700', ox, oy) }
|
if (tier >= 3) { px(cx + hOff, beltY, '#ffd700', ox, oy); px(cx + hOff + 1, beltY, '#ffd700', ox, oy) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chest emblem (tier 4+)
|
// ₿ Bitcoin chest badge — every fighter carries the symbol
|
||||||
if (tier >= 4) {
|
{
|
||||||
const ey = by + Math.round(bh * 0.3)
|
const ey = by + Math.round(bh * 0.3)
|
||||||
px(cx + hOff - 1, ey, pal.acc, ox, oy)
|
const ec = cx + hOff
|
||||||
px(cx + hOff, ey, pal.accLight, ox, oy)
|
// Tier 0-1: simple orange ₿ dot
|
||||||
px(cx + hOff + 1, ey, pal.acc, ox, oy)
|
// Tier 2-3: small ₿ shape (vertical bar + bumps)
|
||||||
px(cx + hOff, ey - 1, pal.acc, ox, oy)
|
// Tier 4+: full golden ₿ with glow
|
||||||
px(cx + hOff, ey + 1, pal.acc, ox, oy)
|
if (tier >= 4) {
|
||||||
|
// Golden glow behind
|
||||||
|
px(ec - 2, ey, '#ffd70066', ox, oy)
|
||||||
|
px(ec + 2, ey, '#ffd70066', ox, oy)
|
||||||
|
px(ec, ey - 2, '#ffd70066', ox, oy)
|
||||||
|
px(ec, ey + 2, '#ffd70066', ox, oy)
|
||||||
|
}
|
||||||
|
const bc = tier >= 4 ? '#ffd700' : tier >= 2 ? '#ff8c00' : '#ff6600'
|
||||||
|
// Vertical stroke
|
||||||
|
px(ec, ey - 1, bc, ox, oy)
|
||||||
|
px(ec, ey, bc, ox, oy)
|
||||||
|
px(ec, ey + 1, bc, ox, oy)
|
||||||
|
// Right bumps (₿ shape)
|
||||||
|
if (bw >= 6) {
|
||||||
|
px(ec + 1, ey - 1, bc, ox, oy)
|
||||||
|
px(ec + 1, ey + 1, bc, ox, oy)
|
||||||
|
}
|
||||||
|
// Top/bottom serifs
|
||||||
|
if (tier >= 2) {
|
||||||
|
px(ec, ey - 2, tier >= 4 ? '#ffd700' : '#cc7000', ox, oy)
|
||||||
|
px(ec, ey + 2, tier >= 4 ? '#ffd700' : '#cc7000', ox, oy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shoulder pads (tier 3+)
|
// Shoulder pads (tier 3+)
|
||||||
|
|||||||
@@ -379,18 +379,26 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden"
|
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden"
|
||||||
:class="{ 'border-t-0': nostrProfile?.banner }">
|
:class="{ 'border-t-0': nostrProfile?.banner }">
|
||||||
<!-- Human player: just the human avatar, centered -->
|
<!-- Human player: just the human avatar, centered -->
|
||||||
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-4">
|
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-4 relative">
|
||||||
<HumanPreview
|
<HumanPreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
archetype="human"
|
archetype="human"
|
||||||
:size="200"
|
:size="200"
|
||||||
:win-rate="(stats.winRate || 0) / 100"
|
:win-rate="(stats.winRate || 0) / 100"
|
||||||
anim="idle"
|
anim="idle"
|
||||||
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
|
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)] relative z-10"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="stats.satsWon > 0"
|
||||||
|
class="absolute bottom-0 right-4 flex flex-col-reverse items-center z-0 opacity-80">
|
||||||
|
<div v-for="i in Math.min(Math.ceil(stats.satsWon / 100), 10)" :key="i"
|
||||||
|
class="w-6 h-2 rounded-sm border border-yellow-600/50"
|
||||||
|
:class="i <= 3 ? 'bg-amber-700' : i <= 6 ? 'bg-amber-600' : 'bg-amber-500'"
|
||||||
|
:style="{ marginTop: i > 1 ? '-1px' : '0' }" />
|
||||||
|
<span class="font-pixel text-[7px] text-amber-400 mt-0.5">{{ stats.satsWon }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Bot player: human controller + bot sprite -->
|
<!-- Bot player: human controller + bot sprite -->
|
||||||
<div v-else class="flex items-end justify-center gap-2 mb-4">
|
<div v-else class="flex items-end justify-center gap-2 mb-4 relative">
|
||||||
<HumanPreview
|
<HumanPreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
:archetype="stats.archetype || 'standard'"
|
:archetype="stats.archetype || 'standard'"
|
||||||
@@ -405,9 +413,18 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
:tier="stats.tier"
|
:tier="stats.tier"
|
||||||
:size="140"
|
:size="140"
|
||||||
:customization="stats.customization || undefined"
|
:customization="stats.customization || undefined"
|
||||||
class="drop-shadow-[0_0_20px_var(--glow)]"
|
class="drop-shadow-[0_0_20px_var(--glow)] relative z-10"
|
||||||
:style="{ '--glow': stats.tierColor + '80' } as any"
|
:style="{ '--glow': stats.tierColor + '80' } as any"
|
||||||
/>
|
/>
|
||||||
|
<!-- Sats coin stack behind character -->
|
||||||
|
<div v-if="stats.satsWon > 0"
|
||||||
|
class="absolute bottom-0 right-2 flex flex-col-reverse items-center z-0 opacity-80">
|
||||||
|
<div v-for="i in Math.min(Math.ceil(stats.satsWon / 100), 10)" :key="i"
|
||||||
|
class="w-6 h-2 rounded-sm border border-yellow-600/50"
|
||||||
|
:class="i <= 3 ? 'bg-amber-700' : i <= 6 ? 'bg-amber-600' : 'bg-amber-500'"
|
||||||
|
:style="{ marginTop: i > 1 ? '-1px' : '0' }" />
|
||||||
|
<span class="font-pixel text-[7px] text-amber-400 mt-0.5">{{ stats.satsWon }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Name & tier -->
|
<!-- Name & tier -->
|
||||||
|
|||||||
@@ -357,7 +357,12 @@ async function handleFightEnd(data: any) {
|
|||||||
if (liveScene) { liveScene.destroy(); liveScene = null }
|
if (liveScene) { liveScene.destroy(); liveScene = null }
|
||||||
liveSceneReady.value = false
|
liveSceneReady.value = false
|
||||||
|
|
||||||
|
// Retry loading until rounds are populated (server may still be persisting)
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
await loadFight()
|
await loadFight()
|
||||||
|
if (fight.value?.rounds?.length) break
|
||||||
|
await new Promise(r => setTimeout(r, 800))
|
||||||
|
}
|
||||||
isLive.value = false
|
isLive.value = false
|
||||||
disconnectSSE()
|
disconnectSSE()
|
||||||
stopHumanPolling()
|
stopHumanPolling()
|
||||||
|
|||||||
+9
-1
@@ -36,15 +36,23 @@ app.use('*', logger())
|
|||||||
const allowedOrigin = process.env.CORS_ORIGIN || '*'
|
const allowedOrigin = process.env.CORS_ORIGIN || '*'
|
||||||
app.use('/api/*', cors({ origin: allowedOrigin }))
|
app.use('/api/*', cors({ origin: allowedOrigin }))
|
||||||
|
|
||||||
|
// COOP/COEP headers: required for SharedArrayBuffer (Kokoro TTS WASM threading)
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
await next()
|
||||||
|
c.header('Cross-Origin-Opener-Policy', 'same-origin')
|
||||||
|
c.header('Cross-Origin-Embedder-Policy', 'credentialless')
|
||||||
|
})
|
||||||
|
|
||||||
// Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, etc.
|
// Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, etc.
|
||||||
app.use('*', secureHeaders({
|
app.use('*', secureHeaders({
|
||||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'"],
|
scriptSrc: ["'self'", 'blob:'],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'"],
|
||||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||||
|
workerSrc: ["'self'", 'blob:'],
|
||||||
} : undefined,
|
} : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -463,6 +463,14 @@ export const EXTRA_PROMPTS: Record<string, PromptEntry[]> = {
|
|||||||
{ prompt: 'Roast someone who trusts custodial exchanges with their Bitcoin.' , theme: 'bitcoin' },
|
{ prompt: 'Roast someone who trusts custodial exchanges with their Bitcoin.' , theme: 'bitcoin' },
|
||||||
{ prompt: 'Roast someone who calls themselves a "Web3 developer" but can\'t deploy a smart contract.' , theme: 'bot_coding' },
|
{ prompt: 'Roast someone who calls themselves a "Web3 developer" but can\'t deploy a smart contract.' , theme: 'bot_coding' },
|
||||||
{ prompt: 'Roast a sysadmin who uses root for everything.' , theme: 'bot_coding' },
|
{ prompt: 'Roast a sysadmin who uses root for everything.' , theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast a "vibe coder" who writes entire apps with AI prompts and has no idea how any of it works. 3 sentences.', theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast someone who replaced their entire engineering team with ChatGPT and wonders why production is on fire. 3 sentences.', theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast a developer who says "just use AI" instead of learning to code. 3 sentences.', theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast someone whose entire GitHub profile is AI-generated code they don\'t understand. 3 sentences.', theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast a startup founder who pivoted to AI 6 times in one year. 3 sentences.', theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast someone who uses 4 different AI coding assistants simultaneously and still can\'t ship. 3 sentences.', theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast a developer who added Rust to a JavaScript project "for performance" and now nobody can build it. 3 sentences.', theme: 'bot_coding' },
|
||||||
|
{ prompt: 'Roast someone who says "I don\'t need tests, I have AI to find my bugs." 3 sentences.', theme: 'bot_coding' },
|
||||||
],
|
],
|
||||||
|
|
||||||
creative_writing: [
|
creative_writing: [
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { db, schema } from '../db/index.js'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import { createTournament, joinTournament, startTournament } from './tournaments.js'
|
||||||
|
import { logger } from '../lib/logger.js'
|
||||||
|
|
||||||
|
/** Seed a dev tournament with mock bots for testing betting/tournament UI */
|
||||||
|
export async function seedDevTournament(): Promise<void> {
|
||||||
|
// Skip if a tournament already exists
|
||||||
|
const existing = db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(schema.tournaments)
|
||||||
|
.get()
|
||||||
|
if (existing && existing.count > 0) {
|
||||||
|
logger.info('dev-seed', 'tournaments already exist — skipping seed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab 8 mock bots for the tournament
|
||||||
|
const bots = db.select({ id: schema.bots.id, name: schema.bots.name })
|
||||||
|
.from(schema.bots)
|
||||||
|
.limit(8)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
if (bots.length < 4) {
|
||||||
|
logger.warn('dev-seed', `only ${bots.length} bots — need at least 4 for tournament seed`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tournament
|
||||||
|
const tournamentId = createTournament(
|
||||||
|
'SATOSHI SHOWDOWN #1',
|
||||||
|
'single_elim',
|
||||||
|
8,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Join bots
|
||||||
|
for (const bot of bots.slice(0, 8)) {
|
||||||
|
try {
|
||||||
|
joinTournament(tournamentId, bot.id)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('dev-seed', `failed to join bot ${bot.name}: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the tournament (seeds by elo, generates bracket)
|
||||||
|
try {
|
||||||
|
startTournament(tournamentId)
|
||||||
|
logger.info('dev-seed', `seeded tournament "${tournamentId}" with ${Math.min(bots.length, 8)} bots`)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('dev-seed', `failed to start tournament: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('dev-seed', 'dev seeding complete — tournament ready for testing')
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { seedMockBots, seedClassicBots } from './engine/mock.js'
|
|||||||
import { startBackgroundFights } from './engine/background.js'
|
import { startBackgroundFights } from './engine/background.js'
|
||||||
import { getActiveFighterCount } from './engine/orchestrator.js'
|
import { getActiveFighterCount } from './engine/orchestrator.js'
|
||||||
import { clearAllPending } from './engine/human-responses.js'
|
import { clearAllPending } from './engine/human-responses.js'
|
||||||
|
import { seedDevTournament } from './engine/dev-seed.js'
|
||||||
|
|
||||||
// Production env validation — warn but don't crash (wallet features degrade gracefully)
|
// Production env validation — warn but don't crash (wallet features degrade gracefully)
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -24,6 +25,11 @@ runMigrations()
|
|||||||
await seedMockBots()
|
await seedMockBots()
|
||||||
await seedClassicBots()
|
await seedClassicBots()
|
||||||
|
|
||||||
|
// Dev mode: seed tournament + betting data for testing
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
await seedDevTournament()
|
||||||
|
}
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 9100
|
const port = Number(process.env.PORT) || 9100
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port }, () => {
|
serve({ fetch: app.fetch, port }, () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user