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
+62 -2
View File
@@ -2,6 +2,8 @@
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import PixelGlove from '../components/PixelGlove.vue'
import SpritePreview from '../components/SpritePreview.vue'
import HumanPreview from '../components/HumanPreview.vue'
interface FightResult {
id: string
@@ -12,6 +14,20 @@ interface FightResult {
totalRounds: number
}
// Decorative crowd characters scattered around the page
const crowd = [
{ seed: 'hero_lobster', arch: 'lobster', tier: 3, type: 'bot' as const, x: 3, y: 18, size: 56, wr: 0.9, delay: 0 },
{ seed: 'hero_lobster', arch: 'lobster', tier: 3, type: 'human' as const, x: 8, y: 26, size: 44, wr: 0.9, delay: 0.2 },
{ seed: 'cool_ninja', arch: 'ninja', tier: 4, type: 'bot' as const, x: 88, y: 15, size: 52, wr: 0.7, delay: 0.5 },
{ seed: 'cool_ninja', arch: 'ninja', tier: 4, type: 'human' as const, x: 92, y: 22, size: 40, wr: 0.7, delay: 0.7 },
{ seed: 'sad_potato', arch: 'potato', tier: 1, type: 'bot' as const, x: 5, y: 72, size: 48, wr: 0.1, delay: 1.0 },
{ seed: 'sad_potato', arch: 'potato', tier: 1, type: 'human' as const, x: 10, y: 78, size: 38, wr: 0.1, delay: 1.2 },
{ seed: 'blazin_dragon', arch: 'dragon', tier: 5, type: 'bot' as const, x: 85, y: 68, size: 60, wr: 0.95, delay: 0.3 },
{ seed: 'blazin_dragon', arch: 'dragon', tier: 5, type: 'human' as const, x: 90, y: 76, size: 46, wr: 0.95, delay: 0.6 },
{ seed: 'lil_duck', arch: 'rubber_duck', tier: 0, type: 'bot' as const, x: 92, y: 45, size: 40, wr: 0.3, delay: 1.5 },
{ seed: 'chef_boy', arch: 'chef', tier: 2, type: 'bot' as const, x: 2, y: 48, size: 44, wr: 0.5, delay: 0.8 },
]
const tagline = ref('')
const fullTagline = 'A safe place to hash it out.'
const isTypingDone = ref(false)
@@ -39,8 +55,41 @@ onMounted(async () => {
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
<div class="max-w-4xl w-full text-center slide-up">
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden relative">
<!-- Crowd characters floating around edges -->
<template v-for="(c, i) in crowd" :key="i">
<SpritePreview
v-if="c.type === 'bot'"
:seed="c.seed"
:archetype="c.arch"
:tier="c.tier"
:size="c.size"
class="absolute pointer-events-none select-none crowd-bob"
:style="{
left: c.x + '%',
top: c.y + '%',
opacity: 0.35,
animationDelay: c.delay + 's',
}"
/>
<HumanPreview
v-else
:seed="c.seed"
:archetype="c.arch"
:size="c.size"
:win-rate="c.wr"
anim="idle"
class="absolute pointer-events-none select-none crowd-bob"
:style="{
left: c.x + '%',
top: c.y + '%',
opacity: 0.3,
animationDelay: c.delay + 's',
}"
/>
</template>
<div class="max-w-4xl w-full text-center slide-up relative z-10">
<!-- BIG NEON TITLE -->
<div class="mb-6">
@@ -130,3 +179,14 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.crowd-bob {
animation: crowdBob 3s ease-in-out infinite;
}
@keyframes crowdBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
</style>