feat: production deployment — Dockerfile, docker-compose, SPA serving, classic bot fights

- Add Dockerfile (multi-stage: build frontend + server, serve from single container)
- Add docker-compose.yml for Portainer stack deployment
- Server serves frontend SPA in production (static assets + SPA fallback)
- Auto-run migrations and seed mock bots on server startup
- DB path configurable via DB_PATH env var
- Add "Fight a Classic Bot" button for instant mock bot matches
- FIGHT button queues for real AI opponents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 10:42:18 +00:00
co-authored by Claude Opus 4.6
parent 120250c01a
commit 6bf9fe27b3
17 changed files with 2321 additions and 298 deletions
+60 -11
View File
@@ -11,6 +11,7 @@ const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login,
const step = ref<string>('login')
const error = ref('')
const isJoining = ref(false)
const isFightingClassic = ref(false)
const queueCount = ref(0)
let pollHandle: ReturnType<typeof setInterval> | null = null
@@ -173,6 +174,25 @@ async function fight() {
isJoining.value = false
}
async function fightClassicBot() {
if (!bot.value || isFightingClassic.value) return
isFightingClassic.value = true
error.value = ''
try {
const res = await fetch(`/api/fights/mock/${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 fight.'
}
} catch {
error.value = 'Network error.'
}
isFightingClassic.value = false
}
function handleSignOut() {
logout()
step.value = 'login'
@@ -505,17 +525,46 @@ function handleSignOut() {
</p>
</div>
<!-- Big fight button -->
<button
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-2xl tracking-[0.2em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isJoining"
@click="fight"
>
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
</button>
<!-- Fight buttons -->
<div class="space-y-3">
<!-- Main fight button real queue -->
<button
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-2xl tracking-[0.2em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isJoining || isFightingClassic"
@click="fight"
>
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
</button>
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
Queue up against a real AI bot
</p>
<!-- Classic bot button instant mock fight -->
<button
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-[0.15em]
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-30 disabled:cursor-not-allowed
flex items-center justify-center gap-3"
:disabled="isJoining || isFightingClassic"
@click="fightClassicBot"
>
<svg class="w-6 h-6 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="12" rx="2" />
<circle cx="9" cy="10" r="1.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="10" r="1.5" fill="currentColor" stroke="none" />
<line x1="8" y1="20" x2="8" y2="16" />
<line x1="16" y1="20" x2="16" y2="16" />
</svg>
{{ isFightingClassic ? 'STARTING...' : 'FIGHT A CLASSIC BOT' }}
</button>
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
Instant match against a house bot
</p>
</div>
<!-- Quick links -->
<div class="mt-5 flex gap-2">