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:
Dorian
2026-03-09 08:21:16 +00:00
co-authored by Claude Opus 4.6
parent a10c1c9cb6
commit 5ee6b6c88c
10 changed files with 141 additions and 16 deletions
+5
View File
@@ -322,6 +322,11 @@ async function replay() {
async function _doReplay() {
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
displayHpB.value = 100
logItems.value = []
+2 -2
View File
@@ -9,7 +9,7 @@ import { pickChoreography } from './fight/choreography/picker'
export { pickChoreography }
import {
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,
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,
@@ -193,7 +193,7 @@ export async function createFightScene(config: FightSceneConfig) {
const vhsTracking = (duration: number = 0.4) => _vhsTracking(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 HOME_A = W * HOME_A_RATIO
const HOME_B = W * HOME_B_RATIO
+2 -1
View File
@@ -87,10 +87,11 @@ export const HYPERDETAIL_BASE_CHANCE = 0.05 // Base hyperdetail chance
export const HYPERDETAIL_INTENSITY_SCALE = 0.45 // Scales with intensity
// --- 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_B_RATIO = 0.72 // Fighter B home X position ratio
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)
// --- Fighter scaling ---
+28 -7
View File
@@ -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) }
}
// Chest emblem (tier 4+)
if (tier >= 4) {
// ₿ Bitcoin chest badge — every fighter carries the symbol
{
const ey = by + Math.round(bh * 0.3)
px(cx + hOff - 1, ey, pal.acc, ox, oy)
px(cx + hOff, ey, pal.accLight, ox, oy)
px(cx + hOff + 1, ey, pal.acc, ox, oy)
px(cx + hOff, ey - 1, pal.acc, ox, oy)
px(cx + hOff, ey + 1, pal.acc, ox, oy)
const ec = cx + hOff
// Tier 0-1: simple orange ₿ dot
// Tier 2-3: small ₿ shape (vertical bar + bumps)
// Tier 4+: full golden ₿ with glow
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+)
+21 -4
View File
@@ -379,18 +379,26 @@ const tierClass = (t: number) => `tier-${t}`
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden"
:class="{ 'border-t-0': nostrProfile?.banner }">
<!-- 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
:seed="stats.avatarSeed || stats.name"
archetype="human"
:size="200"
:win-rate="(stats.winRate || 0) / 100"
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>
<!-- 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
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype || 'standard'"
@@ -405,9 +413,18 @@ const tierClass = (t: number) => `tier-${t}`
:tier="stats.tier"
:size="140"
: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"
/>
<!-- 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>
<!-- Name & tier -->
+6 -1
View File
@@ -357,7 +357,12 @@ async function handleFightEnd(data: any) {
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
await loadFight()
// Retry loading until rounds are populated (server may still be persisting)
for (let attempt = 0; attempt < 5; attempt++) {
await loadFight()
if (fight.value?.rounds?.length) break
await new Promise(r => setTimeout(r, 800))
}
isLive.value = false
disconnectSSE()
stopHumanPolling()