tabras / app.py
Codex
Polish demo: boss reveal screen, portraits, varied packs, paced draft, title copy
39c3669
Raw
History Blame Contribute Delete
48 kB
import base64
import mimetypes
import threading
import time
from dataclasses import replace
from pathlib import Path
from random import Random
import gradio as gr
from clients import art_client_from_env, card_client_from_env, configure_mode
from primitives import School
from ui import (
CARD_PANEL_COUNT,
HAND_PANEL_COUNT,
RunState,
board_html,
boss_splash_uri,
choose_draft_card_loading_steps,
collect_ready_battle,
collect_ready_pack,
draft_screen_html,
escape_html,
log_html,
new_run_shell,
pass_turn_steps,
play_hand_card_steps,
queue_next_pack,
refresh_art,
)
ASSETS = Path(__file__).parent / "assets"
# Encode a bundled image asset as a CSS-ready data URI (HF-Spaces safe, no static paths).
def asset_data_uri(name: str, mime: str = "image/jpeg") -> str:
encoded = base64.b64encode((ASSETS / name).read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}"
# Encode an optional bundled image asset as a CSS-ready data URI.
def optional_asset_data_uri(name: str) -> str:
path = optional_asset_path(name)
if path is None:
return ""
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}"
# Return the first matching asset path, accepting common image extensions.
def optional_asset_path(name: str) -> Path | None:
path = ASSETS / name
if path.exists():
return path
if path.suffix:
for suffix in (".jpg", ".jpeg", ".png", ".webp"):
candidate = path.with_suffix(suffix)
if candidate.exists():
return candidate
return None
INN_BG = asset_data_uri("inn_bg.jpg")
WOOD_BG = asset_data_uri("wood_board.jpg")
HEAD = """
<script>
function tabrasClick(id) {
const el = document.getElementById(id);
if (!el) return;
(el.tagName === 'BUTTON' ? el : el.querySelector('button')).click();
}
function tabrasPlay(id, card) {
if (card.classList.contains('launching')) return;
card.classList.add('launching');
tabrasClick(id);
}
</script>
"""
CSS = """
.gradio-container {
background: linear-gradient(rgba(10, 6, 14, 0.82), rgba(6, 4, 9, 0.9)), url("__INN_BG__") center / cover fixed;
color: #f4ead9;
max-width: 100% !important;
}
footer { display: none !important; }
.hidden-controls { display: none !important; }
*, *::before, *::after { box-sizing: border-box; }
gradio-app, body {
background: linear-gradient(rgba(10, 6, 14, 0.82), rgba(6, 4, 9, 0.9)), url("__INN_BG__") center / cover fixed !important;
overflow-x: hidden;
}
.gradio-container .block, .gradio-container .form, .gradio-container .gr-group, .gradio-container .styler {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
button.primary {
background: linear-gradient(180deg, #f2c24d, #a96f17) !important;
color: #3a2403 !important;
border: 2px solid #f6dd9a !important;
font-weight: 800 !important;
}
#play-now-btn {
width: auto !important;
min-width: 170px !important;
max-width: 220px !important;
align-self: center !important;
}
#play-now-btn button {
width: auto !important;
min-width: 170px !important;
padding: 10px 28px !important;
}
#start-draft-btn {
width: auto !important;
min-width: 150px !important;
max-width: 200px !important;
align-self: center !important;
margin: 0 auto !important;
}
#start-draft-btn button {
width: auto !important;
min-width: 150px !important;
padding: 10px 24px !important;
}
.tabras-title {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
gap: 26px;
padding: 18vh 20px 0;
}
.tabras-title h1 {
font-size: 92px;
margin: 0;
color: #f6f0ff;
text-shadow: 0 0 30px rgba(160, 90, 255, 0.6), 0 3px 18px rgba(0, 0, 0, 0.65);
}
.tabras-sub {
display: flex;
flex-direction: column;
gap: 10px;
color: #efe6ff;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8);
}
.tabras-sub .subline {
font-size: 21px;
line-height: 1.4;
white-space: nowrap;
}
#play-now-btn { margin-top: 26px !important; }
.gradio-container .setup-panel {
background: rgba(26, 20, 46, 0.82) !important;
border: 2px solid rgba(190, 160, 255, 0.3) !important;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important;
padding: 18px !important;
border-radius: 14px !important;
max-width: 560px;
margin: 8vh auto 0 !important;
}
.step-kicker {
color: #ffd35c;
font-size: 13px;
font-weight: 900;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 8px;
}
.setup-panel h2 {
margin: 0 0 12px;
color: #f6f0ff;
font-size: 28px;
}
.choice-row button {
min-height: 46px;
}
.selector-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
width: min(980px, 92vw);
margin: 0 auto;
}
.selector-panel {
position: relative;
min-height: 360px;
border: 2px solid rgba(190, 160, 255, 0.34);
border-radius: 14px;
overflow: hidden;
cursor: pointer;
background: var(--selector-fallback);
background-size: cover;
background-position: center;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42);
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
}
.selector-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.selector-panel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(12, 8, 30, 0.06), rgba(12, 8, 30, 0.88));
pointer-events: none;
}
.selector-panel:hover {
transform: translateY(-8px);
border-color: #ffd35c;
box-shadow: 0 0 28px rgba(255, 211, 92, 0.22), 0 24px 58px rgba(0, 0, 0, 0.52);
filter: saturate(1.1);
}
.selector-label {
position: absolute;
left: 18px;
right: 18px;
bottom: 18px;
color: #f6f0ff;
font-size: 26px;
font-weight: 900;
text-shadow: 0 3px 14px rgba(0, 0, 0, 0.82);
z-index: 2;
}
.selector-copy {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
padding: 18px;
color: #f6f0ff;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
opacity: 0;
background: linear-gradient(180deg, rgba(8, 4, 20, 0.24), rgba(8, 4, 20, 0.88));
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.9);
transition: opacity 0.16s ease;
z-index: 3;
}
.selector-panel:hover .selector-copy {
opacity: 1;
}
@media (max-width: 900px) {
.selector-grid { grid-template-columns: 1fr; }
.selector-panel { min-height: 240px; }
}
/* ---- card faces ---- */
.tabras-card {
position: relative;
border-radius: 10px;
padding: 26px 10px 8px;
background: linear-gradient(180deg, #2a2347, #151028);
border: 2px solid #8d7bd6;
color: #f0eaff;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 4px;
}
.tabras-card.empty { opacity: 0.35; }
.card-cost {
position: absolute;
top: -9px;
left: -9px;
width: 34px;
height: 34px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #7fd4ff, #1668c9);
border: 2px solid #d8f1ff;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 17px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
z-index: 2;
}
.card-name { font-size: 13px; font-weight: 800; line-height: 1.15; min-height: 30px; }
.school-fire .card-name { color: #ff9d80; }
.school-ice .card-name { color: #8fdfff; }
.school-earth .card-name { color: #aaf08a; }
.tabras-card.school-fire { border-color: #e06a48; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35), 0 0 14px rgba(255, 110, 70, 0.22); }
.tabras-card.school-ice { border-color: #4fb6e8; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35), 0 0 14px rgba(90, 200, 255, 0.22); }
.tabras-card.school-earth { border-color: #6fc454; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35), 0 0 14px rgba(130, 230, 110, 0.22); }
.card-art {
height: 64px;
border-radius: 6px;
border: 1px solid rgba(200, 170, 255, 0.3);
background: linear-gradient(135deg, rgba(80, 60, 140, 0.5), rgba(20, 40, 70, 0.55));
overflow: hidden;
}
.card-art.generated {
border-style: solid;
background-color: #0d0a16;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.pending-art {
position: relative;
border-color: rgba(190, 160, 255, 0.28);
}
.pending-art::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.12) 42%, transparent 70%);
transform: translateX(-120%);
animation: art-sheen 1.8s ease-in-out infinite;
}
.school-art-fire { background: radial-gradient(circle at 40% 35%, rgba(255, 155, 80, 0.34), transparent 32%), linear-gradient(135deg, #3a1724, #180d1f 72%); }
.school-art-ice { background: radial-gradient(circle at 45% 35%, rgba(125, 220, 255, 0.32), transparent 34%), linear-gradient(135deg, #172c4a, #111328 72%); }
.school-art-earth { background: radial-gradient(circle at 45% 38%, rgba(170, 240, 138, 0.28), transparent 34%), linear-gradient(135deg, #20331f, #111328 72%); }
@keyframes art-sheen {
from { transform: translateX(-120%); }
to { transform: translateX(120%); }
}
.card-rules { font-size: 11.5px; line-height: 1.3; min-height: 44px; color: #ece4ff; }
.card-flavor {
color: #b9a8e0;
font-size: 10px;
font-style: italic;
border-top: 1px solid rgba(200, 170, 255, 0.2);
padding-top: 4px;
}
/* ---- draft screen ---- */
.draft-board {
width: min(1220px, calc(100vw - 56px));
min-height: 82vh;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 20px 18px;
}
.draft-banner { text-align: center; }
.draft-banner h2 { margin: 0; color: #ffd35c; letter-spacing: 0.08em; text-transform: uppercase; text-shadow: 0 0 18px rgba(255, 211, 92, 0.45); font-size: 30px; }
.draft-banner p { margin: 6px 0 0; color: #b9a8e0; font-size: 17px; }
.draft-pack {
display: grid;
grid-template-columns: repeat(3, minmax(0, 300px));
gap: clamp(16px, 2vw, 30px);
justify-content: center;
align-items: stretch;
width: 100%;
}
.draft-card {
width: 100%;
min-width: 0;
min-height: 405px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
animation: pack-in 0.5s ease backwards;
}
.draft-pack .draft-card:nth-child(2) { animation-delay: 0.1s; }
.draft-pack .draft-card:nth-child(3) { animation-delay: 0.2s; }
.draft-card:hover { transform: translateY(-10px) scale(1.045); box-shadow: 0 0 22px rgba(56, 232, 210, 0.5); }
@keyframes pack-in {
from { opacity: 0; transform: translateY(28px) scale(0.92); }
to { opacity: 1; transform: none; }
}
.draft-pack .draft-card.fading { animation: pack-out 0.7s ease forwards; animation-delay: 0s; pointer-events: none; cursor: default; }
.draft-pack .draft-card.picked { animation: picked-out 0.7s ease forwards; animation-delay: 0s; pointer-events: none; cursor: default; }
@keyframes pack-out {
from { opacity: 1; }
to { opacity: 0; transform: translateY(-14px) scale(0.94); filter: grayscale(0.7); }
}
@keyframes picked-out {
30% { opacity: 1; transform: scale(1.08); box-shadow: 0 0 44px rgba(242, 194, 77, 0.95); }
to { opacity: 0; transform: translateY(-34px) scale(1.02); box-shadow: 0 0 30px rgba(242, 194, 77, 0.6); }
}
.draft-card .card-name { font-size: 17px; min-height: 44px; }
.draft-card .card-art { height: 112px; }
.draft-card .card-rules { font-size: 13px; min-height: 64px; }
.draft-card .card-flavor { font-size: 12px; }
.starter-board { min-height: 82vh; gap: 22px; }
.starter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(190px, 1fr));
gap: 18px;
width: min(920px, 92vw);
}
.starter-card { min-height: 260px; }
.starter-card .card-name { font-size: 17px; min-height: 40px; }
.starter-card .card-art { height: 70px; }
.starter-card .card-rules { font-size: 13.5px; min-height: 46px; }
.starter-card .card-flavor { font-size: 12px; }
.loading-board { gap: 30px; }
.draft-loading {
width: min(520px, 86vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 22px;
border: 1px solid rgba(255, 211, 92, 0.35);
border-radius: 12px;
background: rgba(12, 8, 30, 0.6);
box-shadow: 0 0 24px rgba(255, 211, 92, 0.12);
}
.loading-title {
color: #ffd35c;
font-size: 20px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loading-subtitle {
color: #b9a8e0;
font-size: 13px;
text-align: center;
}
.rules-screen {
min-height: 74vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.rules-card {
width: min(780px, 92vw);
border: 2px solid rgba(255, 211, 92, 0.32);
border-radius: 16px;
background: rgba(16, 10, 34, 0.82);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45), 0 0 28px rgba(255, 211, 92, 0.12);
padding: 30px;
}
.rules-card h1 {
margin: 0 0 12px;
color: #ffd35c;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.rules-card p {
color: #d9d0f0;
font-size: 16px;
line-height: 1.45;
}
.reveal-screen {
min-height: 64vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding-top: 12px;
text-align: center;
animation: reveal-in 0.6s ease backwards;
}
#reveal-next-btn {
width: auto !important;
min-width: 150px !important;
max-width: 200px !important;
align-self: center !important;
margin: 4px auto 0 !important;
}
#reveal-next-btn button {
width: auto !important;
min-width: 150px !important;
padding: 9px 26px !important;
}
@keyframes reveal-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } }
.reveal-kicker {
font-size: 15px;
font-weight: 900;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #f06bff;
text-shadow: 0 0 18px rgba(232, 91, 255, 0.55);
}
.reveal-art {
width: min(440px, 86vw);
height: min(440px, 60vh);
border-radius: 16px;
border: 3px solid #b14ad0;
background-size: cover;
background-position: center top;
background-color: #160e26;
box-shadow: 0 0 48px rgba(177, 74, 208, 0.45), 0 24px 60px rgba(0, 0, 0, 0.6);
}
.reveal-name {
font-size: 40px;
font-weight: 900;
color: #f6f0ff;
letter-spacing: 0.04em;
text-shadow: 0 0 26px rgba(232, 91, 255, 0.5), 0 3px 12px rgba(0, 0, 0, 0.7);
}
.reveal-quote {
font-size: 19px;
font-style: italic;
color: #d9c4ee;
max-width: 540px;
}
.versus {
display: flex;
align-items: center;
justify-content: center;
gap: 28px;
margin: 6px 0 14px;
}
.versus-side { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.versus-face {
width: 132px;
height: 132px;
border-radius: 50% 50% 46% 46%;
border: 4px solid #b08a4f;
background-size: cover;
background-position: center top;
background-color: #1b1430;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55);
}
.versus-side:last-child .versus-face { border-color: #b14ad0; box-shadow: 0 0 26px rgba(232, 91, 255, 0.4), 0 12px 30px rgba(0, 0, 0, 0.55); }
.versus-face-text { display: flex; align-items: center; justify-content: center; color: #f4d69b; font-weight: 900; font-size: 22px; }
.versus-name { font-weight: 800; color: #f6f0ff; font-size: 18px; }
.versus-tag { font-size: 12px; color: #b9a8e0; letter-spacing: 0.04em; }
.versus-vs { font-size: 30px; font-weight: 900; color: #ffd35c; text-shadow: 0 0 16px rgba(255, 211, 92, 0.5); }
.villain-story { text-align: center; color: #d9d0f0; font-style: italic; max-width: 540px; margin: 0 auto; }
.rules-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.rule-tile {
border: 1px solid rgba(190, 160, 255, 0.28);
border-radius: 10px;
padding: 14px;
background: rgba(42, 35, 71, 0.62);
}
.rule-tile b {
display: block;
color: #f6f0ff;
margin-bottom: 6px;
}
.rule-tile span {
color: #b9a8e0;
font-size: 14px;
line-height: 1.35;
}
.loading-bar {
width: 100%;
height: 12px;
overflow: hidden;
border-radius: 999px;
background: rgba(20, 14, 50, 0.95);
border: 1px solid rgba(95, 220, 255, 0.35);
}
.loading-bar span {
display: block;
width: 40%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #38e8d2, #ffd35c, #ff9d80);
animation: loading-sweep 1.15s ease-in-out infinite;
}
@keyframes loading-sweep {
0% { transform: translateX(-110%); }
100% { transform: translateX(260%); }
}
.deck-strip { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; max-width: 1100px; align-items: center; }
.deck-strip-label { font-weight: 800; color: #f2c24d; margin-right: 6px; font-size: 18px; }
.deck-chip { background: rgba(10, 6, 30, 0.5); border: 1px solid #5b4a8f; border-radius: 12px; padding: 3px 12px; font-size: 13px; }
.deck-chip b { color: #38e8d2; }
.deck-chip.anchor { border-color: #ffd35c; box-shadow: 0 0 8px rgba(255, 211, 92, 0.4); }
@media (max-width: 900px) {
.draft-board { min-height: auto; justify-content: flex-start; }
.draft-pack { grid-template-columns: 1fr; max-width: 320px; }
.draft-card { width: min(300px, 92vw); min-height: 405px; }
.starter-grid { grid-template-columns: repeat(2, minmax(150px, 1fr)); }
}
/* ---- battle board ---- */
.board {
position: relative;
border: 4px solid #7a5630;
border-radius: 22px;
padding: 8px 14px;
background: linear-gradient(rgba(24, 13, 5, 0.42), rgba(14, 7, 2, 0.6)), url("__WOOD_BG__") center / cover;
box-shadow: inset 0 0 70px rgba(0, 0, 0, 0.62), 0 0 30px rgba(150, 95, 40, 0.25), 0 24px 60px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.zone { position: relative; display: flex; align-items: center; justify-content: center; }
.zone-center { display: flex; flex-direction: column; align-items: center; gap: 6px; flex: 1; }
.piles { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; }
.pile {
width: 64px;
text-align: center;
border: 2px solid #5b4a8f;
border-radius: 8px;
padding: 6px 0 3px;
background: repeating-linear-gradient(45deg, #1c1535 0, #1c1535 6px, #251c45 6px, #251c45 12px);
}
.pile span { font-weight: 900; font-size: 18px; color: #ffd35c; }
.pile label { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: #9d8fc7; }
.fatigue-warn { color: #ff8d7a; font-size: 10px; font-weight: 800; }
.hero { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.hero-frame { position: relative; width: 124px; height: 124px; }
.hero-face {
width: 100%;
height: 100%;
border-radius: 50% 50% 46% 46%;
border: 4px solid #b08a4f;
background: radial-gradient(circle at 50% 30%, #4a3e72, #1b1430 75%);
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 22px;
color: #f4d69b;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
}
.hero-face.portrait {
background-size: cover;
background-position: center top;
}
.hp-gem, .block-gem, .ward-gem {
position: absolute;
width: 42px;
height: 42px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 19px;
color: #fff;
text-shadow: 0 1px 2px #000;
border: 3px solid #f3d492;
}
.hp-gem { right: -10px; bottom: -6px; background: radial-gradient(circle at 35% 30%, #e0524d, #8f1612); }
.block-gem { left: -10px; bottom: -6px; background: radial-gradient(circle at 35% 30%, #c9cdd4, #5a626e); border-color: #eef2f7; }
.ward-gem { left: -10px; top: -6px; background: radial-gradient(circle at 35% 30%, #8fd0ff, #1d4d8f); border-color: #d8f1ff; }
.hero-name { font-weight: 800; color: #f4d69b; text-shadow: 0 2px 6px #000; }
.hero-frame.hit { animation: hero-shake 0.45s ease; }
.hero-frame.hit .hp-gem { animation: gem-flash 0.7s ease; }
@keyframes hero-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-7px) rotate(-2deg); }
40% { transform: translateX(6px) rotate(2deg); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
@keyframes gem-flash {
0%, 100% { filter: none; }
30% { filter: brightness(2.2) saturate(1.6); transform: scale(1.25); }
}
.dmg-pop {
position: absolute;
left: 50%;
top: 22%;
transform: translateX(-50%);
font-size: 34px;
font-weight: 900;
color: #ff5340;
text-shadow: 0 0 12px rgba(255, 60, 30, 0.9), 0 2px 3px #000;
pointer-events: none;
z-index: 50;
animation: dmg-float 1.4s ease-out forwards;
}
@keyframes dmg-float {
from { opacity: 0; transform: translateX(-50%) translateY(16px) scale(0.5); }
25% { opacity: 1; transform: translateX(-50%) translateY(-8px) scale(1.3); }
to { opacity: 0; transform: translateX(-50%) translateY(-54px) scale(1); }
}
.chips { display: flex; gap: 6px; min-height: 18px; }
.chip { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 8px; border: 1px solid; }
.chip.charge { color: #ffd98c; border-color: #c9982f; background: rgba(120, 85, 10, 0.35); }
.chip.weak { color: #a8c6ff; border-color: #4a6fb5; background: rgba(30, 55, 110, 0.35); }
.chip.vuln { color: #ffb3c2; border-color: #b54a64; background: rgba(110, 25, 45, 0.35); }
.enemy-hand { display: flex; justify-content: center; margin-bottom: -10px; }
.enemy-card-back {
width: 52px;
height: 72px;
border-radius: 6px;
border: 2px solid #7a5fc0;
margin: 0 -10px;
background: repeating-linear-gradient(45deg, #2a1745 0, #2a1745 7px, #371d5c 7px, #371d5c 14px);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.4);
transform: rotate(3deg);
}
.enemy-card-back:nth-child(odd) { transform: rotate(-3deg); }
.battlefield {
position: relative;
min-height: 118px;
margin: 8px 36px;
border-radius: 14px;
background: rgba(10, 6, 30, 0.35);
box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 6px 150px 6px 16px;
}
.round-banner {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #cfc3f2;
font-size: 13px;
}
.end-turn {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
padding: 13px 24px;
border-radius: 26px;
border: 3px solid #f6dd9a;
background: linear-gradient(180deg, #f2c24d, #a96f17);
color: #3a2403;
font-weight: 900;
letter-spacing: 0.06em;
cursor: pointer;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
.end-turn:hover { filter: brightness(1.12); }
.end-turn.pulse { animation: end-pulse 1.2s ease-in-out infinite; }
@keyframes end-pulse {
0%, 100% { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); }
50% { box-shadow: 0 0 26px rgba(246, 221, 154, 0.9), 0 6px 18px rgba(0, 0, 0, 0.45); }
}
.board.quake { animation: board-quake 0.5s ease; }
@keyframes board-quake {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-6px, 3px); }
40% { transform: translate(5px, -4px); }
60% { transform: translate(-4px, 2px); }
80% { transform: translate(3px, -2px); }
}
.pending-row { display: flex; gap: 8px; min-height: 34px; justify-content: center; }
.showcase { display: flex; gap: 18px; justify-content: center; align-items: flex-start; min-height: 0; perspective: 900px; }
.play-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.play-slot.fresh { animation: play-in 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) backwards; }
.play-label {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 1px 10px;
border-radius: 10px;
}
.play-label.you { color: #7df5e2; background: rgba(20, 90, 80, 0.45); border: 1px solid #2aa896; }
.play-label.boss { color: #f0b8ff; background: rgba(110, 30, 130, 0.45); border: 1px solid #b14ad0; }
.played-card { width: 132px; }
.played-card.player-play { box-shadow: 0 0 22px rgba(56, 232, 210, 0.6); }
.played-card.enemy-play { box-shadow: 0 0 22px rgba(232, 91, 255, 0.6); border-color: #b14ad0; }
@keyframes play-in {
from { transform: translateY(120px) rotateY(90deg) scale(0.4); opacity: 0; }
60% { transform: translateY(-6px) rotateY(0deg) scale(1.12); opacity: 1; }
to { transform: none; opacity: 1; }
}
.token { display: flex; flex-direction: column; align-items: center; padding: 2px 10px; border-radius: 10px; font-size: 11px; border: 2px solid; line-height: 1.1; }
.token b { font-size: 16px; }
.token.bomb { border-color: #e0524d; background: rgba(120, 20, 10, 0.5); color: #ffb9a8; }
.token.burn { border-color: #f2913d; background: rgba(140, 70, 10, 0.45); color: #ffd9a8; }
.mana-bar { display: flex; align-items: center; gap: 6px; }
.mana { width: 19px; height: 19px; transform: rotate(45deg); border-radius: 5px; background: #1d2c3c; border: 2px solid #2f4a66; }
.mana.filled {
background: radial-gradient(circle at 35% 30%, #7fd4ff, #1668c9);
border-color: #bfe9ff;
box-shadow: 0 0 8px rgba(80, 170, 255, 0.8);
}
.mana-count { margin-left: 8px; font-weight: 800; color: #bfe9ff; }
.hand-fan { display: flex; justify-content: center; align-items: flex-end; padding: 26px 0 6px; min-height: 240px; }
.hand-card {
width: 150px;
margin: 0 -16px;
transform: rotate(var(--rot)) translateY(var(--ty));
transform-origin: 50% 130%;
transition: transform 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
box-shadow: 0 0 14px rgba(56, 232, 210, 0.55);
border-color: #45e8c8;
}
.hand-card:hover {
transform: rotate(0deg) translateY(-48px) scale(1.45);
z-index: 99 !important;
box-shadow: 0 0 24px rgba(56, 232, 210, 0.5), 0 24px 50px rgba(0, 0, 0, 0.6);
}
.hand-card.unplayable { filter: grayscale(0.8) brightness(0.7); cursor: default; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); border-color: #4a3f6e; }
.hand-card.unplayable:hover { box-shadow: 0 24px 50px rgba(0, 0, 0, 0.6); }
.hand-card.launching {
transform: translateY(-170px) scale(1.08) rotate(0deg) !important;
opacity: 0;
transition: transform 0.3s ease-in, opacity 0.3s ease-in;
pointer-events: none;
}
.round-splash {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
z-index: 150;
pointer-events: none;
border-radius: 18px;
animation: splash-bg 1.9s ease forwards;
}
@keyframes splash-bg {
0% { background: rgba(6, 3, 18, 0); }
18% { background: rgba(6, 3, 18, 0.78); }
72% { background: rgba(6, 3, 18, 0.78); }
100% { background: rgba(6, 3, 18, 0); }
}
.splash-round {
font-size: 50px;
font-weight: 900;
letter-spacing: 0.3em;
color: #ffd35c;
text-shadow: 0 0 34px rgba(255, 211, 92, 0.9), 0 4px 8px #000;
animation: splash 1.9s ease forwards;
}
.splash-initiative {
font-size: 24px;
font-weight: 900;
letter-spacing: 0.26em;
animation: splash 1.65s ease 0.25s both;
}
.splash-initiative.you { color: #38e8d2; text-shadow: 0 0 24px rgba(56, 232, 210, 0.85); }
.splash-initiative.boss { color: #f06bff; text-shadow: 0 0 24px rgba(232, 91, 255, 0.85); }
@keyframes splash {
0% { opacity: 0; transform: scale(2.2); }
25% { opacity: 1; transform: scale(1); }
70% { opacity: 1; }
100% { opacity: 0; transform: scale(0.92); }
}
.boss-thinking {
position: absolute;
left: 50%;
bottom: -6px;
transform: translateX(-50%);
z-index: 60;
padding: 5px 16px;
border-radius: 14px;
background: rgba(20, 10, 36, 0.94);
border: 1px solid #b14ad0;
color: #f0c4ff;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.08em;
white-space: nowrap;
animation: think-pulse 1.1s ease-in-out infinite;
}
@keyframes think-pulse {
0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); }
50% { opacity: 1; transform: translateX(-50%) scale(1.06); }
}
.board.thinking .hero.enemy .hero-face { box-shadow: 0 0 28px rgba(232, 91, 255, 0.6); }
.winner-banner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: center;
background: rgba(8, 5, 22, 0.78);
z-index: 200;
border-radius: 18px;
}
.winner-banner h1 { font-size: 64px; letter-spacing: 0.2em; margin: 0; animation: banner-in 0.7s cubic-bezier(0.2, 1.6, 0.4, 1) backwards; }
@keyframes banner-in {
from { transform: scale(2.6); opacity: 0; letter-spacing: 0.6em; }
to { transform: scale(1); opacity: 1; letter-spacing: 0.2em; }
}
.winner-banner.victory h1 { color: #ffd98c; text-shadow: 0 0 30px rgba(255, 200, 90, 0.7); }
.winner-banner.defeat h1 { color: #ff7a6a; text-shadow: 0 0 30px rgba(255, 70, 40, 0.6); }
.winner-banner.draw h1 { color: #b9a8e0; }
.new-run {
padding: 12px 30px;
border-radius: 24px;
border: 3px solid #f6dd9a;
background: linear-gradient(180deg, #f2c24d, #a96f17);
font-weight: 900;
cursor: pointer;
color: #3a2403;
}
.game-over .hand-fan, .game-over .end-turn { pointer-events: none; }
.log-panel { background: rgba(18, 14, 38, 0.85) !important; border: 2px solid #5b4a8f; border-radius: 14px; padding: 8px; }
.log-scroll {
display: flex;
flex-direction: column-reverse;
gap: 4px;
max-height: 600px;
overflow-y: auto;
font-size: 11.5px;
color: #d9d0f0;
}
.log-line { padding: 4px 8px; border-left: 3px solid transparent; border-radius: 4px; line-height: 1.35; }
.log-line b { color: #ffd35c; font-size: 12.5px; }
.log-owner { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; opacity: 0.7; }
.log-rules { display: block; font-size: 10.5px; opacity: 0.85; }
.log-play.log-you { border-left-color: #38e8d2; background: rgba(30, 120, 110, 0.22); }
.log-play.log-boss { border-left-color: #e85bff; background: rgba(120, 40, 140, 0.22); }
.log-round {
text-align: center;
color: #ffd35c;
font-weight: 800;
letter-spacing: 0.06em;
margin-top: 8px;
border-top: 1px solid rgba(255, 211, 92, 0.35);
padding-top: 6px;
}
.log-winner { text-align: center; color: #ffd35c; font-weight: 900; font-size: 13px; border: 1px solid #ffd35c; background: rgba(255, 211, 92, 0.14); }
.log-muted { opacity: 0.55; font-style: italic; }
.log-draft { color: #aaf08a; }
.log-scroll > .log-line:first-child { box-shadow: inset 0 0 0 1px rgba(255, 211, 92, 0.35); }
"""
CSS = CSS.replace("__INN_BG__", INN_BG).replace("__WOOD_BG__", WOOD_BG)
# Build all Gradio components for the Tabras app.
def build_app() -> gr.Blocks:
with gr.Blocks(title="Tabras") as app:
state = gr.State(None)
screen_state = gr.State("title")
with gr.Group(visible=True, elem_id="title-screen") as title_group:
gr.HTML(
"<div class='tabras-title'><h1>Tabras</h1>"
"<div class='tabras-sub'>"
"<div class='subline'>You're a spellcaster fighting an evil wizard.</div>"
"<div class='subline'>Draft your spellbook by selecting nine cards that are authored by MiniCPM and drawn by SDXL-Turbo.</div>"
"<div class='subline'>Are you powerful enough to destroy the Nemotron-enhanced wizard?</div>"
"</div></div>"
)
play_now = gr.Button("Play Now", variant="primary", elem_id="play-now-btn")
with gr.Group(visible=False, elem_id="name-screen") as name_group:
with gr.Column(elem_classes=["setup-panel"]):
gr.HTML("<div class='step-kicker'>Step 1 of 3</div><h2>Name your challenger</h2>")
name = gr.Textbox(label="Name", value="Vishnu")
name_next = gr.Button("Continue", variant="primary")
with gr.Group(visible=False, elem_id="school-screen") as school_group:
with gr.Column(elem_classes=["setup-panel"]):
gr.HTML("<div class='step-kicker'>Step 3 of 3</div><h2>Choose your spell school</h2>")
school_view = gr.HTML(school_selector_html("Dark Fantasy"))
with gr.Group(visible=False, elem_id="background-screen") as background_group:
with gr.Column(elem_classes=["setup-panel"]):
gr.HTML("<div class='step-kicker'>Step 2 of 3</div><h2>Choose your background</h2>")
gr.HTML(background_selector_html())
with gr.Group(visible=False, elem_id="reveal-screen") as reveal_group:
reveal_view = gr.HTML()
reveal_next = gr.Button("Continue", variant="primary", elem_id="reveal-next-btn")
with gr.Group(visible=False, elem_id="rules-screen") as rules_group:
rules_view = gr.HTML()
continue_rules = gr.Button("Start Draft", variant="primary", elem_id="start-draft-btn")
with gr.Group(visible=False, elem_id="draft-screen") as draft_group:
draft_view = gr.HTML()
with gr.Group(visible=False, elem_id="battle-screen") as battle_group:
with gr.Row():
with gr.Column(scale=4):
board_view = gr.HTML()
with gr.Column(scale=1, min_width=240, elem_classes=["log-panel"]):
gr.Markdown("### Battle log")
log_view = gr.HTML()
with gr.Row(elem_classes=["hidden-controls"]):
hand_buttons = [gr.Button("", elem_id=f"hand-btn-{index}") for index in range(HAND_PANEL_COUNT)]
draft_buttons = [gr.Button("", elem_id=f"draft-btn-{index}") for index in range(CARD_PANEL_COUNT)]
background_buttons = [gr.Button("", elem_id=f"background-btn-{slug}") for slug in ("dark-fantasy", "cyberpunk", "anime")]
school_buttons = [gr.Button("", elem_id=f"school-btn-{slug}") for slug in ("fire", "ice", "earth")]
end_turn = gr.Button("", elem_id="end-turn-btn")
restart = gr.Button("", elem_id="restart-btn")
world_state = gr.State("Dark Fantasy")
outputs = [
state,
screen_state,
title_group,
name_group,
school_group,
background_group,
reveal_group,
rules_group,
draft_group,
battle_group,
reveal_view,
rules_view,
draft_view,
board_view,
log_view,
school_view,
world_state,
]
play_now.click(show_name, outputs=outputs)
name_next.click(show_background, outputs=outputs)
for value, button in zip(("Dark Fantasy", "Cyberpunk", "Anime"), background_buttons):
button.click(value_handler(choose_background, value), outputs=outputs)
for value, button in zip(("fire", "ice", "earth"), school_buttons):
button.click(value_handler(choose_school, value), inputs=[name, world_state], outputs=outputs)
reveal_next.click(show_rules, inputs=[state], outputs=outputs)
continue_rules.click(show_draft_from_rules, inputs=[state], outputs=outputs)
for index, button in enumerate(draft_buttons):
button.click(indexed_handler(draft_pick, index), inputs=[state], outputs=outputs)
for index, button in enumerate(hand_buttons):
button.click(indexed_handler(play_card, index), inputs=[state], outputs=outputs)
end_turn.click(end_player_turn, inputs=[state], outputs=outputs)
restart.click(show_name, outputs=outputs)
gr.Timer(0.8).tick(refresh_screen, inputs=[state, screen_state, world_state], outputs=outputs)
return app
# Bind one index into a streaming handler as a true generator function.
def indexed_handler(handler, index: int):
def stream(run_state):
yield from handler(run_state, index)
return stream
# Bind one string value into a handler.
def value_handler(handler, value: str):
def call(*args):
return handler(value, *args)
return call
# Show the name prompt.
def show_name() -> list[object]:
warm_models()
return render(None, "name")
_warmed = False
# Preload the heavy models on a background thread the moment a run begins, so the
# GPU warms during setup navigation (name/world/school/rules) instead of stalling
# the first draft pack and first art. Cached clients make this a one-time cost.
def warm_models() -> None:
global _warmed
if _warmed:
return
_warmed = True
def _load() -> None:
try:
card_client_from_env() # loads + caches the card model
art = art_client_from_env()
if art is not None:
art.create_art("a single torch flame on a dark stone wall") # forces the art pipe to load
except Exception:
pass
threading.Thread(target=_load, daemon=True).start()
# Show the school prompt.
def show_school() -> list[object]:
return render(None, "school")
# Show the background prompt.
def show_background() -> list[object]:
return render(None, "background")
# Store one background choice and advance to school selection.
def choose_background(world: str) -> list[object]:
return render(None, "school", world)
# Start rules after one school selector is clicked.
def choose_school(school: str, name: str, world: str) -> list[object]:
return start_rules(name, school, world)
# Reveal the boss, then start deck generation in the background. The reveal +
# rules screens give the first pack time to forge before the draft.
def start_rules(name: str, school: str, world: str) -> list[object]:
client = card_client_from_env()
art_client = art_client_from_env()
run_state = new_run_shell(name, world, school_as_literal(school), seed=Random().getrandbits(32))
run_state = queue_next_pack(run_state, client, art_client)
return render(run_state, "reveal")
# Move from the boss reveal to the rules screen.
def show_rules(run_state: RunState | None) -> list[object]:
if run_state is None:
return render(None, "name")
return render(refresh_art(run_state), "rules")
# Move from rules to draft, showing deck loading if the first pack is not ready.
def show_draft_from_rules(run_state: RunState | None) -> list[object]:
if run_state is None:
return render(None, "name")
run_state = collect_ready_pack(refresh_art(run_state), card_client_from_env(), art_client_from_env())
if run_state is not None and not run_state.current_pack and run_state.duel is None:
run_state = replace(run_state, loading="Loading your deck")
return render(run_state, "draft")
# Choose one draft card, streaming the paced battle opening on the final pick.
def draft_pick(run_state: RunState | None, index: int):
if run_state is None:
yield render(None, "name")
return
client = card_client_from_env()
art_client = art_client_from_env()
yield from paced_frames(choose_draft_card_loading_steps(run_state, index, client, art_client))
# Play one hand card by index, streaming the paced boss response.
def play_card(run_state: RunState | None, index: int):
if run_state is None:
yield render(None, "name")
return
yield from paced_frames(play_hand_card_steps(run_state, index))
# End the player turn, streaming the paced boss response.
def end_player_turn(run_state: RunState | None):
if run_state is None:
yield render(None, "name")
return
yield from paced_frames(pass_turn_steps(run_state))
# Refresh visible generated art without advancing the game.
def refresh_screen(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]:
if screen not in {"title", "name", "school", "background", "reveal", "rules", "draft", "battle"}:
screen = "title"
if screen in {"reveal", "rules", "draft"}:
client, art_client = card_client_from_env(), art_client_from_env()
run_state = collect_ready_pack(refresh_art(run_state), client, art_client)
run_state = collect_ready_battle(run_state, client, art_client)
if run_state is not None and run_state.duel is not None:
screen = "battle"
return render(refresh_art(run_state), screen, world)
# Yield rendered frames, letting each dramatic beat linger on screen.
def paced_frames(steps):
previous = None
for state in steps:
if previous is not None:
time.sleep(frame_delay(previous))
yield render(state, "battle" if state.duel else "draft")
previous = state
# Return how long one frame should stay on screen before the next.
def frame_delay(state: RunState) -> float:
if state.round_flash:
return 1.4
if state.boss_thinking:
return 0.35
if state.pack_fading >= 0:
return 0.45
return 0.45
# Render all app outputs.
def render(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]:
run_state = refresh_art(run_state)
world = run_state.world if run_state is not None else world
return [
run_state,
screen,
gr.update(visible=screen == "title"),
gr.update(visible=screen == "name"),
gr.update(visible=screen == "school"),
gr.update(visible=screen == "background"),
gr.update(visible=screen == "reveal"),
gr.update(visible=screen == "rules"),
gr.update(visible=screen == "draft"),
gr.update(visible=screen == "battle"),
reveal_html(run_state),
rules_html(run_state),
draft_screen_html(run_state),
board_html(run_state),
log_html(run_state),
school_selector_html(world),
world,
]
# Return HTML for image-backed background choices.
def background_selector_html() -> str:
cards = (
selector_card("background-btn-dark-fantasy", "Dark Fantasy", "You are a mage in a forlorn, lost, and dark world.", "darkFantasy.png", "#25152d", "#5f315f"),
selector_card("background-btn-cyberpunk", "Cyberpunk", "You are a spellrunner in a neon city where old magic haunts new machines.", "cyberpunk.png", "#102438", "#2dd7d0"),
selector_card("background-btn-anime", "Anime", "You are a bright prodigy in a dramatic world of rival schools and impossible magic.", "anime.png", "#1f2452", "#ff8fd8"),
)
return "<div class='selector-grid'>" + "".join(cards) + "</div>"
# Return HTML for image-backed school choices.
def school_selector_html(world: str = "Dark Fantasy") -> str:
cards = tuple(selector_card(*card) for card in school_selector_cards(world))
return "<div class='selector-grid'>" + "".join(cards) + "</div>"
# Return world-specific school selector card specs.
def school_selector_cards(world: str) -> tuple[tuple[str, str, str, str, str, str], ...]:
slug = world_slug(world)
copy = school_selector_copy(slug)
return (
("school-btn-fire", "Fire", copy["fire"], school_asset_name(slug, "fire"), "#351018", "#f06a2a"),
("school-btn-ice", "Ice", copy["ice"], school_asset_name(slug, "ice"), "#102040", "#7ed9ff"),
("school-btn-earth", "Earth", copy["earth"], school_asset_name(slug, "earth"), "#1d2d18", "#a5d46a"),
)
# Return hover copy for school choices in one world.
def school_selector_copy(slug: str) -> dict[str, str]:
if slug == "cyberpunk":
return {
"fire": "Burn through the neon grid with volatile heat, overclocked rituals, and delayed detonations.",
"ice": "Freeze signal, tempo, and nerve; win through clean timing and exposed weaknesses.",
"earth": "Raise concrete, steel, and old stone to bank pressure before the city breaks.",
}
if slug == "anime":
return {
"fire": "Fight like a rival prodigy: explosive pressure, bold finishers, and impossible sparks.",
"ice": "Move first, punish openings, and turn one perfect tempo beat into a burst.",
"earth": "Stand your ground, gather force, and answer with a dramatic shield-charged strike.",
}
return {
"fire": "Call ruinous flame in a lost kingdom: burn clocks, bombs, and violent finishers.",
"ice": "Bind the dark with frost, tempo, and precise strikes through brittle openings.",
"earth": "Wear the grave-stone crown: absorb the blow, bank force, and bury the boss.",
}
# Return a stable asset slug for one world label.
def world_slug(world: str) -> str:
return world.lower().replace(" ", "-")
# Return the bundled image filename for a world-specific school.
def school_asset_name(slug: str, school: str) -> str:
prefix = {"dark-fantasy": "darkFantasy", "cyberpunk": "cyberpunk", "anime": "anime"}[slug]
suffix = {"fire": "Fire", "ice": "Ice", "earth": "Earth"}[school]
if slug == "cyberpunk" and school == "ice":
return "cyberpunkice.png"
return f"{prefix}{suffix}.png"
# Return one clickable visual selector card.
def selector_card(button_id: str, label: str, copy: str, image: str, dark: str, accent: str) -> str:
uri = optional_asset_data_uri(image)
image_tag = f"<img class='selector-image' src='{uri}' alt=''>" if uri else ""
fallback = f"radial-gradient(circle at 50% 30%, {accent}, transparent 34%), linear-gradient(135deg, {dark}, #080512 82%)"
return (
f"<div class='selector-panel' onclick=\"tabrasClick('{button_id}')\" "
f"style='--selector-fallback:{fallback};'>"
f"{image_tag}"
f"<div class='selector-label'>{label}</div>"
f"<div class='selector-copy'>{copy}</div>"
"</div>"
)
# Return the dedicated rules screen while deck generation runs.
# Villain name + in-character quote per world, shown on the boss reveal screen.
VILLAINS: dict[str, tuple[str, str]] = {
"dark fantasy": ("The Hollow Warden", "“The keep has not opened its gates in an age. It will not open for you.”"),
"cyberpunk": ("Specter-9", "“Your magic is a legacy format. I am the patch that deprecates it.”"),
"anime": ("The Necrolich", "“I have waited aeons to rule this world. You will not stop me.”"),
}
# Return the dramatic boss reveal: full art, villain name, and a quote.
def reveal_html(run_state: RunState | None) -> str:
if run_state is None:
return ""
world = run_state.world
villain_name, quote = VILLAINS.get(world.strip().lower(), VILLAINS["dark fantasy"])
splash = boss_splash_uri(world)
art = (
f"<div class='reveal-art' style=\"background-image:url('{splash}')\"></div>"
if splash
else "<div class='reveal-art reveal-art-empty'></div>"
)
return (
"<div class='reveal-screen'>"
"<div class='reveal-kicker'>Your Enemy</div>"
f"{art}"
f"<div class='reveal-name'>{escape_html(villain_name)}</div>"
f"<div class='reveal-quote'>{escape_html(quote)}</div>"
"</div>"
)
def rules_html(run_state: RunState | None) -> str:
if run_state is None:
return ""
return (
"<div class='rules-screen'><div class='rules-card'>"
"<h1>The Rules</h1>"
"<div class='rules-grid'>"
"<div class='rule-tile'><b>Each round</b><span>A coin flip decides who acts first &mdash; knowing the order is a weapon.</span></div>"
"<div class='rule-tile'><b>Energy</b><span>Start at 1, ramp to 5. It refills every round and never carries over.</span></div>"
"<div class='rule-tile'><b>Block</b><span>Stops incoming damage until your next turn, then fades.</span></div>"
"<div class='rule-tile'><b>Ward</b><span>A persistent shield that absorbs one decisive hit, whenever it lands.</span></div>"
"<div class='rule-tile'><b>Draft</b><span>Pick one card from each pack to build a 15-card deck.</span></div>"
"<div class='rule-tile'><b>Win</b><span>Reduce the boss to 0 HP across the duel.</span></div>"
"</div>"
"</div></div>"
)
# Return a typed school value.
def school_as_literal(value: str) -> School:
if value in {"fire", "ice", "earth"}:
return value # type: ignore[return-value]
return "fire"
if __name__ == "__main__":
configure_mode() # MODE=LOCAL by default: run the models on your own hardware
build_app().launch(server_name="127.0.0.1", server_port=7860, css=CSS, head=HEAD)