Spaces:
Running
Running
Commit ·
a8afc36
1
Parent(s): 6bbf552
finished base
Browse files- ai_runtime.py +12 -3
- app.py +621 -103
- art.py +136 -0
- assets/anime.png +3 -0
- assets/animeBoss.png +3 -0
- assets/animeEarth.png +3 -0
- assets/animeFire.png +3 -0
- assets/animeIce.png +3 -0
- assets/cyberpunk.png +3 -0
- assets/cyberpunkBoss.png +3 -0
- assets/cyberpunkEarth.png +3 -0
- assets/cyberpunkFire.png +3 -0
- assets/cyberpunkice.png +3 -0
- assets/darkFantasy.png +3 -0
- assets/darkFantasyBoss.png +3 -0
- assets/darkFantasyEarth.png +3 -0
- assets/darkFantasyFire.png +3 -0
- assets/darkFantasyIce.png +3 -0
- assets/inn_bg.jpg +3 -0
- assets/wood_board.jpg +3 -0
- budget.py +1 -0
- clients.py +28 -1
- forge.py +61 -0
- generator.py +192 -20
- local_llm.py +12 -2
- pyproject.toml +1 -1
- requirements.txt +7 -0
- tests/test_ai_runtime.py +7 -0
- tests/test_app.py +147 -0
- tests/test_art.py +121 -0
- tests/test_clients.py +65 -2
- tests/test_forge.py +47 -0
- tests/test_generator.py +147 -6
- tests/test_local_llm.py +2 -0
- tests/test_ui.py +208 -1
- ui.py +442 -42
ai_runtime.py
CHANGED
|
@@ -7,6 +7,7 @@ DEFAULT_MINICPM_MODEL_PATH = Path("models/minicpm-v-4.6-gguf/MiniCPM-V-4.6-Q4_K_
|
|
| 7 |
DEFAULT_CARD_PORT = 8090
|
| 8 |
DEFAULT_APP_PORT = 7860
|
| 9 |
DEFAULT_BOSS_MODEL = "mlx-community/Nemotron-Mini-4B-Instruct-4bit-mlx"
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
# Return the OpenAI-compatible chat endpoint for a local port.
|
|
@@ -25,7 +26,9 @@ def minicpm_server_command(model_path: Path = DEFAULT_MINICPM_MODEL_PATH, port:
|
|
| 25 |
"--port",
|
| 26 |
str(port),
|
| 27 |
"--ctx-size",
|
| 28 |
-
"
|
|
|
|
|
|
|
| 29 |
"--n-gpu-layers",
|
| 30 |
"99",
|
| 31 |
)
|
|
@@ -39,13 +42,19 @@ def local_ai_env(card_port: int = DEFAULT_CARD_PORT) -> dict[str, str]:
|
|
| 39 |
"TABRAS_CARD_BACKEND": "llamacpp",
|
| 40 |
"TABRAS_CARD_ENDPOINT": chat_endpoint(card_port),
|
| 41 |
"TABRAS_CARD_MODEL": "minicpm-v-4.6-q4",
|
| 42 |
-
"TABRAS_CARD_TEMPERATURE": "0.
|
| 43 |
-
"TABRAS_CARD_MAX_TOKENS": "
|
| 44 |
"TABRAS_AI_BOSS": "1",
|
| 45 |
"TABRAS_BOSS_BACKEND": "mlx",
|
| 46 |
"TABRAS_BOSS_MODEL": DEFAULT_BOSS_MODEL,
|
| 47 |
"TABRAS_BOSS_TEMPERATURE": "0.2",
|
| 48 |
"TABRAS_BOSS_MAX_TOKENS": "96",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
)
|
| 51 |
return env
|
|
|
|
| 7 |
DEFAULT_CARD_PORT = 8090
|
| 8 |
DEFAULT_APP_PORT = 7860
|
| 9 |
DEFAULT_BOSS_MODEL = "mlx-community/Nemotron-Mini-4B-Instruct-4bit-mlx"
|
| 10 |
+
DEFAULT_ART_MODEL = "stabilityai/sdxl-turbo"
|
| 11 |
|
| 12 |
|
| 13 |
# Return the OpenAI-compatible chat endpoint for a local port.
|
|
|
|
| 26 |
"--port",
|
| 27 |
str(port),
|
| 28 |
"--ctx-size",
|
| 29 |
+
"16384",
|
| 30 |
+
"--parallel",
|
| 31 |
+
"4",
|
| 32 |
"--n-gpu-layers",
|
| 33 |
"99",
|
| 34 |
)
|
|
|
|
| 42 |
"TABRAS_CARD_BACKEND": "llamacpp",
|
| 43 |
"TABRAS_CARD_ENDPOINT": chat_endpoint(card_port),
|
| 44 |
"TABRAS_CARD_MODEL": "minicpm-v-4.6-q4",
|
| 45 |
+
"TABRAS_CARD_TEMPERATURE": "0.7",
|
| 46 |
+
"TABRAS_CARD_MAX_TOKENS": "192",
|
| 47 |
"TABRAS_AI_BOSS": "1",
|
| 48 |
"TABRAS_BOSS_BACKEND": "mlx",
|
| 49 |
"TABRAS_BOSS_MODEL": DEFAULT_BOSS_MODEL,
|
| 50 |
"TABRAS_BOSS_TEMPERATURE": "0.2",
|
| 51 |
"TABRAS_BOSS_MAX_TOKENS": "96",
|
| 52 |
+
"TABRAS_ART_BACKEND": "diffusers",
|
| 53 |
+
"TABRAS_ART_MODEL": DEFAULT_ART_MODEL,
|
| 54 |
+
"TABRAS_ART_STEPS": "4",
|
| 55 |
+
"TABRAS_ART_GUIDANCE": "0.0",
|
| 56 |
+
"TABRAS_ART_WIDTH": "512",
|
| 57 |
+
"TABRAS_ART_HEIGHT": "320",
|
| 58 |
}
|
| 59 |
)
|
| 60 |
return env
|
app.py
CHANGED
|
@@ -1,22 +1,66 @@
|
|
|
|
|
|
|
|
| 1 |
import time
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
-
from clients import card_client_from_env
|
| 6 |
from primitives import School
|
| 7 |
from ui import (
|
| 8 |
CARD_PANEL_COUNT,
|
| 9 |
HAND_PANEL_COUNT,
|
| 10 |
RunState,
|
| 11 |
board_html,
|
| 12 |
-
|
|
|
|
| 13 |
draft_screen_html,
|
| 14 |
log_html,
|
| 15 |
-
|
| 16 |
pass_turn_steps,
|
| 17 |
play_hand_card_steps,
|
|
|
|
|
|
|
| 18 |
)
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
HEAD = """
|
| 21 |
<script>
|
| 22 |
function tabrasClick(id) {
|
|
@@ -34,14 +78,16 @@ function tabrasPlay(id, card) {
|
|
| 34 |
|
| 35 |
CSS = """
|
| 36 |
.gradio-container {
|
| 37 |
-
background:
|
| 38 |
color: #f4ead9;
|
| 39 |
max-width: 100% !important;
|
| 40 |
}
|
| 41 |
footer { display: none !important; }
|
| 42 |
.hidden-controls { display: none !important; }
|
|
|
|
| 43 |
gradio-app, body {
|
| 44 |
-
background:
|
|
|
|
| 45 |
}
|
| 46 |
.gradio-container .block, .gradio-container .form, .gradio-container .gr-group, .gradio-container .styler {
|
| 47 |
background: transparent !important;
|
|
@@ -54,6 +100,29 @@ button.primary {
|
|
| 54 |
border: 2px solid #f6dd9a !important;
|
| 55 |
font-weight: 800 !important;
|
| 56 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
.tabras-title {
|
| 59 |
min-height: 70vh;
|
|
@@ -66,27 +135,117 @@ button.primary {
|
|
| 66 |
.tabras-title h1 {
|
| 67 |
font-size: 76px;
|
| 68 |
margin: 0 0 12px;
|
| 69 |
-
color: #
|
| 70 |
-
text-shadow: 0 3px 18px rgba(0, 0, 0, 0.65);
|
| 71 |
}
|
| 72 |
.gradio-container .setup-panel {
|
| 73 |
-
background: rgba(
|
| 74 |
-
border: 2px solid rgba(
|
| 75 |
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important;
|
| 76 |
padding: 18px !important;
|
| 77 |
border-radius: 14px !important;
|
| 78 |
max-width: 560px;
|
| 79 |
margin: 8vh auto 0 !important;
|
| 80 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
/* ---- card faces ---- */
|
| 83 |
.tabras-card {
|
| 84 |
position: relative;
|
| 85 |
border-radius: 10px;
|
| 86 |
padding: 26px 10px 8px;
|
| 87 |
-
background: linear-gradient(180deg, #
|
| 88 |
-
border: 2px solid #
|
| 89 |
-
color: #
|
| 90 |
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
| 91 |
display: flex;
|
| 92 |
flex-direction: column;
|
|
@@ -112,34 +271,88 @@ button.primary {
|
|
| 112 |
z-index: 2;
|
| 113 |
}
|
| 114 |
.card-name { font-size: 13px; font-weight: 800; line-height: 1.15; min-height: 30px; }
|
| 115 |
-
.school-fire .card-name { color: #
|
| 116 |
-
.school-ice .card-name { color: #
|
| 117 |
-
.school-earth .card-name { color: #
|
|
|
|
|
|
|
|
|
|
| 118 |
.card-art {
|
| 119 |
height: 64px;
|
| 120 |
border-radius: 6px;
|
| 121 |
-
border: 1px
|
| 122 |
-
background: linear-gradient(135deg, rgba(
|
|
|
|
| 123 |
}
|
| 124 |
-
.card-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
.card-flavor {
|
| 126 |
-
color: #
|
| 127 |
font-size: 10px;
|
| 128 |
font-style: italic;
|
| 129 |
-
border-top: 1px solid rgba(
|
| 130 |
padding-top: 4px;
|
| 131 |
}
|
| 132 |
|
| 133 |
/* ---- draft screen ---- */
|
| 134 |
-
.draft-board {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
.draft-banner { text-align: center; }
|
| 136 |
-
.draft-banner h2 { margin: 0; color: #
|
| 137 |
-
.draft-banner p { margin:
|
| 138 |
-
.draft-pack {
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
.draft-pack .draft-card:nth-child(2) { animation-delay: 0.1s; }
|
| 141 |
.draft-pack .draft-card:nth-child(3) { animation-delay: 0.2s; }
|
| 142 |
-
.draft-card:hover { transform: translateY(-
|
| 143 |
@keyframes pack-in {
|
| 144 |
from { opacity: 0; transform: translateY(28px) scale(0.92); }
|
| 145 |
to { opacity: 1; transform: none; }
|
|
@@ -154,24 +367,135 @@ button.primary {
|
|
| 154 |
30% { opacity: 1; transform: scale(1.08); box-shadow: 0 0 44px rgba(242, 194, 77, 0.95); }
|
| 155 |
to { opacity: 0; transform: translateY(-34px) scale(1.02); box-shadow: 0 0 30px rgba(242, 194, 77, 0.6); }
|
| 156 |
}
|
| 157 |
-
.draft-card .card-name { font-size: 17px; min-height:
|
| 158 |
.draft-card .card-art { height: 112px; }
|
| 159 |
-
.draft-card .card-rules { font-size:
|
| 160 |
.draft-card .card-flavor { font-size: 12px; }
|
| 161 |
-
.
|
| 162 |
-
.
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
/* ---- battle board ---- */
|
| 168 |
.board {
|
| 169 |
position: relative;
|
| 170 |
-
border: 4px solid #
|
| 171 |
border-radius: 22px;
|
| 172 |
padding: 8px 14px;
|
| 173 |
-
background:
|
| 174 |
-
box-shadow: inset 0 0
|
| 175 |
overflow: hidden;
|
| 176 |
}
|
| 177 |
.zone { position: relative; display: flex; align-items: center; justify-content: center; }
|
|
@@ -180,13 +504,13 @@ button.primary {
|
|
| 180 |
.pile {
|
| 181 |
width: 64px;
|
| 182 |
text-align: center;
|
| 183 |
-
border: 2px solid #
|
| 184 |
border-radius: 8px;
|
| 185 |
padding: 6px 0 3px;
|
| 186 |
-
background: repeating-linear-gradient(45deg, #
|
| 187 |
}
|
| 188 |
-
.pile span { font-weight: 900; font-size: 18px; color: #
|
| 189 |
-
.pile label { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: #
|
| 190 |
.fatigue-warn { color: #ff8d7a; font-size: 10px; font-weight: 800; }
|
| 191 |
|
| 192 |
.hero { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
|
@@ -196,7 +520,7 @@ button.primary {
|
|
| 196 |
height: 100%;
|
| 197 |
border-radius: 50% 50% 46% 46%;
|
| 198 |
border: 4px solid #b08a4f;
|
| 199 |
-
background: radial-gradient(circle at 50% 30%, #
|
| 200 |
display: flex;
|
| 201 |
align-items: center;
|
| 202 |
justify-content: center;
|
|
@@ -265,9 +589,9 @@ button.primary {
|
|
| 265 |
width: 52px;
|
| 266 |
height: 72px;
|
| 267 |
border-radius: 6px;
|
| 268 |
-
border: 2px solid #
|
| 269 |
margin: 0 -10px;
|
| 270 |
-
background: repeating-linear-gradient(45deg, #
|
| 271 |
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.4);
|
| 272 |
transform: rotate(3deg);
|
| 273 |
}
|
|
@@ -278,7 +602,7 @@ button.primary {
|
|
| 278 |
min-height: 118px;
|
| 279 |
margin: 8px 36px;
|
| 280 |
border-radius: 14px;
|
| 281 |
-
background: rgba(
|
| 282 |
box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.35);
|
| 283 |
display: flex;
|
| 284 |
flex-direction: column;
|
|
@@ -293,7 +617,7 @@ button.primary {
|
|
| 293 |
font-weight: 800;
|
| 294 |
letter-spacing: 0.12em;
|
| 295 |
text-transform: uppercase;
|
| 296 |
-
color: #
|
| 297 |
font-size: 13px;
|
| 298 |
}
|
| 299 |
.end-turn {
|
|
@@ -342,11 +666,11 @@ button.primary {
|
|
| 342 |
padding: 1px 10px;
|
| 343 |
border-radius: 10px;
|
| 344 |
}
|
| 345 |
-
.play-label.you { color: #
|
| 346 |
-
.play-label.boss { color: #
|
| 347 |
.played-card { width: 132px; }
|
| 348 |
-
.played-card.player-play { box-shadow: 0 0 22px rgba(
|
| 349 |
-
.played-card.enemy-play { box-shadow: 0 0 22px rgba(
|
| 350 |
@keyframes play-in {
|
| 351 |
from { transform: translateY(120px) rotateY(90deg) scale(0.4); opacity: 0; }
|
| 352 |
60% { transform: translateY(-6px) rotateY(0deg) scale(1.12); opacity: 1; }
|
|
@@ -374,15 +698,15 @@ button.primary {
|
|
| 374 |
transform-origin: 50% 130%;
|
| 375 |
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
| 376 |
cursor: pointer;
|
| 377 |
-
box-shadow: 0 0 14px rgba(
|
| 378 |
-
border-color: #
|
| 379 |
}
|
| 380 |
.hand-card:hover {
|
| 381 |
transform: rotate(0deg) translateY(-48px) scale(1.45);
|
| 382 |
z-index: 99 !important;
|
| 383 |
-
box-shadow: 0 0 24px rgba(
|
| 384 |
}
|
| 385 |
-
.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: #
|
| 386 |
.hand-card.unplayable:hover { box-shadow: 0 24px 50px rgba(0, 0, 0, 0.6); }
|
| 387 |
.hand-card.launching {
|
| 388 |
transform: translateY(-170px) scale(1.08) rotate(0deg) !important;
|
|
@@ -404,17 +728,17 @@ button.primary {
|
|
| 404 |
animation: splash-bg 1.9s ease forwards;
|
| 405 |
}
|
| 406 |
@keyframes splash-bg {
|
| 407 |
-
0% { background: rgba(
|
| 408 |
-
18% { background: rgba(
|
| 409 |
-
72% { background: rgba(
|
| 410 |
-
100% { background: rgba(
|
| 411 |
}
|
| 412 |
.splash-round {
|
| 413 |
font-size: 50px;
|
| 414 |
font-weight: 900;
|
| 415 |
letter-spacing: 0.3em;
|
| 416 |
-
color: #
|
| 417 |
-
text-shadow: 0 0 34px rgba(
|
| 418 |
animation: splash 1.9s ease forwards;
|
| 419 |
}
|
| 420 |
.splash-initiative {
|
|
@@ -423,8 +747,8 @@ button.primary {
|
|
| 423 |
letter-spacing: 0.26em;
|
| 424 |
animation: splash 1.65s ease 0.25s both;
|
| 425 |
}
|
| 426 |
-
.splash-initiative.you { color: #
|
| 427 |
-
.splash-initiative.boss { color: #
|
| 428 |
@keyframes splash {
|
| 429 |
0% { opacity: 0; transform: scale(2.2); }
|
| 430 |
25% { opacity: 1; transform: scale(1); }
|
|
@@ -439,9 +763,9 @@ button.primary {
|
|
| 439 |
z-index: 60;
|
| 440 |
padding: 5px 16px;
|
| 441 |
border-radius: 14px;
|
| 442 |
-
background: rgba(20, 10,
|
| 443 |
-
border: 1px solid #
|
| 444 |
-
color: #
|
| 445 |
font-weight: 700;
|
| 446 |
font-size: 12px;
|
| 447 |
letter-spacing: 0.08em;
|
|
@@ -452,7 +776,7 @@ button.primary {
|
|
| 452 |
0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); }
|
| 453 |
50% { opacity: 1; transform: translateX(-50%) scale(1.06); }
|
| 454 |
}
|
| 455 |
-
.board.thinking .hero.enemy .hero-face { box-shadow: 0 0 28px rgba(
|
| 456 |
|
| 457 |
.winner-banner {
|
| 458 |
position: absolute;
|
|
@@ -462,7 +786,7 @@ button.primary {
|
|
| 462 |
gap: 16px;
|
| 463 |
align-items: center;
|
| 464 |
justify-content: center;
|
| 465 |
-
background: rgba(
|
| 466 |
z-index: 200;
|
| 467 |
border-radius: 18px;
|
| 468 |
}
|
|
@@ -473,7 +797,7 @@ button.primary {
|
|
| 473 |
}
|
| 474 |
.winner-banner.victory h1 { color: #ffd98c; text-shadow: 0 0 30px rgba(255, 200, 90, 0.7); }
|
| 475 |
.winner-banner.defeat h1 { color: #ff7a6a; text-shadow: 0 0 30px rgba(255, 70, 40, 0.6); }
|
| 476 |
-
.winner-banner.draw h1 { color: #
|
| 477 |
.new-run {
|
| 478 |
padding: 12px 30px;
|
| 479 |
border-radius: 24px;
|
|
@@ -485,7 +809,7 @@ button.primary {
|
|
| 485 |
}
|
| 486 |
.game-over .hand-fan, .game-over .end-turn { pointer-events: none; }
|
| 487 |
|
| 488 |
-
.log-panel { background: rgba(
|
| 489 |
.log-scroll {
|
| 490 |
display: flex;
|
| 491 |
flex-direction: column-reverse;
|
|
@@ -493,46 +817,61 @@ button.primary {
|
|
| 493 |
max-height: 600px;
|
| 494 |
overflow-y: auto;
|
| 495 |
font-size: 11.5px;
|
| 496 |
-
color: #
|
| 497 |
}
|
| 498 |
.log-line { padding: 4px 8px; border-left: 3px solid transparent; border-radius: 4px; line-height: 1.35; }
|
| 499 |
-
.log-line b { color: #
|
| 500 |
.log-owner { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; opacity: 0.7; }
|
| 501 |
.log-rules { display: block; font-size: 10.5px; opacity: 0.85; }
|
| 502 |
-
.log-play.log-you { border-left-color: #
|
| 503 |
-
.log-play.log-boss { border-left-color: #
|
| 504 |
.log-round {
|
| 505 |
text-align: center;
|
| 506 |
-
color: #
|
| 507 |
font-weight: 800;
|
| 508 |
letter-spacing: 0.06em;
|
| 509 |
margin-top: 8px;
|
| 510 |
-
border-top: 1px solid rgba(
|
| 511 |
padding-top: 6px;
|
| 512 |
}
|
| 513 |
-
.log-winner { text-align: center; color: #
|
| 514 |
.log-muted { opacity: 0.55; font-style: italic; }
|
| 515 |
-
.log-draft { color: #
|
| 516 |
-
.log-scroll > .log-line:first-child { box-shadow: inset 0 0 0 1px rgba(255,
|
| 517 |
"""
|
| 518 |
|
|
|
|
|
|
|
| 519 |
|
| 520 |
# Build all Gradio components for the Tabras app.
|
| 521 |
def build_app() -> gr.Blocks:
|
| 522 |
with gr.Blocks(title="Tabras") as app:
|
| 523 |
state = gr.State(None)
|
|
|
|
| 524 |
|
| 525 |
with gr.Group(visible=True, elem_id="title-screen") as title_group:
|
| 526 |
gr.HTML("<div class='tabras-title'><h1>Tabras</h1><p>A small-model card duel on an old wooden desk.</p></div>")
|
| 527 |
-
play_now = gr.Button("Play Now", variant="primary")
|
| 528 |
|
| 529 |
-
with gr.Group(visible=False, elem_id="
|
| 530 |
with gr.Column(elem_classes=["setup-panel"]):
|
| 531 |
-
gr.
|
| 532 |
name = gr.Textbox(label="Name", value="Vishnu")
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
|
| 537 |
with gr.Group(visible=False, elem_id="draft-screen") as draft_group:
|
| 538 |
draft_view = gr.HTML()
|
|
@@ -548,19 +887,44 @@ def build_app() -> gr.Blocks:
|
|
| 548 |
with gr.Row(elem_classes=["hidden-controls"]):
|
| 549 |
hand_buttons = [gr.Button("", elem_id=f"hand-btn-{index}") for index in range(HAND_PANEL_COUNT)]
|
| 550 |
draft_buttons = [gr.Button("", elem_id=f"draft-btn-{index}") for index in range(CARD_PANEL_COUNT)]
|
|
|
|
|
|
|
| 551 |
end_turn = gr.Button("", elem_id="end-turn-btn")
|
| 552 |
restart = gr.Button("", elem_id="restart-btn")
|
|
|
|
| 553 |
|
| 554 |
-
outputs = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
-
play_now.click(
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
for index, button in enumerate(draft_buttons):
|
| 559 |
button.click(indexed_handler(draft_pick, index), inputs=[state], outputs=outputs)
|
| 560 |
for index, button in enumerate(hand_buttons):
|
| 561 |
button.click(indexed_handler(play_card, index), inputs=[state], outputs=outputs)
|
| 562 |
end_turn.click(end_player_turn, inputs=[state], outputs=outputs)
|
| 563 |
-
restart.click(
|
|
|
|
| 564 |
|
| 565 |
return app
|
| 566 |
|
|
@@ -573,31 +937,72 @@ def indexed_handler(handler, index: int):
|
|
| 573 |
return stream
|
| 574 |
|
| 575 |
|
| 576 |
-
#
|
| 577 |
-
def
|
| 578 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
|
|
|
|
|
|
|
|
|
|
| 580 |
|
| 581 |
-
|
| 582 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
client = card_client_from_env()
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
return render(run_state, "draft")
|
| 586 |
|
| 587 |
|
| 588 |
# Choose one draft card, streaming the paced battle opening on the final pick.
|
| 589 |
def draft_pick(run_state: RunState | None, index: int):
|
| 590 |
if run_state is None:
|
| 591 |
-
yield render(None, "
|
| 592 |
return
|
| 593 |
client = card_client_from_env()
|
| 594 |
-
|
|
|
|
| 595 |
|
| 596 |
|
| 597 |
# Play one hand card by index, streaming the paced boss response.
|
| 598 |
def play_card(run_state: RunState | None, index: int):
|
| 599 |
if run_state is None:
|
| 600 |
-
yield render(None, "
|
| 601 |
return
|
| 602 |
yield from paced_frames(play_hand_card_steps(run_state, index))
|
| 603 |
|
|
@@ -605,11 +1010,20 @@ def play_card(run_state: RunState | None, index: int):
|
|
| 605 |
# End the player turn, streaming the paced boss response.
|
| 606 |
def end_player_turn(run_state: RunState | None):
|
| 607 |
if run_state is None:
|
| 608 |
-
yield render(None, "
|
| 609 |
return
|
| 610 |
yield from paced_frames(pass_turn_steps(run_state))
|
| 611 |
|
| 612 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
# Yield rendered frames, letting each dramatic beat linger on screen.
|
| 614 |
def paced_frames(steps):
|
| 615 |
previous = None
|
|
@@ -623,28 +1037,132 @@ def paced_frames(steps):
|
|
| 623 |
# Return how long one frame should stay on screen before the next.
|
| 624 |
def frame_delay(state: RunState) -> float:
|
| 625 |
if state.round_flash:
|
| 626 |
-
return 2.2
|
| 627 |
-
if state.boss_thinking:
|
| 628 |
return 1.4
|
|
|
|
|
|
|
| 629 |
if state.pack_fading >= 0:
|
| 630 |
-
return 0.
|
| 631 |
-
return 0.
|
| 632 |
|
| 633 |
|
| 634 |
# Render all app outputs.
|
| 635 |
-
def render(run_state: RunState | None, screen: str) -> list[object]:
|
|
|
|
|
|
|
| 636 |
return [
|
| 637 |
run_state,
|
|
|
|
| 638 |
gr.update(visible=screen == "title"),
|
| 639 |
-
gr.update(visible=screen == "
|
|
|
|
|
|
|
|
|
|
| 640 |
gr.update(visible=screen == "draft"),
|
| 641 |
gr.update(visible=screen == "battle"),
|
|
|
|
| 642 |
draft_screen_html(run_state),
|
| 643 |
board_html(run_state),
|
| 644 |
log_html(run_state),
|
|
|
|
|
|
|
| 645 |
]
|
| 646 |
|
| 647 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
# Return a typed school value.
|
| 649 |
def school_as_literal(value: str) -> School:
|
| 650 |
if value in {"fire", "ice", "earth"}:
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import mimetypes
|
| 3 |
import time
|
| 4 |
+
from dataclasses import replace
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from random import Random
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
|
| 10 |
+
from clients import art_client_from_env, card_client_from_env
|
| 11 |
from primitives import School
|
| 12 |
from ui import (
|
| 13 |
CARD_PANEL_COUNT,
|
| 14 |
HAND_PANEL_COUNT,
|
| 15 |
RunState,
|
| 16 |
board_html,
|
| 17 |
+
choose_draft_card_loading_steps,
|
| 18 |
+
collect_ready_pack,
|
| 19 |
draft_screen_html,
|
| 20 |
log_html,
|
| 21 |
+
new_run_shell,
|
| 22 |
pass_turn_steps,
|
| 23 |
play_hand_card_steps,
|
| 24 |
+
queue_next_pack,
|
| 25 |
+
refresh_art,
|
| 26 |
)
|
| 27 |
|
| 28 |
+
ASSETS = Path(__file__).parent / "assets"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Encode a bundled image asset as a CSS-ready data URI (HF-Spaces safe, no static paths).
|
| 32 |
+
def asset_data_uri(name: str, mime: str = "image/jpeg") -> str:
|
| 33 |
+
encoded = base64.b64encode((ASSETS / name).read_bytes()).decode("ascii")
|
| 34 |
+
return f"data:{mime};base64,{encoded}"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# Encode an optional bundled image asset as a CSS-ready data URI.
|
| 38 |
+
def optional_asset_data_uri(name: str) -> str:
|
| 39 |
+
path = optional_asset_path(name)
|
| 40 |
+
if path is None:
|
| 41 |
+
return ""
|
| 42 |
+
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
| 43 |
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
| 44 |
+
return f"data:{mime};base64,{encoded}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Return the first matching asset path, accepting common image extensions.
|
| 48 |
+
def optional_asset_path(name: str) -> Path | None:
|
| 49 |
+
path = ASSETS / name
|
| 50 |
+
if path.exists():
|
| 51 |
+
return path
|
| 52 |
+
if path.suffix:
|
| 53 |
+
for suffix in (".jpg", ".jpeg", ".png", ".webp"):
|
| 54 |
+
candidate = path.with_suffix(suffix)
|
| 55 |
+
if candidate.exists():
|
| 56 |
+
return candidate
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
INN_BG = asset_data_uri("inn_bg.jpg")
|
| 61 |
+
WOOD_BG = asset_data_uri("wood_board.jpg")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
HEAD = """
|
| 65 |
<script>
|
| 66 |
function tabrasClick(id) {
|
|
|
|
| 78 |
|
| 79 |
CSS = """
|
| 80 |
.gradio-container {
|
| 81 |
+
background: linear-gradient(rgba(10, 6, 14, 0.82), rgba(6, 4, 9, 0.9)), url("__INN_BG__") center / cover fixed;
|
| 82 |
color: #f4ead9;
|
| 83 |
max-width: 100% !important;
|
| 84 |
}
|
| 85 |
footer { display: none !important; }
|
| 86 |
.hidden-controls { display: none !important; }
|
| 87 |
+
*, *::before, *::after { box-sizing: border-box; }
|
| 88 |
gradio-app, body {
|
| 89 |
+
background: linear-gradient(rgba(10, 6, 14, 0.82), rgba(6, 4, 9, 0.9)), url("__INN_BG__") center / cover fixed !important;
|
| 90 |
+
overflow-x: hidden;
|
| 91 |
}
|
| 92 |
.gradio-container .block, .gradio-container .form, .gradio-container .gr-group, .gradio-container .styler {
|
| 93 |
background: transparent !important;
|
|
|
|
| 100 |
border: 2px solid #f6dd9a !important;
|
| 101 |
font-weight: 800 !important;
|
| 102 |
}
|
| 103 |
+
#play-now-btn {
|
| 104 |
+
width: auto !important;
|
| 105 |
+
min-width: 170px !important;
|
| 106 |
+
max-width: 220px !important;
|
| 107 |
+
align-self: center !important;
|
| 108 |
+
}
|
| 109 |
+
#play-now-btn button {
|
| 110 |
+
width: auto !important;
|
| 111 |
+
min-width: 170px !important;
|
| 112 |
+
padding: 10px 28px !important;
|
| 113 |
+
}
|
| 114 |
+
#start-draft-btn {
|
| 115 |
+
width: auto !important;
|
| 116 |
+
min-width: 150px !important;
|
| 117 |
+
max-width: 200px !important;
|
| 118 |
+
align-self: center !important;
|
| 119 |
+
margin: 0 auto !important;
|
| 120 |
+
}
|
| 121 |
+
#start-draft-btn button {
|
| 122 |
+
width: auto !important;
|
| 123 |
+
min-width: 150px !important;
|
| 124 |
+
padding: 10px 24px !important;
|
| 125 |
+
}
|
| 126 |
|
| 127 |
.tabras-title {
|
| 128 |
min-height: 70vh;
|
|
|
|
| 135 |
.tabras-title h1 {
|
| 136 |
font-size: 76px;
|
| 137 |
margin: 0 0 12px;
|
| 138 |
+
color: #f6f0ff;
|
| 139 |
+
text-shadow: 0 0 30px rgba(160, 90, 255, 0.6), 0 3px 18px rgba(0, 0, 0, 0.65);
|
| 140 |
}
|
| 141 |
.gradio-container .setup-panel {
|
| 142 |
+
background: rgba(26, 20, 46, 0.82) !important;
|
| 143 |
+
border: 2px solid rgba(190, 160, 255, 0.3) !important;
|
| 144 |
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important;
|
| 145 |
padding: 18px !important;
|
| 146 |
border-radius: 14px !important;
|
| 147 |
max-width: 560px;
|
| 148 |
margin: 8vh auto 0 !important;
|
| 149 |
}
|
| 150 |
+
.step-kicker {
|
| 151 |
+
color: #ffd35c;
|
| 152 |
+
font-size: 13px;
|
| 153 |
+
font-weight: 900;
|
| 154 |
+
letter-spacing: 0.12em;
|
| 155 |
+
text-transform: uppercase;
|
| 156 |
+
margin-bottom: 8px;
|
| 157 |
+
}
|
| 158 |
+
.setup-panel h2 {
|
| 159 |
+
margin: 0 0 12px;
|
| 160 |
+
color: #f6f0ff;
|
| 161 |
+
font-size: 28px;
|
| 162 |
+
}
|
| 163 |
+
.choice-row button {
|
| 164 |
+
min-height: 46px;
|
| 165 |
+
}
|
| 166 |
+
.selector-grid {
|
| 167 |
+
display: grid;
|
| 168 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 169 |
+
gap: 14px;
|
| 170 |
+
width: min(980px, 92vw);
|
| 171 |
+
margin: 0 auto;
|
| 172 |
+
}
|
| 173 |
+
.selector-panel {
|
| 174 |
+
position: relative;
|
| 175 |
+
min-height: 360px;
|
| 176 |
+
border: 2px solid rgba(190, 160, 255, 0.34);
|
| 177 |
+
border-radius: 14px;
|
| 178 |
+
overflow: hidden;
|
| 179 |
+
cursor: pointer;
|
| 180 |
+
background: var(--selector-fallback);
|
| 181 |
+
background-size: cover;
|
| 182 |
+
background-position: center;
|
| 183 |
+
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42);
|
| 184 |
+
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
|
| 185 |
+
}
|
| 186 |
+
.selector-image {
|
| 187 |
+
position: absolute;
|
| 188 |
+
inset: 0;
|
| 189 |
+
width: 100%;
|
| 190 |
+
height: 100%;
|
| 191 |
+
object-fit: cover;
|
| 192 |
+
}
|
| 193 |
+
.selector-panel::after {
|
| 194 |
+
content: "";
|
| 195 |
+
position: absolute;
|
| 196 |
+
inset: 0;
|
| 197 |
+
background: linear-gradient(180deg, rgba(12, 8, 30, 0.06), rgba(12, 8, 30, 0.88));
|
| 198 |
+
pointer-events: none;
|
| 199 |
+
}
|
| 200 |
+
.selector-panel:hover {
|
| 201 |
+
transform: translateY(-8px);
|
| 202 |
+
border-color: #ffd35c;
|
| 203 |
+
box-shadow: 0 0 28px rgba(255, 211, 92, 0.22), 0 24px 58px rgba(0, 0, 0, 0.52);
|
| 204 |
+
filter: saturate(1.1);
|
| 205 |
+
}
|
| 206 |
+
.selector-label {
|
| 207 |
+
position: absolute;
|
| 208 |
+
left: 18px;
|
| 209 |
+
right: 18px;
|
| 210 |
+
bottom: 18px;
|
| 211 |
+
color: #f6f0ff;
|
| 212 |
+
font-size: 26px;
|
| 213 |
+
font-weight: 900;
|
| 214 |
+
text-shadow: 0 3px 14px rgba(0, 0, 0, 0.82);
|
| 215 |
+
z-index: 2;
|
| 216 |
+
}
|
| 217 |
+
.selector-copy {
|
| 218 |
+
position: absolute;
|
| 219 |
+
inset: 0;
|
| 220 |
+
display: flex;
|
| 221 |
+
align-items: flex-end;
|
| 222 |
+
padding: 18px;
|
| 223 |
+
color: #f6f0ff;
|
| 224 |
+
font-size: 17px;
|
| 225 |
+
font-weight: 700;
|
| 226 |
+
line-height: 1.35;
|
| 227 |
+
opacity: 0;
|
| 228 |
+
background: linear-gradient(180deg, rgba(8, 4, 20, 0.24), rgba(8, 4, 20, 0.88));
|
| 229 |
+
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.9);
|
| 230 |
+
transition: opacity 0.16s ease;
|
| 231 |
+
z-index: 3;
|
| 232 |
+
}
|
| 233 |
+
.selector-panel:hover .selector-copy {
|
| 234 |
+
opacity: 1;
|
| 235 |
+
}
|
| 236 |
+
@media (max-width: 900px) {
|
| 237 |
+
.selector-grid { grid-template-columns: 1fr; }
|
| 238 |
+
.selector-panel { min-height: 240px; }
|
| 239 |
+
}
|
| 240 |
|
| 241 |
/* ---- card faces ---- */
|
| 242 |
.tabras-card {
|
| 243 |
position: relative;
|
| 244 |
border-radius: 10px;
|
| 245 |
padding: 26px 10px 8px;
|
| 246 |
+
background: linear-gradient(180deg, #2a2347, #151028);
|
| 247 |
+
border: 2px solid #8d7bd6;
|
| 248 |
+
color: #f0eaff;
|
| 249 |
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
| 250 |
display: flex;
|
| 251 |
flex-direction: column;
|
|
|
|
| 271 |
z-index: 2;
|
| 272 |
}
|
| 273 |
.card-name { font-size: 13px; font-weight: 800; line-height: 1.15; min-height: 30px; }
|
| 274 |
+
.school-fire .card-name { color: #ff9d80; }
|
| 275 |
+
.school-ice .card-name { color: #8fdfff; }
|
| 276 |
+
.school-earth .card-name { color: #aaf08a; }
|
| 277 |
+
.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); }
|
| 278 |
+
.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); }
|
| 279 |
+
.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); }
|
| 280 |
.card-art {
|
| 281 |
height: 64px;
|
| 282 |
border-radius: 6px;
|
| 283 |
+
border: 1px solid rgba(200, 170, 255, 0.3);
|
| 284 |
+
background: linear-gradient(135deg, rgba(80, 60, 140, 0.5), rgba(20, 40, 70, 0.55));
|
| 285 |
+
overflow: hidden;
|
| 286 |
}
|
| 287 |
+
.card-art.generated {
|
| 288 |
+
border-style: solid;
|
| 289 |
+
background-color: #0d0a16;
|
| 290 |
+
background-repeat: no-repeat;
|
| 291 |
+
background-size: cover;
|
| 292 |
+
background-position: center;
|
| 293 |
+
}
|
| 294 |
+
.pending-art {
|
| 295 |
+
position: relative;
|
| 296 |
+
border-color: rgba(190, 160, 255, 0.28);
|
| 297 |
+
}
|
| 298 |
+
.pending-art::after {
|
| 299 |
+
content: "";
|
| 300 |
+
position: absolute;
|
| 301 |
+
inset: 0;
|
| 302 |
+
background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.12) 42%, transparent 70%);
|
| 303 |
+
transform: translateX(-120%);
|
| 304 |
+
animation: art-sheen 1.8s ease-in-out infinite;
|
| 305 |
+
}
|
| 306 |
+
.school-art-fire { background: radial-gradient(circle at 40% 35%, rgba(255, 155, 80, 0.34), transparent 32%), linear-gradient(135deg, #3a1724, #180d1f 72%); }
|
| 307 |
+
.school-art-ice { background: radial-gradient(circle at 45% 35%, rgba(125, 220, 255, 0.32), transparent 34%), linear-gradient(135deg, #172c4a, #111328 72%); }
|
| 308 |
+
.school-art-earth { background: radial-gradient(circle at 45% 38%, rgba(170, 240, 138, 0.28), transparent 34%), linear-gradient(135deg, #20331f, #111328 72%); }
|
| 309 |
+
@keyframes art-sheen {
|
| 310 |
+
from { transform: translateX(-120%); }
|
| 311 |
+
to { transform: translateX(120%); }
|
| 312 |
+
}
|
| 313 |
+
.card-rules { font-size: 11.5px; line-height: 1.3; min-height: 44px; color: #ece4ff; }
|
| 314 |
.card-flavor {
|
| 315 |
+
color: #b9a8e0;
|
| 316 |
font-size: 10px;
|
| 317 |
font-style: italic;
|
| 318 |
+
border-top: 1px solid rgba(200, 170, 255, 0.2);
|
| 319 |
padding-top: 4px;
|
| 320 |
}
|
| 321 |
|
| 322 |
/* ---- draft screen ---- */
|
| 323 |
+
.draft-board {
|
| 324 |
+
width: min(1220px, calc(100vw - 56px));
|
| 325 |
+
min-height: 82vh;
|
| 326 |
+
margin: 0 auto;
|
| 327 |
+
display: flex;
|
| 328 |
+
flex-direction: column;
|
| 329 |
+
align-items: center;
|
| 330 |
+
justify-content: center;
|
| 331 |
+
gap: 24px;
|
| 332 |
+
padding: 20px 18px;
|
| 333 |
+
}
|
| 334 |
.draft-banner { text-align: center; }
|
| 335 |
+
.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; }
|
| 336 |
+
.draft-banner p { margin: 6px 0 0; color: #b9a8e0; font-size: 17px; }
|
| 337 |
+
.draft-pack {
|
| 338 |
+
display: grid;
|
| 339 |
+
grid-template-columns: repeat(3, minmax(0, 300px));
|
| 340 |
+
gap: clamp(16px, 2vw, 30px);
|
| 341 |
+
justify-content: center;
|
| 342 |
+
align-items: stretch;
|
| 343 |
+
width: 100%;
|
| 344 |
+
}
|
| 345 |
+
.draft-card {
|
| 346 |
+
width: 100%;
|
| 347 |
+
min-width: 0;
|
| 348 |
+
min-height: 405px;
|
| 349 |
+
cursor: pointer;
|
| 350 |
+
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
| 351 |
+
animation: pack-in 0.5s ease backwards;
|
| 352 |
+
}
|
| 353 |
.draft-pack .draft-card:nth-child(2) { animation-delay: 0.1s; }
|
| 354 |
.draft-pack .draft-card:nth-child(3) { animation-delay: 0.2s; }
|
| 355 |
+
.draft-card:hover { transform: translateY(-10px) scale(1.045); box-shadow: 0 0 22px rgba(56, 232, 210, 0.5); }
|
| 356 |
@keyframes pack-in {
|
| 357 |
from { opacity: 0; transform: translateY(28px) scale(0.92); }
|
| 358 |
to { opacity: 1; transform: none; }
|
|
|
|
| 367 |
30% { opacity: 1; transform: scale(1.08); box-shadow: 0 0 44px rgba(242, 194, 77, 0.95); }
|
| 368 |
to { opacity: 0; transform: translateY(-34px) scale(1.02); box-shadow: 0 0 30px rgba(242, 194, 77, 0.6); }
|
| 369 |
}
|
| 370 |
+
.draft-card .card-name { font-size: 17px; min-height: 44px; }
|
| 371 |
.draft-card .card-art { height: 112px; }
|
| 372 |
+
.draft-card .card-rules { font-size: 13px; min-height: 64px; }
|
| 373 |
.draft-card .card-flavor { font-size: 12px; }
|
| 374 |
+
.starter-board { min-height: 82vh; gap: 22px; }
|
| 375 |
+
.starter-grid {
|
| 376 |
+
display: grid;
|
| 377 |
+
grid-template-columns: repeat(3, minmax(190px, 1fr));
|
| 378 |
+
gap: 18px;
|
| 379 |
+
width: min(920px, 92vw);
|
| 380 |
+
}
|
| 381 |
+
.starter-card { min-height: 260px; }
|
| 382 |
+
.starter-card .card-name { font-size: 17px; min-height: 40px; }
|
| 383 |
+
.starter-card .card-art { height: 70px; }
|
| 384 |
+
.starter-card .card-rules { font-size: 13.5px; min-height: 46px; }
|
| 385 |
+
.starter-card .card-flavor { font-size: 12px; }
|
| 386 |
+
.loading-board { gap: 30px; }
|
| 387 |
+
.draft-loading {
|
| 388 |
+
width: min(520px, 86vw);
|
| 389 |
+
display: flex;
|
| 390 |
+
flex-direction: column;
|
| 391 |
+
align-items: center;
|
| 392 |
+
gap: 12px;
|
| 393 |
+
padding: 20px 22px;
|
| 394 |
+
border: 1px solid rgba(255, 211, 92, 0.35);
|
| 395 |
+
border-radius: 12px;
|
| 396 |
+
background: rgba(12, 8, 30, 0.6);
|
| 397 |
+
box-shadow: 0 0 24px rgba(255, 211, 92, 0.12);
|
| 398 |
+
}
|
| 399 |
+
.loading-title {
|
| 400 |
+
color: #ffd35c;
|
| 401 |
+
font-size: 20px;
|
| 402 |
+
font-weight: 900;
|
| 403 |
+
letter-spacing: 0.08em;
|
| 404 |
+
text-transform: uppercase;
|
| 405 |
+
}
|
| 406 |
+
.loading-subtitle {
|
| 407 |
+
color: #b9a8e0;
|
| 408 |
+
font-size: 13px;
|
| 409 |
+
text-align: center;
|
| 410 |
+
}
|
| 411 |
+
.rules-screen {
|
| 412 |
+
min-height: 74vh;
|
| 413 |
+
display: flex;
|
| 414 |
+
align-items: center;
|
| 415 |
+
justify-content: center;
|
| 416 |
+
padding: 24px;
|
| 417 |
+
}
|
| 418 |
+
.rules-card {
|
| 419 |
+
width: min(780px, 92vw);
|
| 420 |
+
border: 2px solid rgba(255, 211, 92, 0.32);
|
| 421 |
+
border-radius: 16px;
|
| 422 |
+
background: rgba(16, 10, 34, 0.82);
|
| 423 |
+
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45), 0 0 28px rgba(255, 211, 92, 0.12);
|
| 424 |
+
padding: 30px;
|
| 425 |
+
}
|
| 426 |
+
.rules-card h1 {
|
| 427 |
+
margin: 0 0 12px;
|
| 428 |
+
color: #ffd35c;
|
| 429 |
+
letter-spacing: 0.08em;
|
| 430 |
+
text-transform: uppercase;
|
| 431 |
+
}
|
| 432 |
+
.rules-card p {
|
| 433 |
+
color: #d9d0f0;
|
| 434 |
+
font-size: 16px;
|
| 435 |
+
line-height: 1.45;
|
| 436 |
+
}
|
| 437 |
+
.rules-grid {
|
| 438 |
+
display: grid;
|
| 439 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 440 |
+
gap: 12px;
|
| 441 |
+
margin-top: 18px;
|
| 442 |
+
}
|
| 443 |
+
.rule-tile {
|
| 444 |
+
border: 1px solid rgba(190, 160, 255, 0.28);
|
| 445 |
+
border-radius: 10px;
|
| 446 |
+
padding: 14px;
|
| 447 |
+
background: rgba(42, 35, 71, 0.62);
|
| 448 |
+
}
|
| 449 |
+
.rule-tile b {
|
| 450 |
+
display: block;
|
| 451 |
+
color: #f6f0ff;
|
| 452 |
+
margin-bottom: 6px;
|
| 453 |
+
}
|
| 454 |
+
.rule-tile span {
|
| 455 |
+
color: #b9a8e0;
|
| 456 |
+
font-size: 14px;
|
| 457 |
+
line-height: 1.35;
|
| 458 |
+
}
|
| 459 |
+
.loading-bar {
|
| 460 |
+
width: 100%;
|
| 461 |
+
height: 12px;
|
| 462 |
+
overflow: hidden;
|
| 463 |
+
border-radius: 999px;
|
| 464 |
+
background: rgba(20, 14, 50, 0.95);
|
| 465 |
+
border: 1px solid rgba(95, 220, 255, 0.35);
|
| 466 |
+
}
|
| 467 |
+
.loading-bar span {
|
| 468 |
+
display: block;
|
| 469 |
+
width: 40%;
|
| 470 |
+
height: 100%;
|
| 471 |
+
border-radius: inherit;
|
| 472 |
+
background: linear-gradient(90deg, #38e8d2, #ffd35c, #ff9d80);
|
| 473 |
+
animation: loading-sweep 1.15s ease-in-out infinite;
|
| 474 |
+
}
|
| 475 |
+
@keyframes loading-sweep {
|
| 476 |
+
0% { transform: translateX(-110%); }
|
| 477 |
+
100% { transform: translateX(260%); }
|
| 478 |
+
}
|
| 479 |
+
.deck-strip { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; max-width: 1100px; align-items: center; }
|
| 480 |
+
.deck-strip-label { font-weight: 800; color: #f2c24d; margin-right: 6px; font-size: 18px; }
|
| 481 |
+
.deck-chip { background: rgba(10, 6, 30, 0.5); border: 1px solid #5b4a8f; border-radius: 12px; padding: 3px 12px; font-size: 13px; }
|
| 482 |
+
.deck-chip b { color: #38e8d2; }
|
| 483 |
+
.deck-chip.anchor { border-color: #ffd35c; box-shadow: 0 0 8px rgba(255, 211, 92, 0.4); }
|
| 484 |
+
@media (max-width: 900px) {
|
| 485 |
+
.draft-board { min-height: auto; justify-content: flex-start; }
|
| 486 |
+
.draft-pack { grid-template-columns: 1fr; max-width: 320px; }
|
| 487 |
+
.draft-card { width: min(300px, 92vw); min-height: 405px; }
|
| 488 |
+
.starter-grid { grid-template-columns: repeat(2, minmax(150px, 1fr)); }
|
| 489 |
+
}
|
| 490 |
|
| 491 |
/* ---- battle board ---- */
|
| 492 |
.board {
|
| 493 |
position: relative;
|
| 494 |
+
border: 4px solid #7a5630;
|
| 495 |
border-radius: 22px;
|
| 496 |
padding: 8px 14px;
|
| 497 |
+
background: linear-gradient(rgba(24, 13, 5, 0.42), rgba(14, 7, 2, 0.6)), url("__WOOD_BG__") center / cover;
|
| 498 |
+
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);
|
| 499 |
overflow: hidden;
|
| 500 |
}
|
| 501 |
.zone { position: relative; display: flex; align-items: center; justify-content: center; }
|
|
|
|
| 504 |
.pile {
|
| 505 |
width: 64px;
|
| 506 |
text-align: center;
|
| 507 |
+
border: 2px solid #5b4a8f;
|
| 508 |
border-radius: 8px;
|
| 509 |
padding: 6px 0 3px;
|
| 510 |
+
background: repeating-linear-gradient(45deg, #1c1535 0, #1c1535 6px, #251c45 6px, #251c45 12px);
|
| 511 |
}
|
| 512 |
+
.pile span { font-weight: 900; font-size: 18px; color: #ffd35c; }
|
| 513 |
+
.pile label { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: #9d8fc7; }
|
| 514 |
.fatigue-warn { color: #ff8d7a; font-size: 10px; font-weight: 800; }
|
| 515 |
|
| 516 |
.hero { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
|
|
|
| 520 |
height: 100%;
|
| 521 |
border-radius: 50% 50% 46% 46%;
|
| 522 |
border: 4px solid #b08a4f;
|
| 523 |
+
background: radial-gradient(circle at 50% 30%, #4a3e72, #1b1430 75%);
|
| 524 |
display: flex;
|
| 525 |
align-items: center;
|
| 526 |
justify-content: center;
|
|
|
|
| 589 |
width: 52px;
|
| 590 |
height: 72px;
|
| 591 |
border-radius: 6px;
|
| 592 |
+
border: 2px solid #7a5fc0;
|
| 593 |
margin: 0 -10px;
|
| 594 |
+
background: repeating-linear-gradient(45deg, #2a1745 0, #2a1745 7px, #371d5c 7px, #371d5c 14px);
|
| 595 |
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.4);
|
| 596 |
transform: rotate(3deg);
|
| 597 |
}
|
|
|
|
| 602 |
min-height: 118px;
|
| 603 |
margin: 8px 36px;
|
| 604 |
border-radius: 14px;
|
| 605 |
+
background: rgba(10, 6, 30, 0.35);
|
| 606 |
box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.35);
|
| 607 |
display: flex;
|
| 608 |
flex-direction: column;
|
|
|
|
| 617 |
font-weight: 800;
|
| 618 |
letter-spacing: 0.12em;
|
| 619 |
text-transform: uppercase;
|
| 620 |
+
color: #cfc3f2;
|
| 621 |
font-size: 13px;
|
| 622 |
}
|
| 623 |
.end-turn {
|
|
|
|
| 666 |
padding: 1px 10px;
|
| 667 |
border-radius: 10px;
|
| 668 |
}
|
| 669 |
+
.play-label.you { color: #7df5e2; background: rgba(20, 90, 80, 0.45); border: 1px solid #2aa896; }
|
| 670 |
+
.play-label.boss { color: #f0b8ff; background: rgba(110, 30, 130, 0.45); border: 1px solid #b14ad0; }
|
| 671 |
.played-card { width: 132px; }
|
| 672 |
+
.played-card.player-play { box-shadow: 0 0 22px rgba(56, 232, 210, 0.6); }
|
| 673 |
+
.played-card.enemy-play { box-shadow: 0 0 22px rgba(232, 91, 255, 0.6); border-color: #b14ad0; }
|
| 674 |
@keyframes play-in {
|
| 675 |
from { transform: translateY(120px) rotateY(90deg) scale(0.4); opacity: 0; }
|
| 676 |
60% { transform: translateY(-6px) rotateY(0deg) scale(1.12); opacity: 1; }
|
|
|
|
| 698 |
transform-origin: 50% 130%;
|
| 699 |
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
| 700 |
cursor: pointer;
|
| 701 |
+
box-shadow: 0 0 14px rgba(56, 232, 210, 0.55);
|
| 702 |
+
border-color: #45e8c8;
|
| 703 |
}
|
| 704 |
.hand-card:hover {
|
| 705 |
transform: rotate(0deg) translateY(-48px) scale(1.45);
|
| 706 |
z-index: 99 !important;
|
| 707 |
+
box-shadow: 0 0 24px rgba(56, 232, 210, 0.5), 0 24px 50px rgba(0, 0, 0, 0.6);
|
| 708 |
}
|
| 709 |
+
.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; }
|
| 710 |
.hand-card.unplayable:hover { box-shadow: 0 24px 50px rgba(0, 0, 0, 0.6); }
|
| 711 |
.hand-card.launching {
|
| 712 |
transform: translateY(-170px) scale(1.08) rotate(0deg) !important;
|
|
|
|
| 728 |
animation: splash-bg 1.9s ease forwards;
|
| 729 |
}
|
| 730 |
@keyframes splash-bg {
|
| 731 |
+
0% { background: rgba(6, 3, 18, 0); }
|
| 732 |
+
18% { background: rgba(6, 3, 18, 0.78); }
|
| 733 |
+
72% { background: rgba(6, 3, 18, 0.78); }
|
| 734 |
+
100% { background: rgba(6, 3, 18, 0); }
|
| 735 |
}
|
| 736 |
.splash-round {
|
| 737 |
font-size: 50px;
|
| 738 |
font-weight: 900;
|
| 739 |
letter-spacing: 0.3em;
|
| 740 |
+
color: #ffd35c;
|
| 741 |
+
text-shadow: 0 0 34px rgba(255, 211, 92, 0.9), 0 4px 8px #000;
|
| 742 |
animation: splash 1.9s ease forwards;
|
| 743 |
}
|
| 744 |
.splash-initiative {
|
|
|
|
| 747 |
letter-spacing: 0.26em;
|
| 748 |
animation: splash 1.65s ease 0.25s both;
|
| 749 |
}
|
| 750 |
+
.splash-initiative.you { color: #38e8d2; text-shadow: 0 0 24px rgba(56, 232, 210, 0.85); }
|
| 751 |
+
.splash-initiative.boss { color: #f06bff; text-shadow: 0 0 24px rgba(232, 91, 255, 0.85); }
|
| 752 |
@keyframes splash {
|
| 753 |
0% { opacity: 0; transform: scale(2.2); }
|
| 754 |
25% { opacity: 1; transform: scale(1); }
|
|
|
|
| 763 |
z-index: 60;
|
| 764 |
padding: 5px 16px;
|
| 765 |
border-radius: 14px;
|
| 766 |
+
background: rgba(20, 10, 36, 0.94);
|
| 767 |
+
border: 1px solid #b14ad0;
|
| 768 |
+
color: #f0c4ff;
|
| 769 |
font-weight: 700;
|
| 770 |
font-size: 12px;
|
| 771 |
letter-spacing: 0.08em;
|
|
|
|
| 776 |
0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); }
|
| 777 |
50% { opacity: 1; transform: translateX(-50%) scale(1.06); }
|
| 778 |
}
|
| 779 |
+
.board.thinking .hero.enemy .hero-face { box-shadow: 0 0 28px rgba(232, 91, 255, 0.6); }
|
| 780 |
|
| 781 |
.winner-banner {
|
| 782 |
position: absolute;
|
|
|
|
| 786 |
gap: 16px;
|
| 787 |
align-items: center;
|
| 788 |
justify-content: center;
|
| 789 |
+
background: rgba(8, 5, 22, 0.78);
|
| 790 |
z-index: 200;
|
| 791 |
border-radius: 18px;
|
| 792 |
}
|
|
|
|
| 797 |
}
|
| 798 |
.winner-banner.victory h1 { color: #ffd98c; text-shadow: 0 0 30px rgba(255, 200, 90, 0.7); }
|
| 799 |
.winner-banner.defeat h1 { color: #ff7a6a; text-shadow: 0 0 30px rgba(255, 70, 40, 0.6); }
|
| 800 |
+
.winner-banner.draw h1 { color: #b9a8e0; }
|
| 801 |
.new-run {
|
| 802 |
padding: 12px 30px;
|
| 803 |
border-radius: 24px;
|
|
|
|
| 809 |
}
|
| 810 |
.game-over .hand-fan, .game-over .end-turn { pointer-events: none; }
|
| 811 |
|
| 812 |
+
.log-panel { background: rgba(18, 14, 38, 0.85) !important; border: 2px solid #5b4a8f; border-radius: 14px; padding: 8px; }
|
| 813 |
.log-scroll {
|
| 814 |
display: flex;
|
| 815 |
flex-direction: column-reverse;
|
|
|
|
| 817 |
max-height: 600px;
|
| 818 |
overflow-y: auto;
|
| 819 |
font-size: 11.5px;
|
| 820 |
+
color: #d9d0f0;
|
| 821 |
}
|
| 822 |
.log-line { padding: 4px 8px; border-left: 3px solid transparent; border-radius: 4px; line-height: 1.35; }
|
| 823 |
+
.log-line b { color: #ffd35c; font-size: 12.5px; }
|
| 824 |
.log-owner { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; opacity: 0.7; }
|
| 825 |
.log-rules { display: block; font-size: 10.5px; opacity: 0.85; }
|
| 826 |
+
.log-play.log-you { border-left-color: #38e8d2; background: rgba(30, 120, 110, 0.22); }
|
| 827 |
+
.log-play.log-boss { border-left-color: #e85bff; background: rgba(120, 40, 140, 0.22); }
|
| 828 |
.log-round {
|
| 829 |
text-align: center;
|
| 830 |
+
color: #ffd35c;
|
| 831 |
font-weight: 800;
|
| 832 |
letter-spacing: 0.06em;
|
| 833 |
margin-top: 8px;
|
| 834 |
+
border-top: 1px solid rgba(255, 211, 92, 0.35);
|
| 835 |
padding-top: 6px;
|
| 836 |
}
|
| 837 |
+
.log-winner { text-align: center; color: #ffd35c; font-weight: 900; font-size: 13px; border: 1px solid #ffd35c; background: rgba(255, 211, 92, 0.14); }
|
| 838 |
.log-muted { opacity: 0.55; font-style: italic; }
|
| 839 |
+
.log-draft { color: #aaf08a; }
|
| 840 |
+
.log-scroll > .log-line:first-child { box-shadow: inset 0 0 0 1px rgba(255, 211, 92, 0.35); }
|
| 841 |
"""
|
| 842 |
|
| 843 |
+
CSS = CSS.replace("__INN_BG__", INN_BG).replace("__WOOD_BG__", WOOD_BG)
|
| 844 |
+
|
| 845 |
|
| 846 |
# Build all Gradio components for the Tabras app.
|
| 847 |
def build_app() -> gr.Blocks:
|
| 848 |
with gr.Blocks(title="Tabras") as app:
|
| 849 |
state = gr.State(None)
|
| 850 |
+
screen_state = gr.State("title")
|
| 851 |
|
| 852 |
with gr.Group(visible=True, elem_id="title-screen") as title_group:
|
| 853 |
gr.HTML("<div class='tabras-title'><h1>Tabras</h1><p>A small-model card duel on an old wooden desk.</p></div>")
|
| 854 |
+
play_now = gr.Button("Play Now", variant="primary", elem_id="play-now-btn")
|
| 855 |
|
| 856 |
+
with gr.Group(visible=False, elem_id="name-screen") as name_group:
|
| 857 |
with gr.Column(elem_classes=["setup-panel"]):
|
| 858 |
+
gr.HTML("<div class='step-kicker'>Step 1 of 3</div><h2>Name your challenger</h2>")
|
| 859 |
name = gr.Textbox(label="Name", value="Vishnu")
|
| 860 |
+
name_next = gr.Button("Continue", variant="primary")
|
| 861 |
+
|
| 862 |
+
with gr.Group(visible=False, elem_id="school-screen") as school_group:
|
| 863 |
+
with gr.Column(elem_classes=["setup-panel"]):
|
| 864 |
+
gr.HTML("<div class='step-kicker'>Step 3 of 3</div><h2>Choose your spell school</h2>")
|
| 865 |
+
school_view = gr.HTML(school_selector_html("Dark Fantasy"))
|
| 866 |
+
|
| 867 |
+
with gr.Group(visible=False, elem_id="background-screen") as background_group:
|
| 868 |
+
with gr.Column(elem_classes=["setup-panel"]):
|
| 869 |
+
gr.HTML("<div class='step-kicker'>Step 2 of 3</div><h2>Choose your background</h2>")
|
| 870 |
+
gr.HTML(background_selector_html())
|
| 871 |
+
|
| 872 |
+
with gr.Group(visible=False, elem_id="rules-screen") as rules_group:
|
| 873 |
+
rules_view = gr.HTML()
|
| 874 |
+
continue_rules = gr.Button("Start Draft", variant="primary", elem_id="start-draft-btn")
|
| 875 |
|
| 876 |
with gr.Group(visible=False, elem_id="draft-screen") as draft_group:
|
| 877 |
draft_view = gr.HTML()
|
|
|
|
| 887 |
with gr.Row(elem_classes=["hidden-controls"]):
|
| 888 |
hand_buttons = [gr.Button("", elem_id=f"hand-btn-{index}") for index in range(HAND_PANEL_COUNT)]
|
| 889 |
draft_buttons = [gr.Button("", elem_id=f"draft-btn-{index}") for index in range(CARD_PANEL_COUNT)]
|
| 890 |
+
background_buttons = [gr.Button("", elem_id=f"background-btn-{slug}") for slug in ("dark-fantasy", "cyberpunk", "anime")]
|
| 891 |
+
school_buttons = [gr.Button("", elem_id=f"school-btn-{slug}") for slug in ("fire", "ice", "earth")]
|
| 892 |
end_turn = gr.Button("", elem_id="end-turn-btn")
|
| 893 |
restart = gr.Button("", elem_id="restart-btn")
|
| 894 |
+
world_state = gr.State("Dark Fantasy")
|
| 895 |
|
| 896 |
+
outputs = [
|
| 897 |
+
state,
|
| 898 |
+
screen_state,
|
| 899 |
+
title_group,
|
| 900 |
+
name_group,
|
| 901 |
+
school_group,
|
| 902 |
+
background_group,
|
| 903 |
+
rules_group,
|
| 904 |
+
draft_group,
|
| 905 |
+
battle_group,
|
| 906 |
+
rules_view,
|
| 907 |
+
draft_view,
|
| 908 |
+
board_view,
|
| 909 |
+
log_view,
|
| 910 |
+
school_view,
|
| 911 |
+
world_state,
|
| 912 |
+
]
|
| 913 |
|
| 914 |
+
play_now.click(show_name, outputs=outputs)
|
| 915 |
+
name_next.click(show_background, outputs=outputs)
|
| 916 |
+
for value, button in zip(("Dark Fantasy", "Cyberpunk", "Anime"), background_buttons):
|
| 917 |
+
button.click(value_handler(choose_background, value), outputs=outputs)
|
| 918 |
+
for value, button in zip(("fire", "ice", "earth"), school_buttons):
|
| 919 |
+
button.click(value_handler(choose_school, value), inputs=[name, world_state], outputs=outputs)
|
| 920 |
+
continue_rules.click(show_draft_from_rules, inputs=[state], outputs=outputs)
|
| 921 |
for index, button in enumerate(draft_buttons):
|
| 922 |
button.click(indexed_handler(draft_pick, index), inputs=[state], outputs=outputs)
|
| 923 |
for index, button in enumerate(hand_buttons):
|
| 924 |
button.click(indexed_handler(play_card, index), inputs=[state], outputs=outputs)
|
| 925 |
end_turn.click(end_player_turn, inputs=[state], outputs=outputs)
|
| 926 |
+
restart.click(show_name, outputs=outputs)
|
| 927 |
+
gr.Timer(0.8).tick(refresh_screen, inputs=[state, screen_state, world_state], outputs=outputs)
|
| 928 |
|
| 929 |
return app
|
| 930 |
|
|
|
|
| 937 |
return stream
|
| 938 |
|
| 939 |
|
| 940 |
+
# Bind one string value into a handler.
|
| 941 |
+
def value_handler(handler, value: str):
|
| 942 |
+
def call(*args):
|
| 943 |
+
return handler(value, *args)
|
| 944 |
+
|
| 945 |
+
return call
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
# Show the name prompt.
|
| 949 |
+
def show_name() -> list[object]:
|
| 950 |
+
return render(None, "name")
|
| 951 |
+
|
| 952 |
+
|
| 953 |
+
# Show the school prompt.
|
| 954 |
+
def show_school() -> list[object]:
|
| 955 |
+
return render(None, "school")
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
# Show the background prompt.
|
| 959 |
+
def show_background() -> list[object]:
|
| 960 |
+
return render(None, "background")
|
| 961 |
+
|
| 962 |
|
| 963 |
+
# Store one background choice and advance to school selection.
|
| 964 |
+
def choose_background(world: str) -> list[object]:
|
| 965 |
+
return render(None, "school", world)
|
| 966 |
|
| 967 |
+
|
| 968 |
+
# Start rules after one school selector is clicked.
|
| 969 |
+
def choose_school(school: str, name: str, world: str) -> list[object]:
|
| 970 |
+
return start_rules(name, school, world)
|
| 971 |
+
|
| 972 |
+
|
| 973 |
+
# Start deck generation while the rules screen is shown.
|
| 974 |
+
def start_rules(name: str, school: str, world: str) -> list[object]:
|
| 975 |
client = card_client_from_env()
|
| 976 |
+
art_client = art_client_from_env()
|
| 977 |
+
run_state = new_run_shell(name, world, school_as_literal(school), seed=Random().getrandbits(32))
|
| 978 |
+
run_state = queue_next_pack(run_state, client, art_client)
|
| 979 |
+
return render(run_state, "rules")
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
# Move from rules to draft, showing deck loading if the first pack is not ready.
|
| 983 |
+
def show_draft_from_rules(run_state: RunState | None) -> list[object]:
|
| 984 |
+
if run_state is None:
|
| 985 |
+
return render(None, "name")
|
| 986 |
+
run_state = collect_ready_pack(refresh_art(run_state), card_client_from_env(), art_client_from_env())
|
| 987 |
+
if run_state is not None and not run_state.current_pack and run_state.duel is None:
|
| 988 |
+
run_state = replace(run_state, loading="Loading your deck")
|
| 989 |
return render(run_state, "draft")
|
| 990 |
|
| 991 |
|
| 992 |
# Choose one draft card, streaming the paced battle opening on the final pick.
|
| 993 |
def draft_pick(run_state: RunState | None, index: int):
|
| 994 |
if run_state is None:
|
| 995 |
+
yield render(None, "name")
|
| 996 |
return
|
| 997 |
client = card_client_from_env()
|
| 998 |
+
art_client = art_client_from_env()
|
| 999 |
+
yield from paced_frames(choose_draft_card_loading_steps(run_state, index, client, art_client))
|
| 1000 |
|
| 1001 |
|
| 1002 |
# Play one hand card by index, streaming the paced boss response.
|
| 1003 |
def play_card(run_state: RunState | None, index: int):
|
| 1004 |
if run_state is None:
|
| 1005 |
+
yield render(None, "name")
|
| 1006 |
return
|
| 1007 |
yield from paced_frames(play_hand_card_steps(run_state, index))
|
| 1008 |
|
|
|
|
| 1010 |
# End the player turn, streaming the paced boss response.
|
| 1011 |
def end_player_turn(run_state: RunState | None):
|
| 1012 |
if run_state is None:
|
| 1013 |
+
yield render(None, "name")
|
| 1014 |
return
|
| 1015 |
yield from paced_frames(pass_turn_steps(run_state))
|
| 1016 |
|
| 1017 |
|
| 1018 |
+
# Refresh visible generated art without advancing the game.
|
| 1019 |
+
def refresh_screen(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]:
|
| 1020 |
+
if screen not in {"title", "name", "school", "background", "rules", "draft", "battle"}:
|
| 1021 |
+
screen = "title"
|
| 1022 |
+
if screen in {"rules", "draft"}:
|
| 1023 |
+
run_state = collect_ready_pack(refresh_art(run_state), card_client_from_env(), art_client_from_env())
|
| 1024 |
+
return render(refresh_art(run_state), screen, world)
|
| 1025 |
+
|
| 1026 |
+
|
| 1027 |
# Yield rendered frames, letting each dramatic beat linger on screen.
|
| 1028 |
def paced_frames(steps):
|
| 1029 |
previous = None
|
|
|
|
| 1037 |
# Return how long one frame should stay on screen before the next.
|
| 1038 |
def frame_delay(state: RunState) -> float:
|
| 1039 |
if state.round_flash:
|
|
|
|
|
|
|
| 1040 |
return 1.4
|
| 1041 |
+
if state.boss_thinking:
|
| 1042 |
+
return 0.35
|
| 1043 |
if state.pack_fading >= 0:
|
| 1044 |
+
return 0.45
|
| 1045 |
+
return 0.45
|
| 1046 |
|
| 1047 |
|
| 1048 |
# Render all app outputs.
|
| 1049 |
+
def render(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]:
|
| 1050 |
+
run_state = refresh_art(run_state)
|
| 1051 |
+
world = run_state.world if run_state is not None else world
|
| 1052 |
return [
|
| 1053 |
run_state,
|
| 1054 |
+
screen,
|
| 1055 |
gr.update(visible=screen == "title"),
|
| 1056 |
+
gr.update(visible=screen == "name"),
|
| 1057 |
+
gr.update(visible=screen == "school"),
|
| 1058 |
+
gr.update(visible=screen == "background"),
|
| 1059 |
+
gr.update(visible=screen == "rules"),
|
| 1060 |
gr.update(visible=screen == "draft"),
|
| 1061 |
gr.update(visible=screen == "battle"),
|
| 1062 |
+
rules_html(run_state),
|
| 1063 |
draft_screen_html(run_state),
|
| 1064 |
board_html(run_state),
|
| 1065 |
log_html(run_state),
|
| 1066 |
+
school_selector_html(world),
|
| 1067 |
+
world,
|
| 1068 |
]
|
| 1069 |
|
| 1070 |
|
| 1071 |
+
# Return HTML for image-backed background choices.
|
| 1072 |
+
def background_selector_html() -> str:
|
| 1073 |
+
cards = (
|
| 1074 |
+
selector_card("background-btn-dark-fantasy", "Dark Fantasy", "You are a mage in a forlorn, lost, and dark world.", "darkFantasy.png", "#25152d", "#5f315f"),
|
| 1075 |
+
selector_card("background-btn-cyberpunk", "Cyberpunk", "You are a spellrunner in a neon city where old magic haunts new machines.", "cyberpunk.png", "#102438", "#2dd7d0"),
|
| 1076 |
+
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"),
|
| 1077 |
+
)
|
| 1078 |
+
return "<div class='selector-grid'>" + "".join(cards) + "</div>"
|
| 1079 |
+
|
| 1080 |
+
|
| 1081 |
+
# Return HTML for image-backed school choices.
|
| 1082 |
+
def school_selector_html(world: str = "Dark Fantasy") -> str:
|
| 1083 |
+
cards = tuple(selector_card(*card) for card in school_selector_cards(world))
|
| 1084 |
+
return "<div class='selector-grid'>" + "".join(cards) + "</div>"
|
| 1085 |
+
|
| 1086 |
+
|
| 1087 |
+
# Return world-specific school selector card specs.
|
| 1088 |
+
def school_selector_cards(world: str) -> tuple[tuple[str, str, str, str, str, str], ...]:
|
| 1089 |
+
slug = world_slug(world)
|
| 1090 |
+
copy = school_selector_copy(slug)
|
| 1091 |
+
return (
|
| 1092 |
+
("school-btn-fire", "Fire", copy["fire"], school_asset_name(slug, "fire"), "#351018", "#f06a2a"),
|
| 1093 |
+
("school-btn-ice", "Ice", copy["ice"], school_asset_name(slug, "ice"), "#102040", "#7ed9ff"),
|
| 1094 |
+
("school-btn-earth", "Earth", copy["earth"], school_asset_name(slug, "earth"), "#1d2d18", "#a5d46a"),
|
| 1095 |
+
)
|
| 1096 |
+
|
| 1097 |
+
|
| 1098 |
+
# Return hover copy for school choices in one world.
|
| 1099 |
+
def school_selector_copy(slug: str) -> dict[str, str]:
|
| 1100 |
+
if slug == "cyberpunk":
|
| 1101 |
+
return {
|
| 1102 |
+
"fire": "Burn through the neon grid with volatile heat, overclocked rituals, and delayed detonations.",
|
| 1103 |
+
"ice": "Freeze signal, tempo, and nerve; win through clean timing and exposed weaknesses.",
|
| 1104 |
+
"earth": "Raise concrete, steel, and old stone to bank pressure before the city breaks.",
|
| 1105 |
+
}
|
| 1106 |
+
if slug == "anime":
|
| 1107 |
+
return {
|
| 1108 |
+
"fire": "Fight like a rival prodigy: explosive pressure, bold finishers, and impossible sparks.",
|
| 1109 |
+
"ice": "Move first, punish openings, and turn one perfect tempo beat into a burst.",
|
| 1110 |
+
"earth": "Stand your ground, gather force, and answer with a dramatic shield-charged strike.",
|
| 1111 |
+
}
|
| 1112 |
+
return {
|
| 1113 |
+
"fire": "Call ruinous flame in a lost kingdom: burn clocks, bombs, and violent finishers.",
|
| 1114 |
+
"ice": "Bind the dark with frost, tempo, and precise strikes through brittle openings.",
|
| 1115 |
+
"earth": "Wear the grave-stone crown: absorb the blow, bank force, and bury the boss.",
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
# Return a stable asset slug for one world label.
|
| 1120 |
+
def world_slug(world: str) -> str:
|
| 1121 |
+
return world.lower().replace(" ", "-")
|
| 1122 |
+
|
| 1123 |
+
|
| 1124 |
+
# Return the bundled image filename for a world-specific school.
|
| 1125 |
+
def school_asset_name(slug: str, school: str) -> str:
|
| 1126 |
+
prefix = {"dark-fantasy": "darkFantasy", "cyberpunk": "cyberpunk", "anime": "anime"}[slug]
|
| 1127 |
+
suffix = {"fire": "Fire", "ice": "Ice", "earth": "Earth"}[school]
|
| 1128 |
+
if slug == "cyberpunk" and school == "ice":
|
| 1129 |
+
return "cyberpunkice.png"
|
| 1130 |
+
return f"{prefix}{suffix}.png"
|
| 1131 |
+
|
| 1132 |
+
|
| 1133 |
+
# Return one clickable visual selector card.
|
| 1134 |
+
def selector_card(button_id: str, label: str, copy: str, image: str, dark: str, accent: str) -> str:
|
| 1135 |
+
uri = optional_asset_data_uri(image)
|
| 1136 |
+
image_tag = f"<img class='selector-image' src='{uri}' alt=''>" if uri else ""
|
| 1137 |
+
fallback = f"radial-gradient(circle at 50% 30%, {accent}, transparent 34%), linear-gradient(135deg, {dark}, #080512 82%)"
|
| 1138 |
+
return (
|
| 1139 |
+
f"<div class='selector-panel' onclick=\"tabrasClick('{button_id}')\" "
|
| 1140 |
+
f"style='--selector-fallback:{fallback};'>"
|
| 1141 |
+
f"{image_tag}"
|
| 1142 |
+
f"<div class='selector-label'>{label}</div>"
|
| 1143 |
+
f"<div class='selector-copy'>{copy}</div>"
|
| 1144 |
+
"</div>"
|
| 1145 |
+
)
|
| 1146 |
+
|
| 1147 |
+
|
| 1148 |
+
# Return the dedicated rules screen while deck generation runs.
|
| 1149 |
+
def rules_html(run_state: RunState | None) -> str:
|
| 1150 |
+
if run_state is None:
|
| 1151 |
+
return ""
|
| 1152 |
+
return (
|
| 1153 |
+
"<div class='rules-screen'><div class='rules-card'>"
|
| 1154 |
+
"<h1>Rules</h1>"
|
| 1155 |
+
"<p>Build a 15-card deck, then duel the boss across paired rounds.</p>"
|
| 1156 |
+
"<div class='rules-grid'>"
|
| 1157 |
+
"<div class='rule-tile'><b>Energy</b><span>Your energy resets each round. It does not carry over.</span></div>"
|
| 1158 |
+
"<div class='rule-tile'><b>Drafting</b><span>Pick one card from each pack. Anchor picks set the deck's direction.</span></div>"
|
| 1159 |
+
"<div class='rule-tile'><b>Defense</b><span>Block lasts until your next turn. Ward absorbs one future hit.</span></div>"
|
| 1160 |
+
"<div class='rule-tile'><b>Boss</b><span>The boss reads the public board, then plays from its hidden hand.</span></div>"
|
| 1161 |
+
"</div>"
|
| 1162 |
+
"</div></div>"
|
| 1163 |
+
)
|
| 1164 |
+
|
| 1165 |
+
|
| 1166 |
# Return a typed school value.
|
| 1167 |
def school_as_literal(value: str) -> School:
|
| 1168 |
if value in {"fire", "ice", "earth"}:
|
art.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import threading
|
| 3 |
+
from dataclasses import dataclass, field, replace
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
from typing import Any, Callable, Protocol, Sequence
|
| 6 |
+
|
| 7 |
+
from budget import Card
|
| 8 |
+
|
| 9 |
+
NEGATIVE_ART_PROMPT = (
|
| 10 |
+
"text, letters, words, labels, ui, interface, card frame, border, watermark, logo, "
|
| 11 |
+
"playing card, paper, white edge, cropped subject, cut off subject, split panel"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ArtClient(Protocol):
|
| 16 |
+
# Return a browser-renderable image URI for one prompt.
|
| 17 |
+
def create_art(self, prompt: str) -> str: # pragma: no cover
|
| 18 |
+
...
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class LazyArtClient:
|
| 23 |
+
loader: Callable[[], ArtClient]
|
| 24 |
+
lock: Any = field(default_factory=threading.Lock, repr=False, compare=False)
|
| 25 |
+
client: ArtClient | None = None
|
| 26 |
+
|
| 27 |
+
# Load the real art backend on first image request.
|
| 28 |
+
def create_art(self, prompt: str) -> str:
|
| 29 |
+
if self.client is None:
|
| 30 |
+
with self.lock:
|
| 31 |
+
if self.client is None:
|
| 32 |
+
self.client = self.loader()
|
| 33 |
+
return self.client.create_art(prompt)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True)
|
| 37 |
+
class DiffusersImageClient:
|
| 38 |
+
pipe: Any
|
| 39 |
+
steps: int = 1
|
| 40 |
+
guidance_scale: float = 0.0
|
| 41 |
+
width: int = 512
|
| 42 |
+
height: int = 320
|
| 43 |
+
lock: Any = field(default_factory=threading.Lock, repr=False, compare=False)
|
| 44 |
+
|
| 45 |
+
# Generate one image and encode it as a data URI.
|
| 46 |
+
def create_art(self, prompt: str) -> str:
|
| 47 |
+
with self.lock:
|
| 48 |
+
result = self.pipe(
|
| 49 |
+
prompt=prompt,
|
| 50 |
+
num_inference_steps=self.steps,
|
| 51 |
+
guidance_scale=self.guidance_scale,
|
| 52 |
+
width=self.width,
|
| 53 |
+
height=self.height,
|
| 54 |
+
negative_prompt=NEGATIVE_ART_PROMPT,
|
| 55 |
+
)
|
| 56 |
+
return image_data_uri(result.images[0])
|
| 57 |
+
|
| 58 |
+
# Load a diffusers text-to-image pipeline.
|
| 59 |
+
@classmethod
|
| 60 |
+
def load(
|
| 61 |
+
cls,
|
| 62 |
+
model_id: str,
|
| 63 |
+
steps: int = 1,
|
| 64 |
+
guidance_scale: float = 0.0,
|
| 65 |
+
width: int = 512,
|
| 66 |
+
height: int = 320,
|
| 67 |
+
) -> "DiffusersImageClient": # pragma: no cover
|
| 68 |
+
import torch
|
| 69 |
+
from diffusers import AutoPipelineForText2Image
|
| 70 |
+
|
| 71 |
+
pipe = AutoPipelineForText2Image.from_pretrained(model_id, torch_dtype=best_torch_dtype(torch))
|
| 72 |
+
pipe.set_progress_bar_config(disable=True)
|
| 73 |
+
pipe = move_pipe_to_device(pipe, torch)
|
| 74 |
+
return cls(pipe, steps=steps, guidance_scale=guidance_scale, width=width, height=height)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# Return the best local dtype for SDXL generation.
|
| 78 |
+
def best_torch_dtype(torch: Any) -> Any:
|
| 79 |
+
if torch.cuda.is_available():
|
| 80 |
+
return torch.float16
|
| 81 |
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
| 82 |
+
return torch.float16
|
| 83 |
+
return torch.float32
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Move a diffusers pipeline to the best available local device.
|
| 87 |
+
def move_pipe_to_device(pipe: Any, torch: Any) -> Any:
|
| 88 |
+
if torch.cuda.is_available():
|
| 89 |
+
return pipe.to("cuda")
|
| 90 |
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
| 91 |
+
return pipe.to("mps")
|
| 92 |
+
return pipe
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# Encode a PIL-like image as a PNG data URI.
|
| 96 |
+
def image_data_uri(image: Any) -> str:
|
| 97 |
+
buffer = BytesIO()
|
| 98 |
+
image.save(buffer, format="PNG")
|
| 99 |
+
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
| 100 |
+
return f"data:image/png;base64,{encoded}"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# Return the prompt sent to the art model for one card.
|
| 104 |
+
def card_art_prompt(card: Card) -> str:
|
| 105 |
+
subject = card.art_prompt or f"{card.name}, {card.school} magic, {card.rules_text()}"
|
| 106 |
+
return (
|
| 107 |
+
f"{theme_style(card.theme)}, {subject}, "
|
| 108 |
+
"single dramatic spell scene, clear centered focal subject, full composition, no text, no card frame"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# Return a visual style clause inferred from the user's world.
|
| 113 |
+
def theme_style(theme: str) -> str:
|
| 114 |
+
lowered = theme.lower()
|
| 115 |
+
if "anime" in lowered:
|
| 116 |
+
return "wide anime fantasy key visual, cel shaded, clean linework, luminous color, dynamic composition"
|
| 117 |
+
if "dark" in lowered or "gothic" in lowered:
|
| 118 |
+
return "wide dark fantasy spell illustration, dramatic chiaroscuro, painterly concept art"
|
| 119 |
+
if "wuxia" in lowered:
|
| 120 |
+
return "wide wuxia fantasy spell illustration, flowing silk, ink-wash energy, cinematic motion"
|
| 121 |
+
return f"wide fantasy spell illustration, {theme}, painterly concept art"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# Add generated art to one card, falling back to the unchanged card on failure.
|
| 125 |
+
def illustrate_card(client: ArtClient | None, card: Card) -> Card:
|
| 126 |
+
if client is None:
|
| 127 |
+
return card
|
| 128 |
+
try:
|
| 129 |
+
return replace(card, art_uri=client.create_art(card_art_prompt(card)))
|
| 130 |
+
except Exception:
|
| 131 |
+
return card
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# Add generated art to a sequence of cards.
|
| 135 |
+
def illustrate_cards(client: ArtClient | None, cards: Sequence[Card]) -> tuple[Card, ...]:
|
| 136 |
+
return tuple(illustrate_card(client, card) for card in cards)
|
assets/anime.png
ADDED
|
Git LFS Details
|
assets/animeBoss.png
ADDED
|
Git LFS Details
|
assets/animeEarth.png
ADDED
|
Git LFS Details
|
assets/animeFire.png
ADDED
|
Git LFS Details
|
assets/animeIce.png
ADDED
|
Git LFS Details
|
assets/cyberpunk.png
ADDED
|
Git LFS Details
|
assets/cyberpunkBoss.png
ADDED
|
Git LFS Details
|
assets/cyberpunkEarth.png
ADDED
|
Git LFS Details
|
assets/cyberpunkFire.png
ADDED
|
Git LFS Details
|
assets/cyberpunkice.png
ADDED
|
Git LFS Details
|
assets/darkFantasy.png
ADDED
|
Git LFS Details
|
assets/darkFantasyBoss.png
ADDED
|
Git LFS Details
|
assets/darkFantasyEarth.png
ADDED
|
Git LFS Details
|
assets/darkFantasyFire.png
ADDED
|
Git LFS Details
|
assets/darkFantasyIce.png
ADDED
|
Git LFS Details
|
assets/inn_bg.jpg
ADDED
|
Git LFS Details
|
assets/wood_board.jpg
ADDED
|
Git LFS Details
|
budget.py
CHANGED
|
@@ -35,6 +35,7 @@ class Card:
|
|
| 35 |
effects: tuple[Effect, ...]
|
| 36 |
flavor: str = ""
|
| 37 |
art_prompt: str = ""
|
|
|
|
| 38 |
|
| 39 |
# Return fixed, parseable rules text for the card.
|
| 40 |
def rules_text(self) -> str:
|
|
|
|
| 35 |
effects: tuple[Effect, ...]
|
| 36 |
flavor: str = ""
|
| 37 |
art_prompt: str = ""
|
| 38 |
+
art_uri: str = ""
|
| 39 |
|
| 40 |
# Return fixed, parseable rules text for the card.
|
| 41 |
def rules_text(self) -> str:
|
clients.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import os
|
| 2 |
|
|
|
|
| 3 |
from boss import BossClient, NemotronBossClient
|
| 4 |
from generator import CardPackClient, LlamaCppCardClient, MiniCPMCardClient
|
| 5 |
from local_llm import (
|
|
@@ -20,6 +21,8 @@ DEFAULT_CARD_MODEL = "minicpm-v-4.6"
|
|
| 20 |
DEFAULT_BOSS_MODEL = "nvidia/Nemotron-Mini-4B-Instruct"
|
| 21 |
DEFAULT_MINICPM_MODEL = "openbmb/MiniCPM-V-4"
|
| 22 |
DEFAULT_MLX_BOSS_MODEL = "mlx-community/Nemotron-Mini-4B-Instruct-4bit-mlx"
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
# Build a card-generation client from environment variables.
|
|
@@ -31,7 +34,8 @@ def card_client_from_env() -> CardPackClient | None:
|
|
| 31 |
model=os.environ.get("TABRAS_CARD_MODEL", DEFAULT_CARD_MODEL),
|
| 32 |
timeout_seconds=int(os.environ.get("TABRAS_CARD_TIMEOUT", "60")),
|
| 33 |
temperature=float(os.environ.get("TABRAS_CARD_TEMPERATURE", "0.0")),
|
| 34 |
-
max_tokens=int(os.environ.get("TABRAS_CARD_MAX_TOKENS", "
|
|
|
|
| 35 |
)
|
| 36 |
)
|
| 37 |
if os.environ.get("TABRAS_CARD_BACKEND") == "transformers":
|
|
@@ -86,3 +90,26 @@ def boss_client_from_env() -> BossClient | None:
|
|
| 86 |
temperature=float(os.environ.get("TABRAS_BOSS_TEMPERATURE", "0.2")),
|
| 87 |
)
|
| 88 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
|
| 3 |
+
from art import ArtClient, DiffusersImageClient, LazyArtClient
|
| 4 |
from boss import BossClient, NemotronBossClient
|
| 5 |
from generator import CardPackClient, LlamaCppCardClient, MiniCPMCardClient
|
| 6 |
from local_llm import (
|
|
|
|
| 21 |
DEFAULT_BOSS_MODEL = "nvidia/Nemotron-Mini-4B-Instruct"
|
| 22 |
DEFAULT_MINICPM_MODEL = "openbmb/MiniCPM-V-4"
|
| 23 |
DEFAULT_MLX_BOSS_MODEL = "mlx-community/Nemotron-Mini-4B-Instruct-4bit-mlx"
|
| 24 |
+
DEFAULT_ART_MODEL = "stabilityai/sdxl-turbo"
|
| 25 |
+
_art_client_cache: ArtClient | None = None
|
| 26 |
|
| 27 |
|
| 28 |
# Build a card-generation client from environment variables.
|
|
|
|
| 34 |
model=os.environ.get("TABRAS_CARD_MODEL", DEFAULT_CARD_MODEL),
|
| 35 |
timeout_seconds=int(os.environ.get("TABRAS_CARD_TIMEOUT", "60")),
|
| 36 |
temperature=float(os.environ.get("TABRAS_CARD_TEMPERATURE", "0.0")),
|
| 37 |
+
max_tokens=int(os.environ.get("TABRAS_CARD_MAX_TOKENS", "320")),
|
| 38 |
+
enable_thinking=False,
|
| 39 |
)
|
| 40 |
)
|
| 41 |
if os.environ.get("TABRAS_CARD_BACKEND") == "transformers":
|
|
|
|
| 90 |
temperature=float(os.environ.get("TABRAS_BOSS_TEMPERATURE", "0.2")),
|
| 91 |
)
|
| 92 |
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# Build an art-generation client from environment variables.
|
| 96 |
+
def art_client_from_env() -> ArtClient | None:
|
| 97 |
+
global _art_client_cache
|
| 98 |
+
if os.environ.get("TABRAS_ART_BACKEND") != "diffusers":
|
| 99 |
+
return None
|
| 100 |
+
if _art_client_cache is None:
|
| 101 |
+
model_id = os.environ.get("TABRAS_ART_MODEL", DEFAULT_ART_MODEL)
|
| 102 |
+
steps = int(os.environ.get("TABRAS_ART_STEPS", "1"))
|
| 103 |
+
guidance_scale = float(os.environ.get("TABRAS_ART_GUIDANCE", "0.0"))
|
| 104 |
+
width = int(os.environ.get("TABRAS_ART_WIDTH", "512"))
|
| 105 |
+
height = int(os.environ.get("TABRAS_ART_HEIGHT", "320"))
|
| 106 |
+
_art_client_cache = LazyArtClient(
|
| 107 |
+
lambda: DiffusersImageClient.load(
|
| 108 |
+
model_id,
|
| 109 |
+
steps=steps,
|
| 110 |
+
guidance_scale=guidance_scale,
|
| 111 |
+
width=width,
|
| 112 |
+
height=height,
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
return _art_client_cache
|
forge.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
from concurrent.futures import Future, ThreadPoolExecutor
|
| 3 |
+
from typing import Any, Callable, Hashable
|
| 4 |
+
|
| 5 |
+
# Separate pools so each workload class runs without head-of-line blocking the others:
|
| 6 |
+
# fast = player draft packs the user waits on; slow = the long boss-deck draft;
|
| 7 |
+
# art = card illustration, kept off the slow lane so it never queues behind the boss deck.
|
| 8 |
+
_executors = {
|
| 9 |
+
"fast": ThreadPoolExecutor(max_workers=3),
|
| 10 |
+
"slow": ThreadPoolExecutor(max_workers=1),
|
| 11 |
+
"art": ThreadPoolExecutor(max_workers=2),
|
| 12 |
+
}
|
| 13 |
+
_lock = threading.Lock()
|
| 14 |
+
_jobs: dict[Hashable, Future] = {}
|
| 15 |
+
_missing = object()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Submit one background job per key on its lane; lanes never starve one another.
|
| 19 |
+
def submit(key: Hashable, fn: Callable[[], Any], lane: str = "fast") -> Future:
|
| 20 |
+
with _lock:
|
| 21 |
+
future = _jobs.get(key)
|
| 22 |
+
if future is None or (future.done() and future.exception() is not None):
|
| 23 |
+
executor = _executors.get(lane, _executors["fast"])
|
| 24 |
+
future = executor.submit(fn)
|
| 25 |
+
_jobs[key] = future
|
| 26 |
+
return future
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# Return a job result, computing inline when it was never prefetched.
|
| 30 |
+
def take(key: Hashable, fn: Callable[[], Any]) -> Any:
|
| 31 |
+
return submit(key, fn).result()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# Return a completed job result without blocking.
|
| 35 |
+
def take_ready(key: Hashable, default: Any = _missing) -> Any:
|
| 36 |
+
with _lock:
|
| 37 |
+
future = _jobs.get(key)
|
| 38 |
+
if future is None or not future.done():
|
| 39 |
+
if default is _missing:
|
| 40 |
+
return None
|
| 41 |
+
return default
|
| 42 |
+
try:
|
| 43 |
+
return future.result()
|
| 44 |
+
except Exception:
|
| 45 |
+
if default is _missing:
|
| 46 |
+
return None
|
| 47 |
+
return default
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# Drop all cached jobs so a new run forges fresh cards.
|
| 51 |
+
def reset() -> None:
|
| 52 |
+
with _lock:
|
| 53 |
+
_jobs.clear()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# Block until every submitted job settles; used by tests.
|
| 57 |
+
def drain() -> None:
|
| 58 |
+
with _lock:
|
| 59 |
+
futures = list(_jobs.values())
|
| 60 |
+
for future in futures:
|
| 61 |
+
future.exception()
|
generator.py
CHANGED
|
@@ -3,6 +3,7 @@ import json
|
|
| 3 |
import subprocess
|
| 4 |
import tempfile
|
| 5 |
from collections import Counter
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Any, Protocol, Sequence
|
| 8 |
|
|
@@ -79,12 +80,13 @@ class LlamaCppCardClient:
|
|
| 79 |
def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 80 |
return generate_llamacpp_card(self.chat, payload, ())
|
| 81 |
|
| 82 |
-
# Return one raw card pack
|
|
|
|
| 83 |
def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
cards.
|
| 87 |
-
return {"cards": cards}
|
| 88 |
|
| 89 |
|
| 90 |
TOOL_SCHEMA: dict[str, Any] = {
|
|
@@ -148,8 +150,11 @@ def generate_pack(
|
|
| 148 |
cost: int,
|
| 149 |
pack_size: int = 3,
|
| 150 |
draft_anchors: Sequence[Card] = (),
|
|
|
|
| 151 |
) -> tuple[Card, ...]:
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
pack = parse_pack_payload(raw, school, theme, cost)
|
| 154 |
if len(pack) < pack_size:
|
| 155 |
raise ValueError("generated pack had the wrong size")
|
|
@@ -232,6 +237,8 @@ def codex_pack_prompt(payload: dict[str, Any]) -> str:
|
|
| 232 |
"Do not include markdown, comments, or numeric magnitudes like damage/block/heal values.",
|
| 233 |
"The engine owns all final numbers. You only choose primitive ids and weights.",
|
| 234 |
"Make cards in the same response meaningfully different: vary primitive mixes, names, and tactical roles.",
|
|
|
|
|
|
|
| 235 |
f"School: {payload['school']}",
|
| 236 |
f"Theme: {payload['theme']}",
|
| 237 |
f"Class identity: {payload['school_identity']}",
|
|
@@ -256,6 +263,71 @@ def card_system_prompt() -> str:
|
|
| 256 |
return "You are Tabras card authoring logic. Output strict JSON only. No thinking. No markdown."
|
| 257 |
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
# Build a compact one-card prompt for llama.cpp MiniCPM.
|
| 260 |
def llamacpp_card_prompt(payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]]) -> str:
|
| 261 |
used_names = [card.get("name", "") for card in pack_cards]
|
|
@@ -267,7 +339,12 @@ def llamacpp_card_prompt(payload: dict[str, Any], pack_cards: Sequence[dict[str,
|
|
| 267 |
"Shape: {\"name\": string, \"flavor\": string, \"art_prompt\": string, \"effects\": [{\"primitive_id\": string, \"weight\": 1}]}",
|
| 268 |
"Use one or two effects. Do not write numeric damage, block, heal, or rules numbers.",
|
| 269 |
"Fit the school identity, but invent the card's fantasy and tactical purpose freely.",
|
|
|
|
| 270 |
"Prefer a new tactical purpose over repeating the current deck or pack.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
f"School: {payload['school']}",
|
| 272 |
f"Theme: {payload['theme']}",
|
| 273 |
f"Class identity: {payload['school_identity']}",
|
|
@@ -287,15 +364,27 @@ def llamacpp_card_prompt(payload: dict[str, Any], pack_cards: Sequence[dict[str,
|
|
| 287 |
)
|
| 288 |
|
| 289 |
|
| 290 |
-
# Generate one llama.cpp card with one corrective retry.
|
| 291 |
def generate_llamacpp_card(chat: LocalChatClient, payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
| 292 |
prompt = llamacpp_card_prompt(payload, pack_cards)
|
| 293 |
-
card = parse_llamacpp_card_text(chat.complete(card_system_prompt(), prompt), payload, pack_cards)
|
| 294 |
need = llama_candidate_need(payload, pack_summary(pack_cards))
|
| 295 |
-
if
|
|
|
|
|
|
|
| 296 |
return card
|
| 297 |
-
retry = parse_llamacpp_card_text(chat.complete(card_system_prompt(), llamacpp_retry_prompt(payload, need)), payload, pack_cards)
|
| 298 |
-
return retry if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
|
| 301 |
# Build a corrective prompt for a missed candidate need.
|
|
@@ -304,6 +393,10 @@ def llamacpp_retry_prompt(payload: dict[str, Any], need: str) -> str:
|
|
| 304 |
(
|
| 305 |
"Create exactly one corrected Tabras card as JSON.",
|
| 306 |
"Shape: {\"name\": string, \"flavor\": string, \"art_prompt\": string, \"effects\": [{\"primitive_id\": string, \"weight\": 1}]}",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
f"School: {payload['school']}",
|
| 308 |
f"Theme: {payload['theme']}",
|
| 309 |
f"Allowed primitive_id values: {json.dumps(payload['allowed_primitives'])}",
|
|
@@ -312,6 +405,9 @@ def llamacpp_retry_prompt(payload: dict[str, Any], need: str) -> str:
|
|
| 312 |
"If the requirement mentions primitive_id scaling, include an effect with primitive_id exactly \"scaling\".",
|
| 313 |
"If the requirement mentions primitive_id multi_hit, include an effect with primitive_id exactly \"multi_hit\".",
|
| 314 |
"If the requirement mentions primitive_id initiative, include an effect with primitive_id exactly \"initiative\".",
|
|
|
|
|
|
|
|
|
|
| 315 |
"JSON only.",
|
| 316 |
)
|
| 317 |
)
|
|
@@ -402,6 +498,13 @@ def earth_anchor_direction(counts: dict[str, int]) -> str:
|
|
| 402 |
# Return a per-candidate need for one-card llama.cpp generation.
|
| 403 |
def llama_candidate_need(payload: dict[str, Any], pack_counts: dict[str, int]) -> str:
|
| 404 |
draft_need_text = str(payload["draft_need"]).lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
if "add block" in draft_need_text and pack_counts.get("block", 0) == 0:
|
| 406 |
return "explore primitive_id block as the shield-charge engine while inventing the card fantasy freely."
|
| 407 |
if "scaling" in draft_need_text and pack_counts.get("scaling", 0) == 0:
|
|
@@ -427,11 +530,24 @@ def card_satisfies_candidate_need(card: dict[str, Any], need: str) -> bool:
|
|
| 427 |
return "multi_hit" in primitives
|
| 428 |
if "primitive_id initiative" in need:
|
| 429 |
return "initiative" in primitives
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
if "avoid another primary deal" in need:
|
| 431 |
return not primitives or primitives[0] != "deal"
|
| 432 |
return True
|
| 433 |
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
# Count primitive ids represented in a costed deck.
|
| 436 |
def deck_summary(deck: Sequence[Card]) -> dict[str, int]:
|
| 437 |
counts: Counter[str] = Counter()
|
|
@@ -485,7 +601,7 @@ def fire_draft_need(counts: dict[str, int]) -> str:
|
|
| 485 |
return "Delayed damage is covered; prefer immediate deal, scaling finishers, draw, or energy."
|
| 486 |
if counts.get("deal", 0) == 0:
|
| 487 |
return "Add immediate deal so Fire can race before delayed damage resolves."
|
| 488 |
-
return "Add a distinct pressure card that advances Fire's
|
| 489 |
|
| 490 |
|
| 491 |
# Return an Ice-specific drafting need.
|
|
@@ -548,19 +664,31 @@ def normalize_card_response(raw: dict[str, Any]) -> dict[str, Any]:
|
|
| 548 |
|
| 549 |
|
| 550 |
# Parse one llama.cpp card response, falling back on malformed JSON.
|
| 551 |
-
def parse_llamacpp_card_text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
try:
|
| 553 |
raw = normalize_card_response(json.loads(extract_json_object(text)))
|
| 554 |
except (TypeError, ValueError, json.JSONDecodeError):
|
| 555 |
raw = {}
|
| 556 |
-
return repair_llamacpp_card(raw, payload, pack_cards)
|
| 557 |
|
| 558 |
|
| 559 |
# Repair cheap llama.cpp JSON into the strict card schema.
|
| 560 |
-
def repair_llamacpp_card(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
allowed = tuple(payload["allowed_primitives"])
|
| 562 |
effects = repair_effects(raw.get("effects"), allowed)
|
| 563 |
-
|
|
|
|
|
|
|
| 564 |
return {
|
| 565 |
"name": name,
|
| 566 |
"flavor": str(raw.get("flavor", "")),
|
|
@@ -569,6 +697,50 @@ def repair_llamacpp_card(raw: dict[str, Any], payload: dict[str, Any], pack_card
|
|
| 569 |
}
|
| 570 |
|
| 571 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
# Repair a model-authored effect list to allowed primitive ids.
|
| 573 |
def repair_effects(raw: Any, allowed: Sequence[str]) -> list[dict[str, Any]]:
|
| 574 |
effects = [effect for effect in raw if isinstance(effect, dict)] if isinstance(raw, list) else []
|
|
@@ -585,10 +757,10 @@ def repair_effect(raw: dict[str, Any], allowed: Sequence[str]) -> dict[str, Any]
|
|
| 585 |
# Build a deterministic fallback card name.
|
| 586 |
def fallback_card_name(school: str, effects: Sequence[dict[str, Any]], pack_cards: Sequence[dict[str, Any]]) -> str:
|
| 587 |
used = {str(card.get("name", "")) for card in pack_cards}
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
return f"{
|
| 592 |
|
| 593 |
|
| 594 |
# Build fallback art text when the model omits it.
|
|
|
|
| 3 |
import subprocess
|
| 4 |
import tempfile
|
| 5 |
from collections import Counter
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any, Protocol, Sequence
|
| 9 |
|
|
|
|
| 80 |
def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 81 |
return generate_llamacpp_card(self.chat, payload, ())
|
| 82 |
|
| 83 |
+
# Return one raw card pack, generating the cards concurrently across
|
| 84 |
+
# llama-server slots, then de-duplicating names so no two picks collide.
|
| 85 |
def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 86 |
+
size = int(payload.get("pack_size", 3))
|
| 87 |
+
with ThreadPoolExecutor(max_workers=size) as pool:
|
| 88 |
+
cards = list(pool.map(lambda _: generate_llamacpp_card(self.chat, payload, ()), range(size)))
|
| 89 |
+
return {"cards": dedup_pack_names(cards)}
|
| 90 |
|
| 91 |
|
| 92 |
TOOL_SCHEMA: dict[str, Any] = {
|
|
|
|
| 150 |
cost: int,
|
| 151 |
pack_size: int = 3,
|
| 152 |
draft_anchors: Sequence[Card] = (),
|
| 153 |
+
quick: bool = False,
|
| 154 |
) -> tuple[Card, ...]:
|
| 155 |
+
payload = pack_payload(school, theme, current_deck, cost, pack_size, draft_anchors)
|
| 156 |
+
payload["quick"] = quick
|
| 157 |
+
raw = client.create_pack(payload)
|
| 158 |
pack = parse_pack_payload(raw, school, theme, cost)
|
| 159 |
if len(pack) < pack_size:
|
| 160 |
raise ValueError("generated pack had the wrong size")
|
|
|
|
| 237 |
"Do not include markdown, comments, or numeric magnitudes like damage/block/heal values.",
|
| 238 |
"The engine owns all final numbers. You only choose primitive ids and weights.",
|
| 239 |
"Make cards in the same response meaningfully different: vary primitive mixes, names, and tactical roles.",
|
| 240 |
+
"The flavor and art_prompt must describe the same concrete scene.",
|
| 241 |
+
"The art_prompt is for image generation: describe subject, setting, mood, and magic; never mention cards, frames, text, UI, or rules.",
|
| 242 |
f"School: {payload['school']}",
|
| 243 |
f"Theme: {payload['theme']}",
|
| 244 |
f"Class identity: {payload['school_identity']}",
|
|
|
|
| 263 |
return "You are Tabras card authoring logic. Output strict JSON only. No thinking. No markdown."
|
| 264 |
|
| 265 |
|
| 266 |
+
NAME_BAN = (
|
| 267 |
+
"deal",
|
| 268 |
+
"damage",
|
| 269 |
+
"bomb",
|
| 270 |
+
"burn",
|
| 271 |
+
"block",
|
| 272 |
+
"draw",
|
| 273 |
+
"scaling",
|
| 274 |
+
"multi",
|
| 275 |
+
"hit",
|
| 276 |
+
"initiative",
|
| 277 |
+
"vulnerable",
|
| 278 |
+
"weak",
|
| 279 |
+
"ward",
|
| 280 |
+
"energy",
|
| 281 |
+
"pressure",
|
| 282 |
+
"clock",
|
| 283 |
+
"plan",
|
| 284 |
+
"tempo",
|
| 285 |
+
"payoff",
|
| 286 |
+
"fast",
|
| 287 |
+
"immediate",
|
| 288 |
+
"card",
|
| 289 |
+
"school",
|
| 290 |
+
"anime",
|
| 291 |
+
"fantasy",
|
| 292 |
+
"spell",
|
| 293 |
+
)
|
| 294 |
+
NAME_STEM_BAN = ("burn", "fast", "flam", "pressur")
|
| 295 |
+
FALLBACK_NAMES: dict[str, tuple[str, ...]] = {
|
| 296 |
+
"fire": ("Cinder Warrant", "Ashen Oath", "Ember Brand", "Sable Pyre", "Charcoal Saint", "Red Sigil"),
|
| 297 |
+
"ice": ("Glass Edict", "Rime Mirror", "Pale Aurora", "Silver Stillness", "Needle Choir", "Blue Omen"),
|
| 298 |
+
"earth": ("Stone Covenant", "Cairn Writ", "Rootbound Seal", "Granite Vow", "Mossbound Relic", "Deep Warden"),
|
| 299 |
+
}
|
| 300 |
+
SCHOOL_FORBIDDEN_IMAGERY: dict[str, tuple[str, ...]] = {
|
| 301 |
+
"fire": ("glacier", "frost", "frozen", "ice", "snow", "winter", "blizzard", "earth"),
|
| 302 |
+
"ice": ("cinder", "ember", "flame", "inferno", "pyre", "ashen", "volcano", "fire", "earth"),
|
| 303 |
+
"earth": ("glacier", "frost", "inferno", "flame", "ember", "blizzard", "fire", "ice"),
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# Return whether a card name is empty or leans on mechanic words.
|
| 308 |
+
def generic_card_name(name: str) -> bool:
|
| 309 |
+
words = [word.strip(".,:;!?'\"").lower() for word in name.split()]
|
| 310 |
+
return not words or any(word in NAME_BAN or word.isdigit() or any(word.startswith(stem) for stem in NAME_STEM_BAN) for word in words)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# Return the shared naming and flavor rules for card prompts.
|
| 314 |
+
def naming_rules() -> tuple[str, ...]:
|
| 315 |
+
return (
|
| 316 |
+
"Name the card with one to three evocative English words of concrete imagery from the world's fantasy.",
|
| 317 |
+
f"Never use these words in the name: {', '.join(NAME_BAN)}.",
|
| 318 |
+
"Bad names repeat mechanics, like Fire Deal or Pressure Clock.",
|
| 319 |
+
"Write flavor as one short English in-world sentence with no mechanic or plan words.",
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# Return school-specific imagery constraints for prompts and validation.
|
| 324 |
+
def school_imagery_rule(school: str) -> str:
|
| 325 |
+
forbidden = ", ".join(SCHOOL_FORBIDDEN_IMAGERY.get(school, ()))
|
| 326 |
+
if not forbidden:
|
| 327 |
+
return "Keep name, flavor, and art imagery aligned with the school."
|
| 328 |
+
return f"Do not use off-school imagery for {school}; avoid these words in name, flavor, and art_prompt: {forbidden}."
|
| 329 |
+
|
| 330 |
+
|
| 331 |
# Build a compact one-card prompt for llama.cpp MiniCPM.
|
| 332 |
def llamacpp_card_prompt(payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]]) -> str:
|
| 333 |
used_names = [card.get("name", "") for card in pack_cards]
|
|
|
|
| 339 |
"Shape: {\"name\": string, \"flavor\": string, \"art_prompt\": string, \"effects\": [{\"primitive_id\": string, \"weight\": 1}]}",
|
| 340 |
"Use one or two effects. Do not write numeric damage, block, heal, or rules numbers.",
|
| 341 |
"Fit the school identity, but invent the card's fantasy and tactical purpose freely.",
|
| 342 |
+
"Do not describe Fire as a people or species; use fast damage plan or pressure plan.",
|
| 343 |
"Prefer a new tactical purpose over repeating the current deck or pack.",
|
| 344 |
+
"Make art_prompt match the flavor sentence as a concrete image scene.",
|
| 345 |
+
"The art_prompt must describe subject, setting, mood, and magic; never mention cards, frames, text, UI, or rules.",
|
| 346 |
+
*naming_rules(),
|
| 347 |
+
school_imagery_rule(str(payload["school"])),
|
| 348 |
f"School: {payload['school']}",
|
| 349 |
f"Theme: {payload['theme']}",
|
| 350 |
f"Class identity: {payload['school_identity']}",
|
|
|
|
| 364 |
)
|
| 365 |
|
| 366 |
|
| 367 |
+
# Generate one llama.cpp card with one corrective retry; quick mode takes the first card.
|
| 368 |
def generate_llamacpp_card(chat: LocalChatClient, payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
| 369 |
prompt = llamacpp_card_prompt(payload, pack_cards)
|
| 370 |
+
card = parse_llamacpp_card_text(chat.complete(card_system_prompt(), prompt), payload, pack_cards, discard_bad_text=False)
|
| 371 |
need = llama_candidate_need(payload, pack_summary(pack_cards))
|
| 372 |
+
if payload.get("quick"):
|
| 373 |
+
return repair_llamacpp_card(card, payload, pack_cards)
|
| 374 |
+
if card_acceptable(card, need, payload):
|
| 375 |
return card
|
| 376 |
+
retry = parse_llamacpp_card_text(chat.complete(card_system_prompt(), llamacpp_retry_prompt(payload, need)), payload, pack_cards, discard_bad_text=False)
|
| 377 |
+
return retry if card_acceptable(retry, need, payload) else repair_llamacpp_card(fallback_raw_for_need(need, payload), payload, pack_cards)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# Return whether a generated card meets its need with a non-generic name.
|
| 381 |
+
def card_acceptable(card: dict[str, Any], need: str, payload: dict[str, Any]) -> bool:
|
| 382 |
+
return (
|
| 383 |
+
card_satisfies_candidate_need(card, need)
|
| 384 |
+
and not generic_card_name(str(card.get("name", "")))
|
| 385 |
+
and not theme_name_leak(str(card.get("name", "")), str(payload["theme"]))
|
| 386 |
+
and not school_flavor_mismatch(str(payload["school"]), card)
|
| 387 |
+
)
|
| 388 |
|
| 389 |
|
| 390 |
# Build a corrective prompt for a missed candidate need.
|
|
|
|
| 393 |
(
|
| 394 |
"Create exactly one corrected Tabras card as JSON.",
|
| 395 |
"Shape: {\"name\": string, \"flavor\": string, \"art_prompt\": string, \"effects\": [{\"primitive_id\": string, \"weight\": 1}]}",
|
| 396 |
+
"Make flavor and art_prompt describe the same concrete scene.",
|
| 397 |
+
"The art_prompt must describe an image scene only; no cards, text, frames, UI, or rules.",
|
| 398 |
+
*naming_rules(),
|
| 399 |
+
school_imagery_rule(str(payload["school"])),
|
| 400 |
f"School: {payload['school']}",
|
| 401 |
f"Theme: {payload['theme']}",
|
| 402 |
f"Allowed primitive_id values: {json.dumps(payload['allowed_primitives'])}",
|
|
|
|
| 405 |
"If the requirement mentions primitive_id scaling, include an effect with primitive_id exactly \"scaling\".",
|
| 406 |
"If the requirement mentions primitive_id multi_hit, include an effect with primitive_id exactly \"multi_hit\".",
|
| 407 |
"If the requirement mentions primitive_id initiative, include an effect with primitive_id exactly \"initiative\".",
|
| 408 |
+
"If the requirement mentions primitive_id burn, include an effect with primitive_id exactly \"burn\".",
|
| 409 |
+
"If the requirement mentions primitive_id bomb, include an effect with primitive_id exactly \"bomb\".",
|
| 410 |
+
"If the requirement mentions primitive_id draw, include an effect with primitive_id exactly \"draw\".",
|
| 411 |
"JSON only.",
|
| 412 |
)
|
| 413 |
)
|
|
|
|
| 498 |
# Return a per-candidate need for one-card llama.cpp generation.
|
| 499 |
def llama_candidate_need(payload: dict[str, Any], pack_counts: dict[str, int]) -> str:
|
| 500 |
draft_need_text = str(payload["draft_need"]).lower()
|
| 501 |
+
school = str(payload["school"])
|
| 502 |
+
if school == "fire" and pack_counts.get("deal", 0) > 0 and pack_counts.get("burn", 0) == 0:
|
| 503 |
+
return "explore primitive_id burn so this pack is not another direct damage card."
|
| 504 |
+
if school == "fire" and pack_counts.get("deal", 0) > 0 and pack_counts.get("bomb", 0) == 0:
|
| 505 |
+
return "explore primitive_id bomb so this pack has delayed pressure."
|
| 506 |
+
if school == "fire" and pack_counts.get("deal", 0) > 0 and pack_counts.get("draw", 0) == 0:
|
| 507 |
+
return "explore primitive_id draw as support instead of another direct damage card."
|
| 508 |
if "add block" in draft_need_text and pack_counts.get("block", 0) == 0:
|
| 509 |
return "explore primitive_id block as the shield-charge engine while inventing the card fantasy freely."
|
| 510 |
if "scaling" in draft_need_text and pack_counts.get("scaling", 0) == 0:
|
|
|
|
| 530 |
return "multi_hit" in primitives
|
| 531 |
if "primitive_id initiative" in need:
|
| 532 |
return "initiative" in primitives
|
| 533 |
+
if "primitive_id burn" in need:
|
| 534 |
+
return "burn" in primitives
|
| 535 |
+
if "primitive_id bomb" in need:
|
| 536 |
+
return "bomb" in primitives
|
| 537 |
+
if "primitive_id draw" in need:
|
| 538 |
+
return "draw" in primitives
|
| 539 |
if "avoid another primary deal" in need:
|
| 540 |
return not primitives or primitives[0] != "deal"
|
| 541 |
return True
|
| 542 |
|
| 543 |
|
| 544 |
+
# Build a minimal raw response that satisfies a missed primitive need.
|
| 545 |
+
def fallback_raw_for_need(need: str, payload: dict[str, Any]) -> dict[str, Any]:
|
| 546 |
+
allowed = tuple(payload["allowed_primitives"])
|
| 547 |
+
primitive = next((candidate for candidate in allowed if f"primitive_id {candidate}" in need), allowed[0])
|
| 548 |
+
return {"effects": [{"primitive_id": primitive, "weight": 1}]}
|
| 549 |
+
|
| 550 |
+
|
| 551 |
# Count primitive ids represented in a costed deck.
|
| 552 |
def deck_summary(deck: Sequence[Card]) -> dict[str, int]:
|
| 553 |
counts: Counter[str] = Counter()
|
|
|
|
| 601 |
return "Delayed damage is covered; prefer immediate deal, scaling finishers, draw, or energy."
|
| 602 |
if counts.get("deal", 0) == 0:
|
| 603 |
return "Add immediate deal so Fire can race before delayed damage resolves."
|
| 604 |
+
return "Add a distinct pressure card that advances Fire's fast damage plan."
|
| 605 |
|
| 606 |
|
| 607 |
# Return an Ice-specific drafting need.
|
|
|
|
| 664 |
|
| 665 |
|
| 666 |
# Parse one llama.cpp card response, falling back on malformed JSON.
|
| 667 |
+
def parse_llamacpp_card_text(
|
| 668 |
+
text: str,
|
| 669 |
+
payload: dict[str, Any],
|
| 670 |
+
pack_cards: Sequence[dict[str, Any]],
|
| 671 |
+
discard_bad_text: bool = True,
|
| 672 |
+
) -> dict[str, Any]:
|
| 673 |
try:
|
| 674 |
raw = normalize_card_response(json.loads(extract_json_object(text)))
|
| 675 |
except (TypeError, ValueError, json.JSONDecodeError):
|
| 676 |
raw = {}
|
| 677 |
+
return repair_llamacpp_card(raw, payload, pack_cards, discard_bad_text)
|
| 678 |
|
| 679 |
|
| 680 |
# Repair cheap llama.cpp JSON into the strict card schema.
|
| 681 |
+
def repair_llamacpp_card(
|
| 682 |
+
raw: dict[str, Any],
|
| 683 |
+
payload: dict[str, Any],
|
| 684 |
+
pack_cards: Sequence[dict[str, Any]],
|
| 685 |
+
discard_bad_text: bool = True,
|
| 686 |
+
) -> dict[str, Any]:
|
| 687 |
allowed = tuple(payload["allowed_primitives"])
|
| 688 |
effects = repair_effects(raw.get("effects"), allowed)
|
| 689 |
+
if discard_bad_text and unusable_raw_card(raw, payload):
|
| 690 |
+
raw = {}
|
| 691 |
+
name = unique_card_name(str(raw.get("name") or fallback_card_name(payload["school"], effects, pack_cards)), pack_cards)
|
| 692 |
return {
|
| 693 |
"name": name,
|
| 694 |
"flavor": str(raw.get("flavor", "")),
|
|
|
|
| 697 |
}
|
| 698 |
|
| 699 |
|
| 700 |
+
# Return whether generated text leans into another school's imagery.
|
| 701 |
+
def school_flavor_mismatch(school: str, raw: dict[str, Any]) -> bool:
|
| 702 |
+
text = " ".join(str(raw.get(key, "")) for key in ("name", "flavor", "art_prompt")).lower()
|
| 703 |
+
words = {word.strip(".,:;!?'\"").lower() for word in text.split()}
|
| 704 |
+
return any(word in words for word in SCHOOL_FORBIDDEN_IMAGERY.get(school, ()))
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
# Return whether raw model text must be discarded before repair.
|
| 708 |
+
def unusable_raw_card(raw: dict[str, Any], payload: dict[str, Any]) -> bool:
|
| 709 |
+
name = str(raw.get("name", ""))
|
| 710 |
+
return (
|
| 711 |
+
generic_card_name(name)
|
| 712 |
+
or theme_name_leak(name, str(payload["theme"]))
|
| 713 |
+
or school_flavor_mismatch(str(payload["school"]), raw)
|
| 714 |
+
)
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
# Return whether the name leaks meta theme labels instead of in-world imagery.
|
| 718 |
+
def theme_name_leak(name: str, theme: str) -> bool:
|
| 719 |
+
name_words = {word.strip(".,:;!?'\"").lower() for word in name.split()}
|
| 720 |
+
theme_words = {word.strip(".,:;!?'\"").lower() for word in theme.split()}
|
| 721 |
+
return any(word in name_words for word in theme_words if word in {"anime", "fantasy", "wuxia", "school", "world"})
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
# Rename duplicate names so every pick in a parallel-built pack stays distinct.
|
| 725 |
+
def dedup_pack_names(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 726 |
+
seen: list[dict[str, Any]] = []
|
| 727 |
+
for card in cards:
|
| 728 |
+
card["name"] = unique_card_name(str(card.get("name", "")), seen)
|
| 729 |
+
seen.append(card)
|
| 730 |
+
return cards
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
# Return a card name that does not duplicate earlier cards in the pack.
|
| 734 |
+
def unique_card_name(name: str, pack_cards: Sequence[dict[str, Any]]) -> str:
|
| 735 |
+
used = {str(card.get("name", "")) for card in pack_cards}
|
| 736 |
+
if name not in used:
|
| 737 |
+
return name
|
| 738 |
+
index = 2
|
| 739 |
+
while f"{name} {index}" in used:
|
| 740 |
+
index += 1
|
| 741 |
+
return f"{name} {index}"
|
| 742 |
+
|
| 743 |
+
|
| 744 |
# Repair a model-authored effect list to allowed primitive ids.
|
| 745 |
def repair_effects(raw: Any, allowed: Sequence[str]) -> list[dict[str, Any]]:
|
| 746 |
effects = [effect for effect in raw if isinstance(effect, dict)] if isinstance(raw, list) else []
|
|
|
|
| 757 |
# Build a deterministic fallback card name.
|
| 758 |
def fallback_card_name(school: str, effects: Sequence[dict[str, Any]], pack_cards: Sequence[dict[str, Any]]) -> str:
|
| 759 |
used = {str(card.get("name", "")) for card in pack_cards}
|
| 760 |
+
for name in FALLBACK_NAMES.get(school, FALLBACK_NAMES["fire"]):
|
| 761 |
+
if name not in used:
|
| 762 |
+
return name
|
| 763 |
+
return f"{FALLBACK_NAMES.get(school, FALLBACK_NAMES['fire'])[0]} {len(used) + 1}"
|
| 764 |
|
| 765 |
|
| 766 |
# Build fallback art text when the model omits it.
|
local_llm.py
CHANGED
|
@@ -13,10 +13,11 @@ class LocalChatClient:
|
|
| 13 |
timeout_seconds: int = 60
|
| 14 |
temperature: float = 0.2
|
| 15 |
max_tokens: int = 256
|
|
|
|
| 16 |
|
| 17 |
# Complete one chat prompt through an OpenAI-compatible local endpoint.
|
| 18 |
def complete(self, system: str, user: str) -> str:
|
| 19 |
-
payload = chat_payload(self.model, system, user, self.temperature, self.max_tokens)
|
| 20 |
req = request.Request(
|
| 21 |
self.endpoint,
|
| 22 |
data=json.dumps(payload).encode("utf-8"),
|
|
@@ -175,7 +176,14 @@ class MiniCPMTransformersChatClient:
|
|
| 175 |
|
| 176 |
|
| 177 |
# Build an OpenAI-compatible chat completion payload.
|
| 178 |
-
def chat_payload(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
payload = {
|
| 180 |
"model": model,
|
| 181 |
"messages": [
|
|
@@ -186,6 +194,8 @@ def chat_payload(model: str, system: str, user: str, temperature: float, max_tok
|
|
| 186 |
}
|
| 187 |
if max_tokens is not None:
|
| 188 |
payload["max_tokens"] = max_tokens
|
|
|
|
|
|
|
| 189 |
return payload
|
| 190 |
|
| 191 |
|
|
|
|
| 13 |
timeout_seconds: int = 60
|
| 14 |
temperature: float = 0.2
|
| 15 |
max_tokens: int = 256
|
| 16 |
+
enable_thinking: bool | None = None
|
| 17 |
|
| 18 |
# Complete one chat prompt through an OpenAI-compatible local endpoint.
|
| 19 |
def complete(self, system: str, user: str) -> str:
|
| 20 |
+
payload = chat_payload(self.model, system, user, self.temperature, self.max_tokens, self.enable_thinking)
|
| 21 |
req = request.Request(
|
| 22 |
self.endpoint,
|
| 23 |
data=json.dumps(payload).encode("utf-8"),
|
|
|
|
| 176 |
|
| 177 |
|
| 178 |
# Build an OpenAI-compatible chat completion payload.
|
| 179 |
+
def chat_payload(
|
| 180 |
+
model: str,
|
| 181 |
+
system: str,
|
| 182 |
+
user: str,
|
| 183 |
+
temperature: float,
|
| 184 |
+
max_tokens: int | None = None,
|
| 185 |
+
enable_thinking: bool | None = None,
|
| 186 |
+
) -> dict[str, Any]:
|
| 187 |
payload = {
|
| 188 |
"model": model,
|
| 189 |
"messages": [
|
|
|
|
| 194 |
}
|
| 195 |
if max_tokens is not None:
|
| 196 |
payload["max_tokens"] = max_tokens
|
| 197 |
+
if enable_thinking is not None:
|
| 198 |
+
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
|
| 199 |
return payload
|
| 200 |
|
| 201 |
|
pyproject.toml
CHANGED
|
@@ -3,7 +3,7 @@ testpaths = ["tests"]
|
|
| 3 |
|
| 4 |
[tool.coverage.run]
|
| 5 |
branch = true
|
| 6 |
-
source = ["ai_runtime", "boss", "budget", "clients", "draft", "game", "generator", "local_llm", "play", "primitives"]
|
| 7 |
|
| 8 |
[tool.coverage.report]
|
| 9 |
show_missing = true
|
|
|
|
| 3 |
|
| 4 |
[tool.coverage.run]
|
| 5 |
branch = true
|
| 6 |
+
source = ["ai_runtime", "art", "boss", "budget", "clients", "draft", "forge", "game", "generator", "local_llm", "play", "primitives"]
|
| 7 |
|
| 8 |
[tool.coverage.report]
|
| 9 |
show_missing = true
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio
|
| 2 |
+
diffusers
|
| 3 |
+
torch
|
| 4 |
+
transformers
|
| 5 |
+
accelerate
|
| 6 |
+
safetensors
|
| 7 |
+
pillow
|
tests/test_ai_runtime.py
CHANGED
|
@@ -15,6 +15,7 @@ def test_minicpm_server_command() -> None:
|
|
| 15 |
assert "--port" in command
|
| 16 |
assert "9000" in command
|
| 17 |
assert "--n-gpu-layers" in command
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
# Verify local AI env forces MiniCPM card generation and Nemotron boss play.
|
|
@@ -23,9 +24,15 @@ def test_local_ai_env(monkeypatch) -> None:
|
|
| 23 |
env = local_ai_env(9001)
|
| 24 |
assert env["TABRAS_CARD_BACKEND"] == "llamacpp"
|
| 25 |
assert env["TABRAS_CARD_ENDPOINT"] == "http://127.0.0.1:9001/v1/chat/completions"
|
|
|
|
|
|
|
| 26 |
assert env["TABRAS_AI_BOSS"] == "1"
|
| 27 |
assert env["TABRAS_BOSS_BACKEND"] == "mlx"
|
| 28 |
assert "Nemotron" in env["TABRAS_BOSS_MODEL"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
# Verify starting MiniCPM fails early when the GGUF is absent.
|
|
|
|
| 15 |
assert "--port" in command
|
| 16 |
assert "9000" in command
|
| 17 |
assert "--n-gpu-layers" in command
|
| 18 |
+
assert "--parallel" in command
|
| 19 |
|
| 20 |
|
| 21 |
# Verify local AI env forces MiniCPM card generation and Nemotron boss play.
|
|
|
|
| 24 |
env = local_ai_env(9001)
|
| 25 |
assert env["TABRAS_CARD_BACKEND"] == "llamacpp"
|
| 26 |
assert env["TABRAS_CARD_ENDPOINT"] == "http://127.0.0.1:9001/v1/chat/completions"
|
| 27 |
+
assert env["TABRAS_CARD_MAX_TOKENS"] == "192"
|
| 28 |
+
assert env["TABRAS_CARD_TEMPERATURE"] == "0.7"
|
| 29 |
assert env["TABRAS_AI_BOSS"] == "1"
|
| 30 |
assert env["TABRAS_BOSS_BACKEND"] == "mlx"
|
| 31 |
assert "Nemotron" in env["TABRAS_BOSS_MODEL"]
|
| 32 |
+
assert env["TABRAS_ART_BACKEND"] == "diffusers"
|
| 33 |
+
assert env["TABRAS_ART_MODEL"] == "stabilityai/sdxl-turbo"
|
| 34 |
+
assert env["TABRAS_ART_WIDTH"] == "512"
|
| 35 |
+
assert env["TABRAS_ART_HEIGHT"] == "320"
|
| 36 |
|
| 37 |
|
| 38 |
# Verify starting MiniCPM fails early when the GGUF is absent.
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import forge
|
| 2 |
+
from app import (
|
| 3 |
+
CSS,
|
| 4 |
+
background_selector_html,
|
| 5 |
+
refresh_screen,
|
| 6 |
+
school_asset_name,
|
| 7 |
+
school_selector_html,
|
| 8 |
+
show_background,
|
| 9 |
+
show_draft_from_rules,
|
| 10 |
+
show_name,
|
| 11 |
+
show_school,
|
| 12 |
+
start_rules,
|
| 13 |
+
)
|
| 14 |
+
from ui import new_run_shell
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AppPackClient:
|
| 18 |
+
# Return one deterministic draft pack for app-flow tests.
|
| 19 |
+
def __init__(self) -> None:
|
| 20 |
+
self.calls = 0
|
| 21 |
+
|
| 22 |
+
# Return a three-card model payload.
|
| 23 |
+
def create_pack(self, payload):
|
| 24 |
+
self.calls += 1
|
| 25 |
+
card = lambda index: {
|
| 26 |
+
"name": f"Flow Card {self.calls}-{index}",
|
| 27 |
+
"flavor": "The table waits.",
|
| 28 |
+
"art_prompt": "glowing spell on a wooden desk",
|
| 29 |
+
"effects": [{"primitive_id": "deal", "weight": 1}],
|
| 30 |
+
}
|
| 31 |
+
return {"cards": [card(index) for index in range(payload["pack_size"])]}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AppArtClient:
|
| 35 |
+
# Count image requests made after card text is forged.
|
| 36 |
+
def __init__(self) -> None:
|
| 37 |
+
self.calls = 0
|
| 38 |
+
|
| 39 |
+
# Return a deterministic image URI.
|
| 40 |
+
def create_art(self, prompt: str) -> str:
|
| 41 |
+
self.calls += 1
|
| 42 |
+
return f"uri:{prompt}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# Verify setup advances through one prompt per screen.
|
| 46 |
+
def test_setup_screen_steps() -> None:
|
| 47 |
+
assert show_name()[1] == "name"
|
| 48 |
+
assert show_background()[1] == "background"
|
| 49 |
+
assert show_school()[1] == "school"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Verify the title CTA stays compact instead of full-width.
|
| 53 |
+
def test_play_now_button_is_compact() -> None:
|
| 54 |
+
assert "#play-now-btn" in CSS
|
| 55 |
+
assert "max-width: 220px" in CSS
|
| 56 |
+
assert "#start-draft-btn" in CSS
|
| 57 |
+
assert "max-width: 200px" in CSS
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Verify draft cards are compact enough for the draft table.
|
| 61 |
+
def test_draft_cards_are_compact() -> None:
|
| 62 |
+
assert "minmax(0, 300px)" in CSS
|
| 63 |
+
assert "min-height: 405px" in CSS
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# Verify setup selectors are image-backed clickable panels with hover copy.
|
| 67 |
+
def test_setup_selectors_use_image_panels() -> None:
|
| 68 |
+
background = background_selector_html()
|
| 69 |
+
school = school_selector_html()
|
| 70 |
+
assert "selector-panel" in background
|
| 71 |
+
assert "selector-image" in background
|
| 72 |
+
assert "selector-copy" in background
|
| 73 |
+
assert "background-btn-dark-fantasy" in background
|
| 74 |
+
assert "You are a mage" in background
|
| 75 |
+
assert "school-btn-fire" in school
|
| 76 |
+
assert "school-btn-ice" in school
|
| 77 |
+
assert "school-btn-earth" in school
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# Verify selector art resolves to the bundled user-provided filenames.
|
| 81 |
+
def test_setup_selectors_use_bundled_assets() -> None:
|
| 82 |
+
background = background_selector_html()
|
| 83 |
+
|
| 84 |
+
assert "data:image/png;base64" in background
|
| 85 |
+
assert school_asset_name("dark-fantasy", "fire") == "darkFantasyFire.png"
|
| 86 |
+
assert school_asset_name("dark-fantasy", "ice") == "darkFantasyIce.png"
|
| 87 |
+
assert school_asset_name("dark-fantasy", "earth") == "darkFantasyEarth.png"
|
| 88 |
+
assert school_asset_name("cyberpunk", "fire") == "cyberpunkFire.png"
|
| 89 |
+
assert school_asset_name("cyberpunk", "ice") == "cyberpunkice.png"
|
| 90 |
+
assert school_asset_name("cyberpunk", "earth") == "cyberpunkEarth.png"
|
| 91 |
+
assert school_asset_name("anime", "fire") == "animeFire.png"
|
| 92 |
+
assert school_asset_name("anime", "ice") == "animeIce.png"
|
| 93 |
+
assert school_asset_name("anime", "earth") == "animeEarth.png"
|
| 94 |
+
assert "data:image/png;base64" in school_selector_html("Cyberpunk")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# Verify the rules screen starts deck generation in the background.
|
| 98 |
+
def test_start_rules_queues_deck_generation(monkeypatch) -> None:
|
| 99 |
+
forge.reset()
|
| 100 |
+
client = AppPackClient()
|
| 101 |
+
monkeypatch.setattr("app.card_client_from_env", lambda: client)
|
| 102 |
+
monkeypatch.setattr("app.art_client_from_env", lambda: None)
|
| 103 |
+
|
| 104 |
+
output = start_rules("Ada", "ice", "Anime")
|
| 105 |
+
state = output[0]
|
| 106 |
+
|
| 107 |
+
assert output[1] == "rules"
|
| 108 |
+
assert state.player_name == "Ada"
|
| 109 |
+
assert state.school == "ice"
|
| 110 |
+
assert "Rules" in output[9]
|
| 111 |
+
assert "Preparing your deck" not in output[9]
|
| 112 |
+
assert "First draft pack ready" not in output[9]
|
| 113 |
+
|
| 114 |
+
forge.drain()
|
| 115 |
+
refresh_screen(state, "rules")
|
| 116 |
+
assert client.calls > 0
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# Verify the rules screen starts image work once card text is ready.
|
| 120 |
+
def test_rules_screen_starts_art_generation(monkeypatch) -> None:
|
| 121 |
+
forge.reset()
|
| 122 |
+
client = AppPackClient()
|
| 123 |
+
art_client = AppArtClient()
|
| 124 |
+
monkeypatch.setattr("app.card_client_from_env", lambda: client)
|
| 125 |
+
monkeypatch.setattr("app.art_client_from_env", lambda: art_client)
|
| 126 |
+
|
| 127 |
+
state = start_rules("Ada", "fire", "Cyberpunk")[0]
|
| 128 |
+
forge.drain()
|
| 129 |
+
state = refresh_screen(state, "rules")[0]
|
| 130 |
+
forge.drain()
|
| 131 |
+
refresh_screen(state, "rules")
|
| 132 |
+
|
| 133 |
+
assert art_client.calls >= 3
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# Verify skipping rules shows a deck-loading draft state if the pack is still pending.
|
| 137 |
+
def test_skip_rules_shows_loading_deck(monkeypatch) -> None:
|
| 138 |
+
forge.reset()
|
| 139 |
+
monkeypatch.setattr("app.card_client_from_env", lambda: None)
|
| 140 |
+
monkeypatch.setattr("app.art_client_from_env", lambda: None)
|
| 141 |
+
|
| 142 |
+
state = new_run_shell("Ada", "Anime", "fire", seed=1)
|
| 143 |
+
output = show_draft_from_rules(state)
|
| 144 |
+
|
| 145 |
+
assert output[1] == "draft"
|
| 146 |
+
assert output[0].loading == "Loading your deck"
|
| 147 |
+
assert "Loading your deck" in output[10]
|
tests/test_art.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from io import BytesIO
|
| 2 |
+
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
from art import (
|
| 6 |
+
DiffusersImageClient,
|
| 7 |
+
best_torch_dtype,
|
| 8 |
+
card_art_prompt,
|
| 9 |
+
illustrate_card,
|
| 10 |
+
illustrate_cards,
|
| 11 |
+
image_data_uri,
|
| 12 |
+
move_pipe_to_device,
|
| 13 |
+
theme_style,
|
| 14 |
+
)
|
| 15 |
+
from budget import Card
|
| 16 |
+
from primitives import Effect
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FakeArtClient:
|
| 20 |
+
# Return a deterministic image URI for prompts.
|
| 21 |
+
def create_art(self, prompt: str) -> str:
|
| 22 |
+
return f"uri:{prompt}"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class BrokenArtClient:
|
| 26 |
+
# Raise like a failing image backend.
|
| 27 |
+
def create_art(self, prompt: str) -> str:
|
| 28 |
+
raise RuntimeError("no art")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FakePipe:
|
| 32 |
+
# Capture pipeline calls and return a PIL image.
|
| 33 |
+
def __init__(self) -> None:
|
| 34 |
+
self.calls = []
|
| 35 |
+
self.device = ""
|
| 36 |
+
|
| 37 |
+
def __call__(self, **kwargs):
|
| 38 |
+
self.calls.append(kwargs)
|
| 39 |
+
return type("Result", (), {"images": [Image.new("RGB", (2, 2), "red")]})()
|
| 40 |
+
|
| 41 |
+
def to(self, device: str):
|
| 42 |
+
self.device = device
|
| 43 |
+
return self
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class FakeTorch:
|
| 47 |
+
float16 = "f16"
|
| 48 |
+
float32 = "f32"
|
| 49 |
+
|
| 50 |
+
class cuda:
|
| 51 |
+
# Report CUDA as unavailable.
|
| 52 |
+
@staticmethod
|
| 53 |
+
def is_available() -> bool:
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
class backends:
|
| 57 |
+
class mps:
|
| 58 |
+
# Report MPS as available.
|
| 59 |
+
@staticmethod
|
| 60 |
+
def is_available() -> bool:
|
| 61 |
+
return True
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Verify images are encoded as browser-renderable PNG data URIs.
|
| 65 |
+
def test_image_data_uri() -> None:
|
| 66 |
+
uri = image_data_uri(Image.new("RGB", (1, 1), "blue"))
|
| 67 |
+
assert uri.startswith("data:image/png;base64,")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Verify diffusers image client calls the pipeline and returns encoded art.
|
| 71 |
+
def test_diffusers_image_client_create_art() -> None:
|
| 72 |
+
pipe = FakePipe()
|
| 73 |
+
client = DiffusersImageClient(pipe, steps=1, width=64, height=64)
|
| 74 |
+
uri = client.create_art("dark spell")
|
| 75 |
+
assert uri.startswith("data:image/png;base64,")
|
| 76 |
+
assert pipe.calls[0]["prompt"] == "dark spell"
|
| 77 |
+
assert pipe.calls[0]["num_inference_steps"] == 1
|
| 78 |
+
assert pipe.calls[0]["width"] == 64
|
| 79 |
+
assert "text" in pipe.calls[0]["negative_prompt"]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# Verify dtype and device helpers prefer MPS when CUDA is unavailable.
|
| 83 |
+
def test_torch_helpers() -> None:
|
| 84 |
+
pipe = FakePipe()
|
| 85 |
+
assert best_torch_dtype(FakeTorch) == "f16"
|
| 86 |
+
assert move_pipe_to_device(pipe, FakeTorch) is pipe
|
| 87 |
+
assert pipe.device == "mps"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Verify card prompts use model-authored art prompts first.
|
| 91 |
+
def test_card_art_prompt() -> None:
|
| 92 |
+
card = Card("Spark", 1, "fire", "wuxia", (Effect("deal", amount=2),), art_prompt="ember saint")
|
| 93 |
+
assert "ember saint" in card_art_prompt(card)
|
| 94 |
+
assert "no text" in card_art_prompt(card)
|
| 95 |
+
fallback = Card("Spark", 1, "fire", "wuxia", (Effect("deal", amount=2),))
|
| 96 |
+
prompt = card_art_prompt(fallback)
|
| 97 |
+
assert "Spark" in prompt
|
| 98 |
+
assert "Deal 2 damage." in prompt
|
| 99 |
+
assert "wide wuxia fantasy spell illustration" in prompt
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# Verify user worlds become concrete visual style clauses.
|
| 103 |
+
def test_theme_style() -> None:
|
| 104 |
+
assert "anime fantasy key visual" in theme_style("Anime")
|
| 105 |
+
assert "dark fantasy spell illustration" in theme_style("dark fantasy")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# Verify illustration attaches generated art without changing card rules.
|
| 109 |
+
def test_illustrate_card() -> None:
|
| 110 |
+
card = Card("Spark", 1, "fire", "wuxia", (Effect("deal", amount=2),), art_prompt="ember")
|
| 111 |
+
illustrated = illustrate_card(FakeArtClient(), card)
|
| 112 |
+
assert illustrated.art_uri.startswith("uri:wide wuxia fantasy spell illustration")
|
| 113 |
+
assert illustrated.rules_text() == card.rules_text()
|
| 114 |
+
assert illustrate_card(None, card) is card
|
| 115 |
+
assert illustrate_card(BrokenArtClient(), card) is card
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# Verify illustration maps across packs.
|
| 119 |
+
def test_illustrate_cards() -> None:
|
| 120 |
+
cards = (Card("Spark", 1, "fire", "wuxia", (Effect("deal", amount=2),), art_prompt="ember"),)
|
| 121 |
+
assert illustrate_cards(FakeArtClient(), cards)[0].art_uri.startswith("uri:wide wuxia")
|
tests/test_clients.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
from clients import boss_client_from_env, card_client_from_env
|
|
|
|
| 2 |
from boss import NemotronBossClient
|
| 3 |
from generator import LlamaCppCardClient, MiniCPMCardClient
|
| 4 |
from local_llm import LocalChatClient, LocalCompletionClient, nemotron_prompt
|
|
@@ -10,6 +11,12 @@ class FakeChat:
|
|
| 10 |
return "{}"
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
# Verify card client is disabled without an endpoint.
|
| 14 |
def test_card_client_from_env_disabled(monkeypatch) -> None:
|
| 15 |
monkeypatch.delenv("TABRAS_CARD_ENDPOINT", raising=False)
|
|
@@ -46,7 +53,8 @@ def test_card_client_from_env_llamacpp(monkeypatch) -> None:
|
|
| 46 |
assert isinstance(client.chat, LocalChatClient)
|
| 47 |
assert client.chat.endpoint == "http://cards"
|
| 48 |
assert client.chat.model == "mini-q4"
|
| 49 |
-
assert client.chat.max_tokens ==
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
# Verify boss client is disabled unless enabled.
|
|
@@ -98,3 +106,58 @@ def test_boss_client_from_env_mlx(monkeypatch) -> None:
|
|
| 98 |
client = boss_client_from_env()
|
| 99 |
assert isinstance(client, NemotronBossClient)
|
| 100 |
assert isinstance(client.chat, FakeChat)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from clients import art_client_from_env, boss_client_from_env, card_client_from_env
|
| 2 |
+
from art import DiffusersImageClient, LazyArtClient
|
| 3 |
from boss import NemotronBossClient
|
| 4 |
from generator import LlamaCppCardClient, MiniCPMCardClient
|
| 5 |
from local_llm import LocalChatClient, LocalCompletionClient, nemotron_prompt
|
|
|
|
| 11 |
return "{}"
|
| 12 |
|
| 13 |
|
| 14 |
+
class FakeArtBackend:
|
| 15 |
+
# Return a deterministic image URI.
|
| 16 |
+
def create_art(self, prompt: str) -> str:
|
| 17 |
+
return f"uri:{prompt}"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
# Verify card client is disabled without an endpoint.
|
| 21 |
def test_card_client_from_env_disabled(monkeypatch) -> None:
|
| 22 |
monkeypatch.delenv("TABRAS_CARD_ENDPOINT", raising=False)
|
|
|
|
| 53 |
assert isinstance(client.chat, LocalChatClient)
|
| 54 |
assert client.chat.endpoint == "http://cards"
|
| 55 |
assert client.chat.model == "mini-q4"
|
| 56 |
+
assert client.chat.max_tokens == 320
|
| 57 |
+
assert client.chat.enable_thinking is False
|
| 58 |
|
| 59 |
|
| 60 |
# Verify boss client is disabled unless enabled.
|
|
|
|
| 106 |
client = boss_client_from_env()
|
| 107 |
assert isinstance(client, NemotronBossClient)
|
| 108 |
assert isinstance(client.chat, FakeChat)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Verify art client is disabled unless explicitly enabled.
|
| 112 |
+
def test_art_client_from_env_disabled(monkeypatch) -> None:
|
| 113 |
+
monkeypatch.delenv("TABRAS_ART_BACKEND", raising=False)
|
| 114 |
+
monkeypatch.setattr("clients._art_client_cache", None)
|
| 115 |
+
assert art_client_from_env() is None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# Verify art client loads and caches the SD-Turbo diffusers backend.
|
| 119 |
+
def test_art_client_from_env_diffusers(monkeypatch) -> None:
|
| 120 |
+
monkeypatch.setenv("TABRAS_ART_BACKEND", "diffusers")
|
| 121 |
+
monkeypatch.setenv("TABRAS_ART_MODEL", "sdxl-local")
|
| 122 |
+
monkeypatch.setenv("TABRAS_ART_STEPS", "1")
|
| 123 |
+
monkeypatch.setenv("TABRAS_ART_WIDTH", "128")
|
| 124 |
+
monkeypatch.setenv("TABRAS_ART_HEIGHT", "96")
|
| 125 |
+
monkeypatch.setattr("clients._art_client_cache", None)
|
| 126 |
+
|
| 127 |
+
calls = []
|
| 128 |
+
|
| 129 |
+
def fake_load(model_id: str, **kwargs):
|
| 130 |
+
calls.append(model_id)
|
| 131 |
+
assert model_id == "sdxl-local"
|
| 132 |
+
assert kwargs["steps"] == 1
|
| 133 |
+
assert kwargs["width"] == 128
|
| 134 |
+
assert kwargs["height"] == 96
|
| 135 |
+
return FakeArtBackend()
|
| 136 |
+
|
| 137 |
+
monkeypatch.setattr(DiffusersImageClient, "load", staticmethod(fake_load))
|
| 138 |
+
client = art_client_from_env()
|
| 139 |
+
assert isinstance(client, LazyArtClient)
|
| 140 |
+
assert art_client_from_env() is client
|
| 141 |
+
assert calls == []
|
| 142 |
+
assert client.create_art("spell") == "uri:spell"
|
| 143 |
+
assert calls == ["sdxl-local"]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# Verify art client defaults use the lazy SDXL-Turbo backend.
|
| 147 |
+
def test_art_client_from_env_fast_defaults(monkeypatch) -> None:
|
| 148 |
+
monkeypatch.setenv("TABRAS_ART_BACKEND", "diffusers")
|
| 149 |
+
monkeypatch.delenv("TABRAS_ART_MODEL", raising=False)
|
| 150 |
+
monkeypatch.delenv("TABRAS_ART_STEPS", raising=False)
|
| 151 |
+
monkeypatch.delenv("TABRAS_ART_WIDTH", raising=False)
|
| 152 |
+
monkeypatch.delenv("TABRAS_ART_HEIGHT", raising=False)
|
| 153 |
+
monkeypatch.setattr("clients._art_client_cache", None)
|
| 154 |
+
|
| 155 |
+
def fake_load(model_id: str, **kwargs):
|
| 156 |
+
assert model_id == "stabilityai/sdxl-turbo"
|
| 157 |
+
assert kwargs["steps"] == 1
|
| 158 |
+
assert kwargs["width"] == 512
|
| 159 |
+
assert kwargs["height"] == 320
|
| 160 |
+
return FakeArtBackend()
|
| 161 |
+
|
| 162 |
+
monkeypatch.setattr(DiffusersImageClient, "load", staticmethod(fake_load))
|
| 163 |
+
assert art_client_from_env().create_art("spark") == "uri:spark"
|
tests/test_forge.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import forge
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
# Verify identical keys reuse one background job.
|
| 5 |
+
def test_submit_dedupes_by_key() -> None:
|
| 6 |
+
forge.reset()
|
| 7 |
+
calls: list[int] = []
|
| 8 |
+
future = forge.submit("k", lambda: calls.append(1) or "a")
|
| 9 |
+
assert forge.submit("k", lambda: calls.append(1) or "b") is future
|
| 10 |
+
forge.drain()
|
| 11 |
+
assert calls == [1]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Verify take computes inline when never prefetched and reuses results after.
|
| 15 |
+
def test_take_computes_and_caches() -> None:
|
| 16 |
+
forge.reset()
|
| 17 |
+
assert forge.take("pack", lambda: "fresh") == "fresh"
|
| 18 |
+
assert forge.take("pack", lambda: "other") == "fresh"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Verify take_ready polls completed jobs without blocking.
|
| 22 |
+
def test_take_ready_polls_completed_jobs() -> None:
|
| 23 |
+
forge.reset()
|
| 24 |
+
assert forge.take_ready("missing", "empty") == "empty"
|
| 25 |
+
forge.submit("art", lambda: "ready")
|
| 26 |
+
forge.drain()
|
| 27 |
+
assert forge.take_ready("art", "empty") == "ready"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Verify failed jobs are resubmitted on the next request.
|
| 31 |
+
def test_failed_jobs_resubmit() -> None:
|
| 32 |
+
forge.reset()
|
| 33 |
+
|
| 34 |
+
def boom() -> str:
|
| 35 |
+
raise RuntimeError("nope")
|
| 36 |
+
|
| 37 |
+
forge.submit("x", boom)
|
| 38 |
+
forge.drain()
|
| 39 |
+
assert forge.take("x", lambda: "recovered") == "recovered"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# Verify slow-lane jobs share the same key cache as fast-lane jobs.
|
| 43 |
+
def test_slow_lane_shares_key_cache() -> None:
|
| 44 |
+
forge.reset()
|
| 45 |
+
future = forge.submit("slow-key", lambda: "art", lane="slow")
|
| 46 |
+
assert forge.submit("slow-key", lambda: "other") is future
|
| 47 |
+
assert forge.take("slow-key", lambda: "other") == "art"
|
tests/test_generator.py
CHANGED
|
@@ -18,6 +18,7 @@ from generator import (
|
|
| 18 |
generate_card,
|
| 19 |
generate_pack,
|
| 20 |
generate_llamacpp_card,
|
|
|
|
| 21 |
generator_payload,
|
| 22 |
llamacpp_card_prompt,
|
| 23 |
llama_candidate_need,
|
|
@@ -30,7 +31,11 @@ from generator import (
|
|
| 30 |
parse_pack_payload,
|
| 31 |
repair_llamacpp_card,
|
| 32 |
primitive_guidance,
|
|
|
|
|
|
|
| 33 |
school_identity,
|
|
|
|
|
|
|
| 34 |
)
|
| 35 |
from primitives import Effect
|
| 36 |
|
|
@@ -159,6 +164,14 @@ def test_llama_candidate_need_avoids_repeated_deal() -> None:
|
|
| 159 |
assert "primitive_id block" in llama_candidate_need(payload, {"deal": 1, "scaling": 1})
|
| 160 |
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
# Verify llama.cpp retry prompt names exact primitive requirements.
|
| 163 |
def test_llamacpp_retry_prompt_mentions_scaling() -> None:
|
| 164 |
payload = pack_payload("earth", "wuxia", [], 2, 3)
|
|
@@ -458,16 +471,18 @@ def test_llamacpp_card_prompt() -> None:
|
|
| 458 |
assert "Current draft need" in prompt
|
| 459 |
assert "This candidate should" in prompt
|
| 460 |
assert "Already in this pack" in prompt
|
|
|
|
| 461 |
assert "\"burn\": 1" in prompt
|
| 462 |
assert "Spark" in prompt
|
| 463 |
|
| 464 |
|
| 465 |
-
# Verify llama.cpp card client
|
|
|
|
| 466 |
def test_llamacpp_card_client() -> None:
|
| 467 |
chat = FakeChat('{"name": "A", "effects": [{"primitive_id": "deal"}]}')
|
| 468 |
client = LlamaCppCardClient(chat) # type: ignore[arg-type]
|
| 469 |
raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 3))
|
| 470 |
-
assert [card["name"] for card in raw["cards"]] == ["A", "A", "A"]
|
| 471 |
assert len(chat.calls) == 3
|
| 472 |
|
| 473 |
|
|
@@ -477,26 +492,134 @@ def test_generate_llamacpp_card_retries_missed_need() -> None:
|
|
| 477 |
chat = FakeChat(
|
| 478 |
[
|
| 479 |
'{"name": "Plain", "effects": [{"primitive_id": "deal"}]}',
|
| 480 |
-
'{"earth_tabras": {"name": "
|
| 481 |
]
|
| 482 |
)
|
| 483 |
card = generate_llamacpp_card(chat, payload, [])
|
| 484 |
-
assert card["name"] == "
|
| 485 |
assert card["effects"] == [{"primitive_id": "scaling", "weight": 1}]
|
| 486 |
assert len(chat.calls) == 2
|
| 487 |
|
| 488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
# Verify llama.cpp repair fills required schema fields.
|
| 490 |
def test_repair_llamacpp_card_fills_missing_fields() -> None:
|
| 491 |
payload = pack_payload("fire", "wuxia", [], 1, 3)
|
| 492 |
raw = {"school": "fire", "effects": [{"primitive_id": "deal", "weight": 0}, {"primitive_id": "burn"}]}
|
| 493 |
card = repair_llamacpp_card(raw, payload, [])
|
| 494 |
-
assert card["name"] == "
|
| 495 |
assert card["flavor"] == ""
|
| 496 |
assert card["art_prompt"] == "wuxia fire card art"
|
| 497 |
assert card["effects"] == [{"primitive_id": "deal", "weight": 1}, {"primitive_id": "burn", "weight": 1}]
|
| 498 |
|
| 499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
# Verify llama.cpp repair drops invalid primitives.
|
| 501 |
def test_repair_llamacpp_card_drops_invalid_primitives() -> None:
|
| 502 |
payload = pack_payload("ice", "wuxia", [], 1, 3)
|
|
@@ -525,7 +648,7 @@ def test_normalize_card_response_unwraps_shape() -> None:
|
|
| 525 |
def test_parse_llamacpp_card_text_falls_back() -> None:
|
| 526 |
payload = pack_payload("earth", "wuxia", [], 1, 3)
|
| 527 |
card = parse_llamacpp_card_text('{"name" "Broken"}', payload, [])
|
| 528 |
-
assert card["name"] == "
|
| 529 |
assert card["effects"] == [{"primitive_id": "deal", "weight": 1}]
|
| 530 |
|
| 531 |
|
|
@@ -539,3 +662,21 @@ def test_codex_client_reports_failure(monkeypatch: pytest.MonkeyPatch, tmp_path:
|
|
| 539 |
client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path))
|
| 540 |
with pytest.raises(RuntimeError, match="blocked"):
|
| 541 |
client.create_pack(pack_payload("fire", "wuxia", [], 1, 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
generate_card,
|
| 19 |
generate_pack,
|
| 20 |
generate_llamacpp_card,
|
| 21 |
+
generic_card_name,
|
| 22 |
generator_payload,
|
| 23 |
llamacpp_card_prompt,
|
| 24 |
llama_candidate_need,
|
|
|
|
| 31 |
parse_pack_payload,
|
| 32 |
repair_llamacpp_card,
|
| 33 |
primitive_guidance,
|
| 34 |
+
school_flavor_mismatch,
|
| 35 |
+
school_imagery_rule,
|
| 36 |
school_identity,
|
| 37 |
+
theme_name_leak,
|
| 38 |
+
unique_card_name,
|
| 39 |
)
|
| 40 |
from primitives import Effect
|
| 41 |
|
|
|
|
| 164 |
assert "primitive_id block" in llama_candidate_need(payload, {"deal": 1, "scaling": 1})
|
| 165 |
|
| 166 |
|
| 167 |
+
# Verify Fire pack generation explores non-deal primitives after one damage card.
|
| 168 |
+
def test_llama_candidate_need_diversifies_fire_pack() -> None:
|
| 169 |
+
payload = pack_payload("fire", "wuxia", [], 2, 3)
|
| 170 |
+
assert "primitive_id burn" in llama_candidate_need(payload, {"deal": 1})
|
| 171 |
+
assert "primitive_id bomb" in llama_candidate_need(payload, {"deal": 1, "burn": 1})
|
| 172 |
+
assert "primitive_id draw" in llama_candidate_need(payload, {"deal": 1, "burn": 1, "bomb": 1})
|
| 173 |
+
|
| 174 |
+
|
| 175 |
# Verify llama.cpp retry prompt names exact primitive requirements.
|
| 176 |
def test_llamacpp_retry_prompt_mentions_scaling() -> None:
|
| 177 |
payload = pack_payload("earth", "wuxia", [], 2, 3)
|
|
|
|
| 471 |
assert "Current draft need" in prompt
|
| 472 |
assert "This candidate should" in prompt
|
| 473 |
assert "Already in this pack" in prompt
|
| 474 |
+
assert "race plan" not in prompt
|
| 475 |
assert "\"burn\": 1" in prompt
|
| 476 |
assert "Spark" in prompt
|
| 477 |
|
| 478 |
|
| 479 |
+
# Verify llama.cpp card client builds packs from concurrent single-card calls,
|
| 480 |
+
# one model call per card, with duplicate names de-duplicated after the fact.
|
| 481 |
def test_llamacpp_card_client() -> None:
|
| 482 |
chat = FakeChat('{"name": "A", "effects": [{"primitive_id": "deal"}]}')
|
| 483 |
client = LlamaCppCardClient(chat) # type: ignore[arg-type]
|
| 484 |
raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 3))
|
| 485 |
+
assert [card["name"] for card in raw["cards"]] == ["A", "A 2", "A 3"]
|
| 486 |
assert len(chat.calls) == 3
|
| 487 |
|
| 488 |
|
|
|
|
| 492 |
chat = FakeChat(
|
| 493 |
[
|
| 494 |
'{"name": "Plain", "effects": [{"primitive_id": "deal"}]}',
|
| 495 |
+
'{"earth_tabras": {"name": "Granite Vow", "effects": [{"primitive_id": "scaling"}]}}',
|
| 496 |
]
|
| 497 |
)
|
| 498 |
card = generate_llamacpp_card(chat, payload, [])
|
| 499 |
+
assert card["name"] == "Granite Vow"
|
| 500 |
assert card["effects"] == [{"primitive_id": "scaling", "weight": 1}]
|
| 501 |
assert len(chat.calls) == 2
|
| 502 |
|
| 503 |
|
| 504 |
+
# Verify generic name detection flags mechanic words and empty names.
|
| 505 |
+
def test_generic_card_name() -> None:
|
| 506 |
+
assert generic_card_name("")
|
| 507 |
+
assert generic_card_name("Fire Deal")
|
| 508 |
+
assert generic_card_name("Ice Deal 2")
|
| 509 |
+
assert generic_card_name("Vulnerable Anime School")
|
| 510 |
+
assert generic_card_name("Fast Burner")
|
| 511 |
+
assert generic_card_name("Flaming Fire")
|
| 512 |
+
assert generic_card_name("Pressureflare")
|
| 513 |
+
assert generic_card_name("Pressure Clock 2")
|
| 514 |
+
assert not generic_card_name("Vow of Cinders")
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
# Verify theme labels are rejected from in-world card names.
|
| 518 |
+
def test_theme_name_leak() -> None:
|
| 519 |
+
assert theme_name_leak("Vulnerable Anime School", "anime school")
|
| 520 |
+
assert not theme_name_leak("Glass Edict", "anime school")
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
# Verify card prompts carry the naming rules.
|
| 524 |
+
def test_prompts_ban_mechanic_names() -> None:
|
| 525 |
+
payload = pack_payload("fire", "wuxia", [], 2, 3)
|
| 526 |
+
assert "Never use these words in the name" in llamacpp_card_prompt(payload, [])
|
| 527 |
+
assert "Never use these words in the name" in llamacpp_retry_prompt(payload, "fit the draft need")
|
| 528 |
+
assert "glacier" in school_imagery_rule("fire")
|
| 529 |
+
assert "glacier" in llamacpp_card_prompt(payload, [])
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
# Verify llama.cpp card generation retries hyper-generic names.
|
| 533 |
+
def test_generate_llamacpp_card_retries_generic_name() -> None:
|
| 534 |
+
payload = pack_payload("fire", "wuxia", [], 2, 3)
|
| 535 |
+
chat = FakeChat(
|
| 536 |
+
[
|
| 537 |
+
'{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}',
|
| 538 |
+
'{"name": "Vow of Cinders", "effects": [{"primitive_id": "deal"}]}',
|
| 539 |
+
]
|
| 540 |
+
)
|
| 541 |
+
card = generate_llamacpp_card(chat, payload, [])
|
| 542 |
+
assert card["name"] == "Vow of Cinders"
|
| 543 |
+
assert len(chat.calls) == 2
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
# Verify failed Fire retries repair to a non-deal primitive when pack needs variety.
|
| 547 |
+
def test_generate_llamacpp_card_repairs_to_needed_fire_primitive() -> None:
|
| 548 |
+
payload = pack_payload("fire", "wuxia", [], 2, 3)
|
| 549 |
+
chat = FakeChat('{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}')
|
| 550 |
+
card = generate_llamacpp_card(chat, payload, [{"name": "A", "effects": [{"primitive_id": "deal"}]}])
|
| 551 |
+
assert card["effects"] == [{"primitive_id": "burn", "weight": 1}]
|
| 552 |
+
assert card["name"] == "Cinder Warrant"
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
# Verify screenshot-style generic names retry into authored names.
|
| 556 |
+
def test_generate_llamacpp_card_retries_theme_and_mechanic_names() -> None:
|
| 557 |
+
payload = pack_payload("ice", "anime school", [], 3, 3)
|
| 558 |
+
chat = FakeChat(
|
| 559 |
+
[
|
| 560 |
+
'{"name": "Vulnerable Anime School", "effects": [{"primitive_id": "multi_hit"}]}',
|
| 561 |
+
'{"name": "Glass Edict", "effects": [{"primitive_id": "multi_hit"}]}',
|
| 562 |
+
]
|
| 563 |
+
)
|
| 564 |
+
card = generate_llamacpp_card(chat, payload, [])
|
| 565 |
+
assert card["name"] == "Glass Edict"
|
| 566 |
+
assert len(chat.calls) == 2
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
# Verify off-school imagery is rejected and retried.
|
| 570 |
+
def test_generate_llamacpp_card_retries_off_school_imagery() -> None:
|
| 571 |
+
payload = pack_payload("fire", "wuxia", [], 2, 3)
|
| 572 |
+
chat = FakeChat(
|
| 573 |
+
[
|
| 574 |
+
'{"name": "Howling Glacier", "art_prompt": "frozen snow spell", "effects": [{"primitive_id": "deal"}]}',
|
| 575 |
+
'{"name": "Vow of Cinders", "art_prompt": "cinder oath spell", "effects": [{"primitive_id": "deal"}]}',
|
| 576 |
+
]
|
| 577 |
+
)
|
| 578 |
+
card = generate_llamacpp_card(chat, payload, [])
|
| 579 |
+
assert card["name"] == "Vow of Cinders"
|
| 580 |
+
assert len(chat.calls) == 2
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
# Verify school imagery validation catches Fire cards with Ice names.
|
| 584 |
+
def test_school_flavor_mismatch() -> None:
|
| 585 |
+
assert school_flavor_mismatch("fire", {"name": "Howling Glacier"})
|
| 586 |
+
assert not school_flavor_mismatch("fire", {"name": "Vow of Cinders"})
|
| 587 |
+
|
| 588 |
+
|
| 589 |
# Verify llama.cpp repair fills required schema fields.
|
| 590 |
def test_repair_llamacpp_card_fills_missing_fields() -> None:
|
| 591 |
payload = pack_payload("fire", "wuxia", [], 1, 3)
|
| 592 |
raw = {"school": "fire", "effects": [{"primitive_id": "deal", "weight": 0}, {"primitive_id": "burn"}]}
|
| 593 |
card = repair_llamacpp_card(raw, payload, [])
|
| 594 |
+
assert card["name"] == "Cinder Warrant"
|
| 595 |
assert card["flavor"] == ""
|
| 596 |
assert card["art_prompt"] == "wuxia fire card art"
|
| 597 |
assert card["effects"] == [{"primitive_id": "deal", "weight": 1}, {"primitive_id": "burn", "weight": 1}]
|
| 598 |
|
| 599 |
|
| 600 |
+
# Verify repair strips off-school imagery before costing.
|
| 601 |
+
def test_repair_llamacpp_card_replaces_off_school_name() -> None:
|
| 602 |
+
payload = pack_payload("fire", "wuxia", [], 1, 3)
|
| 603 |
+
raw = {"name": "Howling Glacier", "art_prompt": "frozen snow spell", "effects": [{"primitive_id": "deal"}]}
|
| 604 |
+
card = repair_llamacpp_card(raw, payload, [])
|
| 605 |
+
assert card["name"] == "Cinder Warrant"
|
| 606 |
+
assert "frozen" not in card["art_prompt"]
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
# Verify llama.cpp repair does not allow duplicate names inside one pack.
|
| 610 |
+
def test_repair_llamacpp_card_uniquifies_duplicate_name() -> None:
|
| 611 |
+
payload = pack_payload("fire", "wuxia", [], 1, 3)
|
| 612 |
+
raw = {"name": "Firepress", "effects": [{"primitive_id": "deal"}]}
|
| 613 |
+
card = repair_llamacpp_card(raw, payload, [{"name": "Firepress"}, {"name": "Firepress 2"}])
|
| 614 |
+
assert card["name"] == "Firepress 3"
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
# Verify generated name uniquing increments only when needed.
|
| 618 |
+
def test_unique_card_name() -> None:
|
| 619 |
+
assert unique_card_name("A", []) == "A"
|
| 620 |
+
assert unique_card_name("A", [{"name": "A"}]) == "A 2"
|
| 621 |
+
|
| 622 |
+
|
| 623 |
# Verify llama.cpp repair drops invalid primitives.
|
| 624 |
def test_repair_llamacpp_card_drops_invalid_primitives() -> None:
|
| 625 |
payload = pack_payload("ice", "wuxia", [], 1, 3)
|
|
|
|
| 648 |
def test_parse_llamacpp_card_text_falls_back() -> None:
|
| 649 |
payload = pack_payload("earth", "wuxia", [], 1, 3)
|
| 650 |
card = parse_llamacpp_card_text('{"name" "Broken"}', payload, [])
|
| 651 |
+
assert card["name"] == "Stone Covenant"
|
| 652 |
assert card["effects"] == [{"primitive_id": "deal", "weight": 1}]
|
| 653 |
|
| 654 |
|
|
|
|
| 662 |
client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path))
|
| 663 |
with pytest.raises(RuntimeError, match="blocked"):
|
| 664 |
client.create_pack(pack_payload("fire", "wuxia", [], 1, 1))
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
# Verify quick mode accepts the first card without a corrective retry.
|
| 668 |
+
def test_generate_llamacpp_card_quick_skips_retry() -> None:
|
| 669 |
+
payload = pack_payload("fire", "wuxia", [], 2, 3)
|
| 670 |
+
payload["quick"] = True
|
| 671 |
+
chat = FakeChat('{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}')
|
| 672 |
+
card = generate_llamacpp_card(chat, payload, [])
|
| 673 |
+
assert card["name"] == "Cinder Warrant"
|
| 674 |
+
assert len(chat.calls) == 1
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
# Verify generate_pack threads the quick flag into the client payload.
|
| 678 |
+
def test_generate_pack_threads_quick_flag() -> None:
|
| 679 |
+
card = {"name": "A", "flavor": "", "art_prompt": "", "effects": [{"primitive_id": "deal"}]}
|
| 680 |
+
client = FakePackClient({"cards": [card, dict(card, name="B"), dict(card, name="C")]})
|
| 681 |
+
generate_pack(client, "fire", "wuxia", [], 1, quick=True)
|
| 682 |
+
assert client.payload["quick"] is True
|
tests/test_local_llm.py
CHANGED
|
@@ -122,6 +122,7 @@ def test_chat_payload() -> None:
|
|
| 122 |
assert payload["temperature"] == 0.5
|
| 123 |
assert "max_tokens" not in payload
|
| 124 |
assert chat_payload("local", "sys", "user", 0.5, 44)["max_tokens"] == 44
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
# Verify completion payload shape matches OpenAI-compatible endpoints.
|
|
@@ -180,6 +181,7 @@ def test_local_chat_client(monkeypatch: Any) -> None:
|
|
| 180 |
payload = json.loads(calls[0][0].data.decode("utf-8"))
|
| 181 |
assert payload["model"] == "local"
|
| 182 |
assert payload["max_tokens"] == 256
|
|
|
|
| 183 |
|
| 184 |
|
| 185 |
# Verify local JSON chat client posts JSON mode payloads.
|
|
|
|
| 122 |
assert payload["temperature"] == 0.5
|
| 123 |
assert "max_tokens" not in payload
|
| 124 |
assert chat_payload("local", "sys", "user", 0.5, 44)["max_tokens"] == 44
|
| 125 |
+
assert chat_payload("local", "sys", "user", 0.5, enable_thinking=False)["chat_template_kwargs"] == {"enable_thinking": False}
|
| 126 |
|
| 127 |
|
| 128 |
# Verify completion payload shape matches OpenAI-compatible endpoints.
|
|
|
|
| 181 |
payload = json.loads(calls[0][0].data.decode("utf-8"))
|
| 182 |
assert payload["model"] == "local"
|
| 183 |
assert payload["max_tokens"] == 256
|
| 184 |
+
assert "chat_template_kwargs" not in payload
|
| 185 |
|
| 186 |
|
| 187 |
# Verify local JSON chat client posts JSON mode payloads.
|
tests/test_ui.py
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from ui import (
|
| 2 |
CARD_PANEL_COUNT,
|
| 3 |
board_html,
|
|
|
|
|
|
|
| 4 |
choose_draft_card,
|
|
|
|
| 5 |
choose_draft_card_steps,
|
| 6 |
draft_screen_html,
|
| 7 |
enemy_school_for,
|
| 8 |
fallback_pack,
|
|
|
|
| 9 |
log_html,
|
| 10 |
mana_html,
|
| 11 |
new_run,
|
|
|
|
| 12 |
pass_turn,
|
| 13 |
pass_turn_steps,
|
| 14 |
pending_tokens_html,
|
| 15 |
play_hand_card,
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
|
@@ -38,6 +49,14 @@ def test_fallback_pack() -> None:
|
|
| 38 |
assert any("banked shield charge" in card.rules_text() for card in pack)
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
# Verify a new run starts in draft state with three clickable cards.
|
| 42 |
def test_new_run_starts_draft() -> None:
|
| 43 |
state = new_run("Ada", "anime", "ice", seed=1)
|
|
@@ -50,6 +69,76 @@ def test_new_run_starts_draft() -> None:
|
|
| 50 |
assert "Deck 6/15" in html
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
# Verify choosing nine draft cards starts combat.
|
| 54 |
def test_choose_draft_card_starts_battle() -> None:
|
| 55 |
state = battle_state()
|
|
@@ -180,12 +269,39 @@ def test_boss_turn_shows_thinking() -> None:
|
|
| 180 |
thinking = [step for step in steps if step.boss_thinking]
|
| 181 |
if thinking:
|
| 182 |
assert "boss-thinking" in board_html(thinking[0])
|
| 183 |
-
assert "
|
|
|
|
| 184 |
return
|
| 185 |
state = steps[-1]
|
| 186 |
raise AssertionError("boss never took a visible thinking step")
|
| 187 |
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
# Verify boss cards stream in one step at a time, newest marked fresh.
|
| 190 |
def test_boss_plays_stream_stepwise() -> None:
|
| 191 |
state = battle_state(seed=4)
|
|
@@ -316,3 +432,94 @@ def test_log_html_newest_first() -> None:
|
|
| 316 |
newest = state.log[-1]
|
| 317 |
assert html.index(newest[:20]) < html.index(state.log[0][:10])
|
| 318 |
assert log_html(None) == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
import forge
|
| 5 |
from ui import (
|
| 6 |
CARD_PANEL_COUNT,
|
| 7 |
board_html,
|
| 8 |
+
boss_thought_lines,
|
| 9 |
+
card_html,
|
| 10 |
choose_draft_card,
|
| 11 |
+
collect_ready_pack,
|
| 12 |
choose_draft_card_steps,
|
| 13 |
draft_screen_html,
|
| 14 |
enemy_school_for,
|
| 15 |
fallback_pack,
|
| 16 |
+
finish_opening_draft,
|
| 17 |
log_html,
|
| 18 |
mana_html,
|
| 19 |
new_run,
|
| 20 |
+
new_run_shell,
|
| 21 |
pass_turn,
|
| 22 |
pass_turn_steps,
|
| 23 |
pending_tokens_html,
|
| 24 |
play_hand_card,
|
| 25 |
+
queue_next_pack,
|
| 26 |
+
refresh_art,
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
| 49 |
assert any("banked shield charge" in card.rules_text() for card in pack)
|
| 50 |
|
| 51 |
|
| 52 |
+
# Verify fallback Fire packs vary instead of repeating the same visible names.
|
| 53 |
+
def test_fire_fallback_pack_rotates_names() -> None:
|
| 54 |
+
first = fallback_pack("fire", "dark fantasy", 2)
|
| 55 |
+
second = fallback_pack("fire", "dark fantasy", 3, current_deck=first)
|
| 56 |
+
assert {card.name for card in first}.isdisjoint({card.name for card in second})
|
| 57 |
+
assert all("card" not in card.art_prompt.lower() for card in first + second)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
# Verify a new run starts in draft state with three clickable cards.
|
| 61 |
def test_new_run_starts_draft() -> None:
|
| 62 |
state = new_run("Ada", "anime", "ice", seed=1)
|
|
|
|
| 69 |
assert "Deck 6/15" in html
|
| 70 |
|
| 71 |
|
| 72 |
+
# Verify a new run shell shows loading instead of unfinished starter cards.
|
| 73 |
+
def test_new_run_shell_shows_loading() -> None:
|
| 74 |
+
state = new_run_shell("Ada", "anime", "ice", seed=1)
|
| 75 |
+
html = draft_screen_html(state)
|
| 76 |
+
assert "loading-bar" in html
|
| 77 |
+
assert "Starter Deck" not in html
|
| 78 |
+
assert "starter-card" not in html
|
| 79 |
+
assert "draft-btn" not in html
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# Verify queued first packs render a loading bar instead of blocking.
|
| 83 |
+
def test_queue_next_pack_shows_loading() -> None:
|
| 84 |
+
forge.reset()
|
| 85 |
+
client = ForgePackClient()
|
| 86 |
+
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client)
|
| 87 |
+
html = draft_screen_html(state)
|
| 88 |
+
assert "loading-bar" in html
|
| 89 |
+
assert "Forging your first draft pack" in html
|
| 90 |
+
assert "draft-btn" not in html
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# Verify a ready background pack replaces the loading state.
|
| 94 |
+
def test_collect_ready_pack_installs_pack() -> None:
|
| 95 |
+
forge.reset()
|
| 96 |
+
client = ForgePackClient()
|
| 97 |
+
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client)
|
| 98 |
+
forge.drain()
|
| 99 |
+
state = collect_ready_pack(state, client)
|
| 100 |
+
assert "loading-bar" in draft_screen_html(state)
|
| 101 |
+
forge.drain()
|
| 102 |
+
state = collect_ready_pack(state, client)
|
| 103 |
+
html = draft_screen_html(state)
|
| 104 |
+
assert "loading-bar" not in html
|
| 105 |
+
assert html.count("draft-card") == CARD_PANEL_COUNT
|
| 106 |
+
assert "draft-btn-0" in html
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# Verify draft packs reveal text before queued art settles.
|
| 110 |
+
def test_collect_ready_pack_reveals_before_art() -> None:
|
| 111 |
+
forge.reset()
|
| 112 |
+
client = ForgePackClient()
|
| 113 |
+
art_client = GatedArtClient()
|
| 114 |
+
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client, art_client)
|
| 115 |
+
forge.drain()
|
| 116 |
+
state = collect_ready_pack(state, client, art_client)
|
| 117 |
+
assert "loading-bar" in draft_screen_html(state)
|
| 118 |
+
for _ in range(20):
|
| 119 |
+
time.sleep(0.01)
|
| 120 |
+
state = collect_ready_pack(state, client, art_client)
|
| 121 |
+
if state.current_pack:
|
| 122 |
+
break
|
| 123 |
+
html = draft_screen_html(state)
|
| 124 |
+
assert "draft-btn-0" in html
|
| 125 |
+
assert "pending-art" in html
|
| 126 |
+
assert all(card.art_uri == "" for card in state.current_pack)
|
| 127 |
+
art_client.released.set()
|
| 128 |
+
forge.drain()
|
| 129 |
+
state = refresh_art(state)
|
| 130 |
+
assert all(card.art_uri for card in state.current_pack)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# Verify opening draft completion replaces the starter view with clickable picks.
|
| 134 |
+
def test_finish_opening_draft_deals_first_pack() -> None:
|
| 135 |
+
state = finish_opening_draft(new_run_shell("Ada", "anime", "ice", seed=1))
|
| 136 |
+
html = draft_screen_html(state)
|
| 137 |
+
assert "Starter Deck" not in html
|
| 138 |
+
assert html.count("draft-card") == CARD_PANEL_COUNT
|
| 139 |
+
assert "draft-btn-0" in html
|
| 140 |
+
|
| 141 |
+
|
| 142 |
# Verify choosing nine draft cards starts combat.
|
| 143 |
def test_choose_draft_card_starts_battle() -> None:
|
| 144 |
state = battle_state()
|
|
|
|
| 269 |
thinking = [step for step in steps if step.boss_thinking]
|
| 270 |
if thinking:
|
| 271 |
assert "boss-thinking" in board_html(thinking[0])
|
| 272 |
+
assert "The boss" in board_html(thinking[0])
|
| 273 |
+
assert "Boss is scheming" not in board_html(thinking[0])
|
| 274 |
return
|
| 275 |
state = steps[-1]
|
| 276 |
raise AssertionError("boss never took a visible thinking step")
|
| 277 |
|
| 278 |
|
| 279 |
+
# Verify boss thinking exposes public tactical trace beats.
|
| 280 |
+
def test_boss_thought_lines_show_public_trace() -> None:
|
| 281 |
+
from game import PendingEffect
|
| 282 |
+
|
| 283 |
+
state = battle_state(seed=4)
|
| 284 |
+
state.duel.enemy.hp = 7
|
| 285 |
+
state.duel.enemy.energy = 5
|
| 286 |
+
state.duel.pending.append(PendingEffect("bomb", state.duel.player.name, state.duel.enemy.name, 5, delay=1))
|
| 287 |
+
thoughts = boss_thought_lines(state)
|
| 288 |
+
assert thoughts[0] == "The boss realizes a 5-damage bomb lands in 2 turns."
|
| 289 |
+
assert "looks at its health: 7 HP" in thoughts[1]
|
| 290 |
+
assert "without revealing them" in thoughts[2]
|
| 291 |
+
assert all(card.name not in thoughts[2] for card in state.duel.enemy.hand)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# Verify boss trace frames use short delays to keep turns responsive.
|
| 295 |
+
def test_frame_delay_shortens_boss_trace() -> None:
|
| 296 |
+
from dataclasses import replace
|
| 297 |
+
|
| 298 |
+
from app import frame_delay
|
| 299 |
+
|
| 300 |
+
state = battle_state(seed=4)
|
| 301 |
+
assert frame_delay(replace(state, boss_thinking=True)) == 0.35
|
| 302 |
+
assert frame_delay(state) == 0.45
|
| 303 |
+
|
| 304 |
+
|
| 305 |
# Verify boss cards stream in one step at a time, newest marked fresh.
|
| 306 |
def test_boss_plays_stream_stepwise() -> None:
|
| 307 |
state = battle_state(seed=4)
|
|
|
|
| 432 |
newest = state.log[-1]
|
| 433 |
assert html.index(newest[:20]) < html.index(state.log[0][:10])
|
| 434 |
assert log_html(None) == ""
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
class ForgePackClient:
|
| 438 |
+
# Thread-safe fake pack client with distinct card names per call.
|
| 439 |
+
def __init__(self) -> None:
|
| 440 |
+
self.lock = threading.Lock()
|
| 441 |
+
self.calls = 0
|
| 442 |
+
|
| 443 |
+
# Return one synthetic pack, numbering cards across calls.
|
| 444 |
+
def create_pack(self, payload):
|
| 445 |
+
with self.lock:
|
| 446 |
+
self.calls += 1
|
| 447 |
+
base = self.calls * 10
|
| 448 |
+
card = lambda i: {"name": f"Forged {base + i}", "flavor": "x", "art_prompt": "y", "effects": [{"primitive_id": "deal", "weight": 1}]}
|
| 449 |
+
return {"cards": [card(i) for i in range(payload["pack_size"])]}
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
class FakeArtClient:
|
| 453 |
+
# Return a stable URI for each generated art prompt.
|
| 454 |
+
def create_art(self, prompt: str) -> str:
|
| 455 |
+
return f"uri:{prompt}"
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
class GatedArtClient:
|
| 459 |
+
# Wait until the test releases the image job.
|
| 460 |
+
def __init__(self) -> None:
|
| 461 |
+
self.released = threading.Event()
|
| 462 |
+
|
| 463 |
+
# Return a stable URI after the gate opens.
|
| 464 |
+
def create_art(self, prompt: str) -> str:
|
| 465 |
+
self.released.wait(1)
|
| 466 |
+
return f"uri:{prompt}"
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
# Verify a new run pre-forges candidate packs and the boss deck in the background.
|
| 470 |
+
def test_new_run_warms_forge() -> None:
|
| 471 |
+
forge.reset()
|
| 472 |
+
client = ForgePackClient()
|
| 473 |
+
state = new_run("Ada", "anime", "ice", client, seed=3)
|
| 474 |
+
forge.drain()
|
| 475 |
+
assert client.calls == 13 # pack 1, three speculative next packs, nine boss packs
|
| 476 |
+
picked = choose_draft_card(state, 0, client)
|
| 477 |
+
assert all(card.name.startswith("Forged") for card in picked.current_pack)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
# Verify battle start consumes the pre-forged boss deck.
|
| 481 |
+
def test_battle_uses_forged_boss_deck() -> None:
|
| 482 |
+
forge.reset()
|
| 483 |
+
client = ForgePackClient()
|
| 484 |
+
state = new_run("Ada", "anime", "ice", client, seed=3)
|
| 485 |
+
for _ in range(9):
|
| 486 |
+
state = choose_draft_card(state, 0, client)
|
| 487 |
+
assert state.duel is not None
|
| 488 |
+
assert any(card.name.startswith("Forged") for card in state.enemy_deck)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
# Verify generated card art renders into the card face.
|
| 492 |
+
def test_card_html_renders_generated_art() -> None:
|
| 493 |
+
card = fallback_pack("fire", "dark fantasy", 2)[0]
|
| 494 |
+
html = card_html(card.__class__(**{**card.__dict__, "art_uri": "data:image/png;base64,abc"}))
|
| 495 |
+
assert "card-art generated" in html
|
| 496 |
+
assert "data:image/png;base64,abc" in html
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
# Verify draft packs can be illustrated through the art client.
|
| 500 |
+
def test_new_run_illustrates_draft_cards() -> None:
|
| 501 |
+
forge.reset()
|
| 502 |
+
client = ForgePackClient()
|
| 503 |
+
art_client = GatedArtClient()
|
| 504 |
+
state = new_run("Ada", "anime", "ice", client, seed=3, art_client=art_client)
|
| 505 |
+
assert all(card.art_uri == "" for card in state.current_pack)
|
| 506 |
+
art_client.released.set()
|
| 507 |
+
forge.drain()
|
| 508 |
+
state = refresh_art(state)
|
| 509 |
+
assert all(card.art_uri.startswith("uri:") for card in state.current_pack)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
# Verify battle start queues art for visible combat cards.
|
| 513 |
+
def test_battle_warms_visible_card_art() -> None:
|
| 514 |
+
forge.reset()
|
| 515 |
+
client = ForgePackClient()
|
| 516 |
+
art_client = FakeArtClient()
|
| 517 |
+
state = new_run("Ada", "anime", "ice", client, seed=3, art_client=art_client)
|
| 518 |
+
for _ in range(9):
|
| 519 |
+
state = choose_draft_card(state, 0, client, art_client)
|
| 520 |
+
forge.drain()
|
| 521 |
+
state = refresh_art(state)
|
| 522 |
+
duel = state.duel
|
| 523 |
+
assert all(card.art_uri for card in duel.player.hand)
|
| 524 |
+
assert all(card.art_uri for card in duel.enemy.hand)
|
| 525 |
+
assert any(not card.art_uri for card in duel.player.deck)
|
ui.py
CHANGED
|
@@ -3,6 +3,8 @@ from dataclasses import dataclass, replace
|
|
| 3 |
from random import Random
|
| 4 |
from typing import Sequence
|
| 5 |
|
|
|
|
|
|
|
| 6 |
from boss import boss_chooser
|
| 7 |
from budget import Card, CardSpec, EffectPlan, cost_card
|
| 8 |
from clients import boss_client_from_env
|
|
@@ -35,11 +37,14 @@ class RunState:
|
|
| 35 |
turn_position: int
|
| 36 |
log: tuple[str, ...]
|
| 37 |
rng: Random
|
|
|
|
| 38 |
showcase: tuple[tuple[str, Card], ...] = ()
|
| 39 |
hp_flash: tuple[int, int] = (0, 0)
|
| 40 |
round_flash: int = 0
|
| 41 |
boss_thinking: bool = False
|
|
|
|
| 42 |
pack_fading: int = -1
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
Steps = Iterator[RunState]
|
|
@@ -50,8 +55,13 @@ def enemy_school_for(school: School) -> School:
|
|
| 50 |
return {"fire": "earth", "earth": "ice", "ice": "fire"}[school]
|
| 51 |
|
| 52 |
|
| 53 |
-
# Start a new run
|
| 54 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
rng = Random(seed)
|
| 56 |
player_deck = backbone_deck(school, world or THEME)
|
| 57 |
enemy_school = enemy_school_for(school)
|
|
@@ -73,54 +83,242 @@ def new_run(player_name: str, world: str, school: School, client: CardPackClient
|
|
| 73 |
turn_position=0,
|
| 74 |
log=(f"{player_name or 'You'} enters {world or THEME}.",),
|
| 75 |
rng=rng,
|
|
|
|
| 76 |
)
|
| 77 |
-
return
|
| 78 |
|
| 79 |
|
| 80 |
-
#
|
| 81 |
-
def
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
selected = state.current_pack[index]
|
| 86 |
cost_index = state.draft_order[state.draft_step]
|
| 87 |
anchors = state.draft_anchors + ((selected,) if cost_index in state.anchor_indexes else ())
|
| 88 |
-
|
| 89 |
state,
|
| 90 |
player_deck=state.player_deck + (selected,),
|
| 91 |
draft_anchors=anchors,
|
| 92 |
draft_step=state.draft_step + 1,
|
| 93 |
log=state.log + (f"Drafted {selected.name}.",),
|
| 94 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
yield replace(state, pack_fading=index)
|
| 96 |
if state.draft_step >= len(state.draft_order):
|
| 97 |
-
yield from start_battle_steps(state, client)
|
| 98 |
return
|
| 99 |
-
yield replace(
|
| 100 |
|
| 101 |
|
| 102 |
# Choose a draft card and advance to the next pack or battle.
|
| 103 |
-
def choose_draft_card(
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
-
#
|
| 108 |
-
def deal_next_pack(
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
# Start combat after drafting, yielding paced states for the opening round.
|
| 116 |
-
def start_battle_steps(
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
duel = DuelState(
|
| 119 |
create_player(state.player_name, shuffled_deck(state.player_deck, state.rng)),
|
| 120 |
create_player("Boss", shuffled_deck(enemy_deck, state.rng)),
|
| 121 |
)
|
| 122 |
draw_cards(duel.player, OPENING_HAND_SIZE)
|
| 123 |
draw_cards(duel.enemy, OPENING_HAND_SIZE)
|
|
|
|
|
|
|
| 124 |
state = replace(
|
| 125 |
state,
|
| 126 |
enemy_deck=enemy_deck,
|
|
@@ -128,16 +326,33 @@ def start_battle_steps(state: RunState, client: CardPackClient | None = None) ->
|
|
| 128 |
duel=duel,
|
| 129 |
log=state.log + ("The boss takes the far side of the table.",),
|
| 130 |
)
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
# Draft a boss deck for the UI.
|
| 135 |
-
def draft_enemy_deck_for_ui(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
deck = list(backbone_deck(school, world))
|
| 137 |
anchors: list[Card] = []
|
| 138 |
anchor_indexes = draft_anchor_indexes(len(SYNERGY_COSTS), rng)
|
| 139 |
for cost_index in draft_order(len(SYNERGY_COSTS), anchor_indexes):
|
| 140 |
-
pack = make_pack(client, school, world, tuple(deck), SYNERGY_COSTS[cost_index], tuple(anchors))
|
| 141 |
selected = pack[best_draft_index(tuple(deck), pack)]
|
| 142 |
deck.append(selected)
|
| 143 |
if cost_index in anchor_indexes:
|
|
@@ -148,44 +363,152 @@ def draft_enemy_deck_for_ui(client: CardPackClient | None, school: School, world
|
|
| 148 |
# Generate a model pack or deterministic fallback.
|
| 149 |
def make_pack(
|
| 150 |
client: CardPackClient | None,
|
|
|
|
| 151 |
school: School,
|
| 152 |
world: str,
|
| 153 |
current_deck: Sequence[Card],
|
| 154 |
cost: int,
|
| 155 |
anchors: Sequence[Card] = (),
|
|
|
|
| 156 |
) -> tuple[Card, ...]:
|
| 157 |
if client is not None:
|
| 158 |
try:
|
| 159 |
-
|
|
|
|
| 160 |
except Exception:
|
| 161 |
pass
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
# Return a deterministic draft pack when no model is configured.
|
| 166 |
-
def fallback_pack(
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
return tuple(cost_card(CardSpec(name, cost, school, world, effects, flavor, art)) for name, effects, flavor, art in plans)
|
| 169 |
|
| 170 |
|
| 171 |
# Return fallback effect plans for a school.
|
| 172 |
-
def fallback_plans(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
if school == "fire":
|
| 174 |
-
|
| 175 |
-
(
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
)
|
|
|
|
| 179 |
if school == "ice":
|
| 180 |
return (
|
| 181 |
-
("Glass Tempo", (EffectPlan("initiative"), EffectPlan("vulnerable")), "The
|
| 182 |
-
("Needle Flurry", (EffectPlan("multi_hit"),), "Small cuts find the opening.", "ice needles
|
| 183 |
("Crack in Winter", (EffectPlan("vulnerable"), EffectPlan("conditional")), "A weakness appears where the frost thins.", "blue crack in a frozen shield"),
|
| 184 |
)
|
| 185 |
if any(effect.primitive_id == "scaling" for card in anchors for effect in card.effects):
|
| 186 |
return (
|
| 187 |
("Stone Intake", (EffectPlan("block"), EffectPlan("scaling")), "The shield drinks the blow and answers later.", "stone shield lit with stored gold"),
|
| 188 |
-
("Table Ward", (EffectPlan("block"), EffectPlan("ward")), "A quiet wall between you and ruin.", "earthen ward
|
| 189 |
("Buried Leverage", (EffectPlan("draw"), EffectPlan("block")), "Patience becomes pressure.", "roots lifting cards from soil"),
|
| 190 |
)
|
| 191 |
return (
|
|
@@ -195,6 +518,11 @@ def fallback_plans(school: School, anchors: Sequence[Card]) -> tuple[tuple[str,
|
|
| 195 |
)
|
| 196 |
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
# Play one hand card, yielding paced states for the boss response.
|
| 199 |
def play_hand_card_steps(state: RunState, index: int) -> Steps:
|
| 200 |
if state.duel is None or index < 0 or index >= len(state.duel.player.hand):
|
|
@@ -230,7 +558,7 @@ def paced_action(state: RunState, steps_fn: Callable[..., Steps], *args: object)
|
|
| 230 |
assert state.duel is not None
|
| 231 |
duel = state.duel
|
| 232 |
player_hp, enemy_hp, round_seen = duel.player.hp, duel.enemy.hp, duel.round_number
|
| 233 |
-
fresh = replace(state, showcase=(), hp_flash=(0, 0), round_flash=0, boss_thinking=False)
|
| 234 |
for step in steps_fn(fresh, *args):
|
| 235 |
assert step.duel is not None
|
| 236 |
flash = (max(0, player_hp - step.duel.player.hp), max(0, enemy_hp - step.duel.enemy.hp))
|
|
@@ -317,7 +645,8 @@ def boss_turn_steps(state: RunState) -> Steps:
|
|
| 317 |
chooser = ui_boss_chooser()
|
| 318 |
played_any = False
|
| 319 |
while playable_indexes(state.duel.enemy):
|
| 320 |
-
|
|
|
|
| 321 |
choice = chooser(state.duel, state.duel.enemy)
|
| 322 |
if choice is None:
|
| 323 |
state.duel.enemy.energy = 0
|
|
@@ -337,6 +666,50 @@ def boss_turn_steps(state: RunState) -> Steps:
|
|
| 337 |
return state
|
| 338 |
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
# Record one played card for the battlefield showcase.
|
| 341 |
def show_play(state: RunState, owner: str, card: Card) -> RunState:
|
| 342 |
return replace(state, showcase=state.showcase + ((owner, card),))
|
|
@@ -356,7 +729,7 @@ def add_log(state: RunState, line: str) -> RunState:
|
|
| 356 |
return replace(state, log=(state.log + (line,))[-80:])
|
| 357 |
|
| 358 |
|
| 359 |
-
# Return HTML for one card face
|
| 360 |
def card_html(card: Card | None, classes: str = "", style: str = "", onclick: str = "") -> str:
|
| 361 |
if card is None:
|
| 362 |
return "<div class='tabras-card empty'></div>"
|
|
@@ -364,17 +737,27 @@ def card_html(card: Card | None, classes: str = "", style: str = "", onclick: st
|
|
| 364 |
f'<div class="tabras-card school-{card.school} {classes}" style="{style}" onclick="{onclick}">'
|
| 365 |
f"<div class='card-cost'>{card.cost}</div>"
|
| 366 |
f"<div class='card-name'>{escape_html(card.name)}</div>"
|
| 367 |
-
"
|
| 368 |
f"<div class='card-rules'>{escape_html(card.rules_text())}</div>"
|
| 369 |
f"<div class='card-flavor'>{escape_html(card.flavor)}</div>"
|
| 370 |
"</div>"
|
| 371 |
)
|
| 372 |
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
# Return HTML for the full draft screen.
|
| 375 |
def draft_screen_html(state: RunState | None) -> str:
|
| 376 |
-
if state is None or state.duel is not None
|
| 377 |
return ""
|
|
|
|
|
|
|
|
|
|
| 378 |
if state.draft_step >= len(state.draft_order):
|
| 379 |
banner = "<div class='draft-banner'><h2>Deck complete</h2><p>The boss shuffles its deck…</p></div>"
|
| 380 |
else:
|
|
@@ -390,6 +773,22 @@ def draft_screen_html(state: RunState | None) -> str:
|
|
| 390 |
return f"<div class='draft-board'>{banner}<div class='draft-pack'>{cards}</div>{deck_strip_html(state)}</div>"
|
| 391 |
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
# Return one draft card, fading the old pack out after a pick.
|
| 394 |
def draft_card_html(state: RunState, index: int, card: Card) -> str:
|
| 395 |
if state.pack_fading < 0:
|
|
@@ -431,7 +830,8 @@ def board_html(state: RunState | None) -> str:
|
|
| 431 |
def enemy_zone_html(state: RunState) -> str:
|
| 432 |
assert state.duel is not None
|
| 433 |
enemy = state.duel.enemy
|
| 434 |
-
|
|
|
|
| 435 |
return (
|
| 436 |
"<div class='zone enemy-zone'>"
|
| 437 |
f"<div class='zone-center'>{enemy_hand_html(enemy)}{hero_html(enemy, state.enemy_school, 'Boss', True, state.hp_flash[1])}{thinking}</div>"
|
|
|
|
| 3 |
from random import Random
|
| 4 |
from typing import Sequence
|
| 5 |
|
| 6 |
+
import forge
|
| 7 |
+
from art import ArtClient, illustrate_card
|
| 8 |
from boss import boss_chooser
|
| 9 |
from budget import Card, CardSpec, EffectPlan, cost_card
|
| 10 |
from clients import boss_client_from_env
|
|
|
|
| 37 |
turn_position: int
|
| 38 |
log: tuple[str, ...]
|
| 39 |
rng: Random
|
| 40 |
+
enemy_seed: int = 0
|
| 41 |
showcase: tuple[tuple[str, Card], ...] = ()
|
| 42 |
hp_flash: tuple[int, int] = (0, 0)
|
| 43 |
round_flash: int = 0
|
| 44 |
boss_thinking: bool = False
|
| 45 |
+
boss_thought: str = ""
|
| 46 |
pack_fading: int = -1
|
| 47 |
+
loading: str = ""
|
| 48 |
|
| 49 |
|
| 50 |
Steps = Iterator[RunState]
|
|
|
|
| 55 |
return {"fire": "earth", "earth": "ice", "ice": "fire"}[school]
|
| 56 |
|
| 57 |
|
| 58 |
+
# Start a new run shell with the starter deck visible immediately.
|
| 59 |
+
def new_run_shell(
|
| 60 |
+
player_name: str,
|
| 61 |
+
world: str,
|
| 62 |
+
school: School,
|
| 63 |
+
seed: int = 7,
|
| 64 |
+
) -> RunState:
|
| 65 |
rng = Random(seed)
|
| 66 |
player_deck = backbone_deck(school, world or THEME)
|
| 67 |
enemy_school = enemy_school_for(school)
|
|
|
|
| 83 |
turn_position=0,
|
| 84 |
log=(f"{player_name or 'You'} enters {world or THEME}.",),
|
| 85 |
rng=rng,
|
| 86 |
+
enemy_seed=rng.getrandbits(32),
|
| 87 |
)
|
| 88 |
+
return state
|
| 89 |
|
| 90 |
|
| 91 |
+
# Start a new run, generate the first draft pack, and warm the background forge.
|
| 92 |
+
def new_run(
|
| 93 |
+
player_name: str,
|
| 94 |
+
world: str,
|
| 95 |
+
school: School,
|
| 96 |
+
client: CardPackClient | None = None,
|
| 97 |
+
seed: int = 7,
|
| 98 |
+
art_client: ArtClient | None = None,
|
| 99 |
+
) -> RunState:
|
| 100 |
+
state = new_run_shell(player_name, world, school, seed)
|
| 101 |
+
warm_enemy_deck(state, client, art_client)
|
| 102 |
+
state = deal_next_pack(state, client, art_client)
|
| 103 |
+
prefetch_next_packs(state, client, art_client)
|
| 104 |
+
return state
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# Finish the first generated draft pack for a visible starter-deck shell.
|
| 108 |
+
def finish_opening_draft(
|
| 109 |
+
state: RunState,
|
| 110 |
+
client: CardPackClient | None = None,
|
| 111 |
+
art_client: ArtClient | None = None,
|
| 112 |
+
) -> RunState:
|
| 113 |
+
warm_enemy_deck(state, client, art_client)
|
| 114 |
+
state = deal_next_pack(state, client, art_client)
|
| 115 |
+
prefetch_next_packs(state, client, art_client)
|
| 116 |
+
return state
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# Queue the next draft pack and render a loading state instead of blocking.
|
| 120 |
+
def queue_next_pack(
|
| 121 |
+
state: RunState,
|
| 122 |
+
client: CardPackClient | None = None,
|
| 123 |
+
art_client: ArtClient | None = None,
|
| 124 |
+
) -> RunState:
|
| 125 |
+
warm_enemy_deck(state, client, art_client)
|
| 126 |
+
if state.draft_step >= len(state.draft_order):
|
| 127 |
+
return state
|
| 128 |
+
cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
|
| 129 |
+
forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
|
| 130 |
+
return replace(state, current_pack=(), loading=loading_message(state))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# Attach a queued draft pack once its background job finishes.
|
| 134 |
+
def collect_ready_pack(
|
| 135 |
+
state: RunState,
|
| 136 |
+
client: CardPackClient | None = None,
|
| 137 |
+
art_client: ArtClient | None = None,
|
| 138 |
+
) -> RunState:
|
| 139 |
+
if state.duel is not None or state.current_pack or state.draft_step >= len(state.draft_order):
|
| 140 |
+
return state
|
| 141 |
+
cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
|
| 142 |
+
pack = forge.take_ready(pack_key(state))
|
| 143 |
+
if pack is None:
|
| 144 |
+
forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
|
| 145 |
+
return state
|
| 146 |
+
warm_card_art(art_client, pack)
|
| 147 |
+
pack = collect_ready_cards(pack)
|
| 148 |
+
preview_state = replace(state, current_pack=pack, loading="")
|
| 149 |
+
if state.draft_step == 0 and client is not None:
|
| 150 |
+
prefetch_next_packs(preview_state, client, art_client)
|
| 151 |
+
if not next_packs_ready(preview_state):
|
| 152 |
+
return state
|
| 153 |
+
state = preview_state
|
| 154 |
+
prefetch_next_packs(state, client, art_client)
|
| 155 |
+
return state
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# Return whether all one-pick-ahead packs are already forged.
|
| 159 |
+
def next_packs_ready(state: RunState) -> bool:
|
| 160 |
+
for index in range(len(state.current_pack)):
|
| 161 |
+
nxt = apply_pick(state, index)
|
| 162 |
+
if nxt.draft_step >= len(nxt.draft_order):
|
| 163 |
+
continue
|
| 164 |
+
if forge.take_ready(pack_key(nxt)) is None:
|
| 165 |
+
return False
|
| 166 |
+
return True
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# Return copy for the visible draft loading state.
|
| 170 |
+
def loading_message(state: RunState) -> str:
|
| 171 |
+
if state.draft_step == 0:
|
| 172 |
+
return "Forging your first draft pack"
|
| 173 |
+
return "Forging the next draft pack"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Apply one draft pick to the run state.
|
| 177 |
+
def apply_pick(state: RunState, index: int) -> RunState:
|
| 178 |
selected = state.current_pack[index]
|
| 179 |
cost_index = state.draft_order[state.draft_step]
|
| 180 |
anchors = state.draft_anchors + ((selected,) if cost_index in state.anchor_indexes else ())
|
| 181 |
+
return replace(
|
| 182 |
state,
|
| 183 |
player_deck=state.player_deck + (selected,),
|
| 184 |
draft_anchors=anchors,
|
| 185 |
draft_step=state.draft_step + 1,
|
| 186 |
log=state.log + (f"Drafted {selected.name}.",),
|
| 187 |
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# Choose a draft card, yielding paced battle states once drafting completes.
|
| 191 |
+
def choose_draft_card_steps(
|
| 192 |
+
state: RunState,
|
| 193 |
+
index: int,
|
| 194 |
+
client: CardPackClient | None = None,
|
| 195 |
+
art_client: ArtClient | None = None,
|
| 196 |
+
) -> Steps:
|
| 197 |
+
state = refresh_art(state)
|
| 198 |
+
if index < 0 or index >= len(state.current_pack):
|
| 199 |
+
yield add_log(state, "That draft card is not available.")
|
| 200 |
+
return
|
| 201 |
+
state = apply_pick(state, index)
|
| 202 |
+
yield replace(state, pack_fading=index)
|
| 203 |
+
if state.draft_step >= len(state.draft_order):
|
| 204 |
+
yield from start_battle_steps(state, client, art_client)
|
| 205 |
+
return
|
| 206 |
+
state = deal_next_pack(state, client, art_client)
|
| 207 |
+
prefetch_next_packs(state, client, art_client)
|
| 208 |
+
yield replace(state, pack_fading=-1)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# Choose a draft card and queue the next pack without blocking UI rendering.
|
| 212 |
+
def choose_draft_card_loading_steps(
|
| 213 |
+
state: RunState,
|
| 214 |
+
index: int,
|
| 215 |
+
client: CardPackClient | None = None,
|
| 216 |
+
art_client: ArtClient | None = None,
|
| 217 |
+
) -> Steps:
|
| 218 |
+
state = refresh_art(state)
|
| 219 |
+
if index < 0 or index >= len(state.current_pack):
|
| 220 |
+
yield add_log(state, "That draft card is not available.")
|
| 221 |
+
return
|
| 222 |
+
state = apply_pick(state, index)
|
| 223 |
yield replace(state, pack_fading=index)
|
| 224 |
if state.draft_step >= len(state.draft_order):
|
| 225 |
+
yield from start_battle_steps(state, client, art_client)
|
| 226 |
return
|
| 227 |
+
yield queue_next_pack(replace(state, pack_fading=-1), client, art_client)
|
| 228 |
|
| 229 |
|
| 230 |
# Choose a draft card and advance to the next pack or battle.
|
| 231 |
+
def choose_draft_card(
|
| 232 |
+
state: RunState,
|
| 233 |
+
index: int,
|
| 234 |
+
client: CardPackClient | None = None,
|
| 235 |
+
art_client: ArtClient | None = None,
|
| 236 |
+
) -> RunState:
|
| 237 |
+
return last_state(choose_draft_card_steps(state, index, client, art_client))
|
| 238 |
|
| 239 |
|
| 240 |
+
# Deal the next draft pack, preferring a forge-prefetched one.
|
| 241 |
+
def deal_next_pack(
|
| 242 |
+
state: RunState,
|
| 243 |
+
client: CardPackClient | None = None,
|
| 244 |
+
art_client: ArtClient | None = None,
|
| 245 |
+
) -> RunState:
|
| 246 |
+
cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
|
| 247 |
+
maker = pack_maker(client, art_client, state, cost)
|
| 248 |
+
pack = forge.take(pack_key(state), maker) if client is not None else maker()
|
| 249 |
+
warm_card_art(art_client, pack)
|
| 250 |
+
return replace(state, current_pack=collect_ready_cards(pack))
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# Return a deck-content signature for forge keys.
|
| 254 |
+
def deck_sig(cards: Sequence[Card]) -> tuple[str, ...]:
|
| 255 |
+
return tuple(f"{card.cost} {card.name}" for card in cards)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# Return the forge key for the next pack of one draft state.
|
| 259 |
+
def pack_key(state: RunState) -> tuple:
|
| 260 |
+
return ("pack", state.enemy_seed, state.draft_step, deck_sig(state.player_deck), deck_sig(state.draft_anchors))
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# Build a pack generator bound to one draft state.
|
| 264 |
+
def pack_maker(
|
| 265 |
+
client: CardPackClient | None,
|
| 266 |
+
art_client: ArtClient | None,
|
| 267 |
+
state: RunState,
|
| 268 |
+
cost: int,
|
| 269 |
+
) -> Callable[[], tuple[Card, ...]]:
|
| 270 |
+
return lambda: make_pack(client, None, state.school, state.world, state.player_deck, cost, state.draft_anchors)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# Pre-generate every candidate next pack while the player reads the current one.
|
| 274 |
+
def prefetch_next_packs(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None:
|
| 275 |
+
if client is None or state.draft_step >= len(state.draft_order):
|
| 276 |
+
return
|
| 277 |
+
for index in range(len(state.current_pack)):
|
| 278 |
+
nxt = apply_pick(state, index)
|
| 279 |
+
if nxt.draft_step >= len(nxt.draft_order):
|
| 280 |
+
continue
|
| 281 |
+
cost = SYNERGY_COSTS[nxt.draft_order[nxt.draft_step]]
|
| 282 |
+
forge.submit(pack_key(nxt), pack_maker(client, art_client, nxt, cost))
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# Return the forge key for the boss deck of one run.
|
| 286 |
+
def enemy_deck_key(state: RunState) -> tuple:
|
| 287 |
+
return ("enemy", state.enemy_school, state.world, state.enemy_seed)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# Build a boss-deck generator with its own deterministic rng.
|
| 291 |
+
def enemy_deck_maker(
|
| 292 |
+
client: CardPackClient | None,
|
| 293 |
+
art_client: ArtClient | None,
|
| 294 |
+
state: RunState,
|
| 295 |
+
) -> Callable[[], tuple[Card, ...]]:
|
| 296 |
+
return lambda: draft_enemy_deck_for_ui(client, art_client, state.enemy_school, state.world, Random(state.enemy_seed))
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# Start forging the boss deck on the slow lane during the draft.
|
| 300 |
+
def warm_enemy_deck(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None:
|
| 301 |
+
if client is not None:
|
| 302 |
+
forge.submit(enemy_deck_key(state), enemy_deck_maker(client, art_client, state), lane="slow")
|
| 303 |
|
| 304 |
|
| 305 |
# Start combat after drafting, yielding paced states for the opening round.
|
| 306 |
+
def start_battle_steps(
|
| 307 |
+
state: RunState,
|
| 308 |
+
client: CardPackClient | None = None,
|
| 309 |
+
art_client: ArtClient | None = None,
|
| 310 |
+
) -> Steps:
|
| 311 |
+
state = refresh_art(state)
|
| 312 |
+
maker = enemy_deck_maker(client, art_client, state)
|
| 313 |
+
enemy_deck = forge.take(enemy_deck_key(state), maker) if client is not None else maker()
|
| 314 |
duel = DuelState(
|
| 315 |
create_player(state.player_name, shuffled_deck(state.player_deck, state.rng)),
|
| 316 |
create_player("Boss", shuffled_deck(enemy_deck, state.rng)),
|
| 317 |
)
|
| 318 |
draw_cards(duel.player, OPENING_HAND_SIZE)
|
| 319 |
draw_cards(duel.enemy, OPENING_HAND_SIZE)
|
| 320 |
+
warm_card_art(art_client, duel.player.hand)
|
| 321 |
+
warm_card_art(art_client, duel.enemy.hand)
|
| 322 |
state = replace(
|
| 323 |
state,
|
| 324 |
enemy_deck=enemy_deck,
|
|
|
|
| 326 |
duel=duel,
|
| 327 |
log=state.log + ("The boss takes the far side of the table.",),
|
| 328 |
)
|
| 329 |
+
for step in paced_action(state, advance_to_player_steps):
|
| 330 |
+
warm_battle_art(art_client, step)
|
| 331 |
+
yield step
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
# Queue art for currently visible combat cards.
|
| 335 |
+
def warm_battle_art(art_client: ArtClient | None, state: RunState) -> None:
|
| 336 |
+
if state.duel is None:
|
| 337 |
+
return
|
| 338 |
+
warm_card_art(art_client, state.duel.player.hand)
|
| 339 |
+
warm_card_art(art_client, state.duel.enemy.hand)
|
| 340 |
+
warm_card_art(art_client, tuple(card for _, card in state.showcase))
|
| 341 |
|
| 342 |
|
| 343 |
# Draft a boss deck for the UI.
|
| 344 |
+
def draft_enemy_deck_for_ui(
|
| 345 |
+
client: CardPackClient | None,
|
| 346 |
+
art_client: ArtClient | None,
|
| 347 |
+
school: School,
|
| 348 |
+
world: str,
|
| 349 |
+
rng: Random,
|
| 350 |
+
) -> tuple[Card, ...]:
|
| 351 |
deck = list(backbone_deck(school, world))
|
| 352 |
anchors: list[Card] = []
|
| 353 |
anchor_indexes = draft_anchor_indexes(len(SYNERGY_COSTS), rng)
|
| 354 |
for cost_index in draft_order(len(SYNERGY_COSTS), anchor_indexes):
|
| 355 |
+
pack = make_pack(client, None, school, world, tuple(deck), SYNERGY_COSTS[cost_index], tuple(anchors), quick=True)
|
| 356 |
selected = pack[best_draft_index(tuple(deck), pack)]
|
| 357 |
deck.append(selected)
|
| 358 |
if cost_index in anchor_indexes:
|
|
|
|
| 363 |
# Generate a model pack or deterministic fallback.
|
| 364 |
def make_pack(
|
| 365 |
client: CardPackClient | None,
|
| 366 |
+
art_client: ArtClient | None,
|
| 367 |
school: School,
|
| 368 |
world: str,
|
| 369 |
current_deck: Sequence[Card],
|
| 370 |
cost: int,
|
| 371 |
anchors: Sequence[Card] = (),
|
| 372 |
+
quick: bool = False,
|
| 373 |
) -> tuple[Card, ...]:
|
| 374 |
if client is not None:
|
| 375 |
try:
|
| 376 |
+
pack = generate_pack(client, school, world, current_deck, cost, draft_anchors=anchors, quick=quick)
|
| 377 |
+
return collect_ready_cards(pack)
|
| 378 |
except Exception:
|
| 379 |
pass
|
| 380 |
+
pack = fallback_pack(school, world, cost, anchors, current_deck)
|
| 381 |
+
return collect_ready_cards(pack)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# Return the stable background-art key for one card face.
|
| 385 |
+
def card_art_key(card: Card) -> tuple:
|
| 386 |
+
return ("art", card.school, card.theme, card.cost, card.name, card.rules_text(), card.flavor, card.art_prompt)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# Start card illustration jobs on the dedicated art lane so images never queue
|
| 390 |
+
# behind the boss-deck draft (slow lane) or the player's packs (fast lane).
|
| 391 |
+
def warm_card_art(art_client: ArtClient | None, cards: Sequence[Card]) -> None:
|
| 392 |
+
if art_client is None:
|
| 393 |
+
return
|
| 394 |
+
for card in cards:
|
| 395 |
+
if not card.art_uri:
|
| 396 |
+
forge.submit(card_art_key(card), lambda card=card: illustrate_card(art_client, card), lane="art")
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# Attach a finished generated image to one card if the forge has it.
|
| 400 |
+
def collect_ready_card(card: Card) -> Card:
|
| 401 |
+
if card.art_uri:
|
| 402 |
+
return card
|
| 403 |
+
illustrated = forge.take_ready(card_art_key(card), card)
|
| 404 |
+
if isinstance(illustrated, Card) and illustrated.art_uri:
|
| 405 |
+
return illustrated
|
| 406 |
+
return card
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
# Attach any finished generated images to a card sequence.
|
| 410 |
+
def collect_ready_cards(cards: Sequence[Card]) -> tuple[Card, ...]:
|
| 411 |
+
return tuple(collect_ready_card(card) for card in cards)
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
# Return cards plus whether all queued art jobs have settled.
|
| 415 |
+
def collect_pack_art_status(cards: Sequence[Card], art_client: ArtClient | None) -> tuple[tuple[Card, ...], bool]:
|
| 416 |
+
if art_client is None:
|
| 417 |
+
return collect_ready_cards(cards), True
|
| 418 |
+
collected = tuple(collect_ready_art_status(card) for card in cards)
|
| 419 |
+
return tuple(card for card, _ in collected), all(ready for _, ready in collected)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
# Return one card plus whether its art job has settled.
|
| 423 |
+
def collect_ready_art_status(card: Card) -> tuple[Card, bool]:
|
| 424 |
+
if card.art_uri:
|
| 425 |
+
return card, True
|
| 426 |
+
illustrated = forge.take_ready(card_art_key(card))
|
| 427 |
+
if illustrated is None:
|
| 428 |
+
return card, False
|
| 429 |
+
if isinstance(illustrated, Card):
|
| 430 |
+
return illustrated, True
|
| 431 |
+
return card, True
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
# Refresh generated art across the visible run state.
|
| 435 |
+
def refresh_art(state: RunState | None) -> RunState | None:
|
| 436 |
+
if state is None:
|
| 437 |
+
return None
|
| 438 |
+
return replace(
|
| 439 |
+
state,
|
| 440 |
+
player_deck=collect_ready_cards(state.player_deck),
|
| 441 |
+
enemy_deck=collect_ready_cards(state.enemy_deck),
|
| 442 |
+
current_pack=collect_ready_cards(state.current_pack),
|
| 443 |
+
duel=refresh_duel_art(state.duel),
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
# Attach finished generated images inside an active duel.
|
| 448 |
+
def refresh_duel_art(duel: DuelState | None) -> DuelState | None:
|
| 449 |
+
if duel is None:
|
| 450 |
+
return None
|
| 451 |
+
return replace(duel, player=refresh_player_art(duel.player), enemy=refresh_player_art(duel.enemy))
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
# Attach finished generated images inside one player zone.
|
| 455 |
+
def refresh_player_art(player: PlayerState) -> PlayerState:
|
| 456 |
+
return replace(
|
| 457 |
+
player,
|
| 458 |
+
deck=list(collect_ready_cards(player.deck)),
|
| 459 |
+
hand=list(collect_ready_cards(player.hand)),
|
| 460 |
+
discard=list(collect_ready_cards(player.discard)),
|
| 461 |
+
)
|
| 462 |
|
| 463 |
|
| 464 |
# Return a deterministic draft pack when no model is configured.
|
| 465 |
+
def fallback_pack(
|
| 466 |
+
school: School,
|
| 467 |
+
world: str,
|
| 468 |
+
cost: int,
|
| 469 |
+
anchors: Sequence[Card] = (),
|
| 470 |
+
current_deck: Sequence[Card] = (),
|
| 471 |
+
) -> tuple[Card, ...]:
|
| 472 |
+
plans = fallback_plans(school, anchors, cost, current_deck)
|
| 473 |
return tuple(cost_card(CardSpec(name, cost, school, world, effects, flavor, art)) for name, effects, flavor, art in plans)
|
| 474 |
|
| 475 |
|
| 476 |
# Return fallback effect plans for a school.
|
| 477 |
+
def fallback_plans(
|
| 478 |
+
school: School,
|
| 479 |
+
anchors: Sequence[Card],
|
| 480 |
+
cost: int = 1,
|
| 481 |
+
current_deck: Sequence[Card] = (),
|
| 482 |
+
) -> tuple[tuple[str, tuple[EffectPlan, ...], str, str], ...]:
|
| 483 |
if school == "fire":
|
| 484 |
+
variants = (
|
| 485 |
+
(
|
| 486 |
+
("Cinder Rush", (EffectPlan("deal"), EffectPlan("burn")), "Heat hits first, then keeps biting.", "a rushing ribbon of cinders across black stone"),
|
| 487 |
+
("Fuse Prayer", (EffectPlan("bomb"),), "A delayed blast tucked under the enemy's next breath.", "black powder sigil glowing under ash"),
|
| 488 |
+
("Kindling Draw", (EffectPlan("draw"), EffectPlan("deal")), "The next spark finds your hand.", "embers rising into a bright spiral"),
|
| 489 |
+
),
|
| 490 |
+
(
|
| 491 |
+
("Ember Warrant", (EffectPlan("deal"),), "The sentence arrives before the smoke.", "a red-hot seal stamped into dark basalt"),
|
| 492 |
+
("Blackpowder Hymn", (EffectPlan("bomb"), EffectPlan("burn")), "Every note waits for the fuse.", "sparks crawling through a ritual fuse circle"),
|
| 493 |
+
("Coalglass Vow", (EffectPlan("draw"), EffectPlan("burn")), "The oath glows brighter when broken.", "molten glass reflecting a small flame"),
|
| 494 |
+
),
|
| 495 |
+
(
|
| 496 |
+
("Ashen Oath", (EffectPlan("burn"), EffectPlan("scaling")), "A promise written in soot remembers every spark.", "ash lifting from a cracked ember sigil"),
|
| 497 |
+
("Red Sigil", (EffectPlan("deal"), EffectPlan("draw")), "One mark, one opening, one breath of heat.", "a crimson rune flaring in smoke"),
|
| 498 |
+
("Charcoal Saint", (EffectPlan("block"), EffectPlan("burn")), "The saint shields with one hand and burns with the other.", "a soot-black statue lit from within"),
|
| 499 |
+
),
|
| 500 |
)
|
| 501 |
+
return variants[fallback_variant_index(cost, current_deck, len(variants))]
|
| 502 |
if school == "ice":
|
| 503 |
return (
|
| 504 |
+
("Glass Tempo", (EffectPlan("initiative"), EffectPlan("vulnerable")), "The air freezes one heartbeat ahead.", "frosted hourglass above a frozen floor"),
|
| 505 |
+
("Needle Flurry", (EffectPlan("multi_hit"),), "Small cuts find the opening.", "ice needles crossing a moonlit hall"),
|
| 506 |
("Crack in Winter", (EffectPlan("vulnerable"), EffectPlan("conditional")), "A weakness appears where the frost thins.", "blue crack in a frozen shield"),
|
| 507 |
)
|
| 508 |
if any(effect.primitive_id == "scaling" for card in anchors for effect in card.effects):
|
| 509 |
return (
|
| 510 |
("Stone Intake", (EffectPlan("block"), EffectPlan("scaling")), "The shield drinks the blow and answers later.", "stone shield lit with stored gold"),
|
| 511 |
+
("Table Ward", (EffectPlan("block"), EffectPlan("ward")), "A quiet wall between you and ruin.", "earthen ward carved into dark stone"),
|
| 512 |
("Buried Leverage", (EffectPlan("draw"), EffectPlan("block")), "Patience becomes pressure.", "roots lifting cards from soil"),
|
| 513 |
)
|
| 514 |
return (
|
|
|
|
| 518 |
)
|
| 519 |
|
| 520 |
|
| 521 |
+
# Return the rotating fallback pack index.
|
| 522 |
+
def fallback_variant_index(cost: int, current_deck: Sequence[Card], count: int) -> int:
|
| 523 |
+
return (cost + len(current_deck)) % count
|
| 524 |
+
|
| 525 |
+
|
| 526 |
# Play one hand card, yielding paced states for the boss response.
|
| 527 |
def play_hand_card_steps(state: RunState, index: int) -> Steps:
|
| 528 |
if state.duel is None or index < 0 or index >= len(state.duel.player.hand):
|
|
|
|
| 558 |
assert state.duel is not None
|
| 559 |
duel = state.duel
|
| 560 |
player_hp, enemy_hp, round_seen = duel.player.hp, duel.enemy.hp, duel.round_number
|
| 561 |
+
fresh = replace(state, showcase=(), hp_flash=(0, 0), round_flash=0, boss_thinking=False, boss_thought="")
|
| 562 |
for step in steps_fn(fresh, *args):
|
| 563 |
assert step.duel is not None
|
| 564 |
flash = (max(0, player_hp - step.duel.player.hp), max(0, enemy_hp - step.duel.enemy.hp))
|
|
|
|
| 645 |
chooser = ui_boss_chooser()
|
| 646 |
played_any = False
|
| 647 |
while playable_indexes(state.duel.enemy):
|
| 648 |
+
for thought in boss_thought_lines(state):
|
| 649 |
+
yield replace(state, boss_thinking=True, boss_thought=thought)
|
| 650 |
choice = chooser(state.duel, state.duel.enemy)
|
| 651 |
if choice is None:
|
| 652 |
state.duel.enemy.energy = 0
|
|
|
|
| 666 |
return state
|
| 667 |
|
| 668 |
|
| 669 |
+
# Return short visible boss reasoning beats from public board state.
|
| 670 |
+
def boss_thought_lines(state: RunState) -> tuple[str, ...]:
|
| 671 |
+
assert state.duel is not None
|
| 672 |
+
return (
|
| 673 |
+
boss_pending_thought(state.duel),
|
| 674 |
+
boss_health_thought(state.duel.enemy),
|
| 675 |
+
boss_hand_thought(state.duel.enemy),
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
# Return the boss thought about incoming delayed effects.
|
| 680 |
+
def boss_pending_thought(duel: DuelState) -> str:
|
| 681 |
+
threats = [effect for effect in duel.pending if effect.target == duel.enemy.name]
|
| 682 |
+
bombs = [effect for effect in threats if effect.primitive_id == "bomb"]
|
| 683 |
+
burns = [effect for effect in threats if effect.primitive_id == "burn"]
|
| 684 |
+
if bombs:
|
| 685 |
+
soonest = min(effect.delay + 1 for effect in bombs)
|
| 686 |
+
total = sum(effect.amount for effect in bombs)
|
| 687 |
+
label = "turn" if soonest == 1 else "turns"
|
| 688 |
+
return f"The boss realizes a {total}-damage bomb lands in {soonest} {label}."
|
| 689 |
+
if burns:
|
| 690 |
+
total = sum(effect.amount for effect in burns)
|
| 691 |
+
return f"The boss notices {total} burn damage still ticking."
|
| 692 |
+
return "The boss scans the table for delayed threats."
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
# Return the boss thought about its own health and defenses.
|
| 696 |
+
def boss_health_thought(enemy: PlayerState) -> str:
|
| 697 |
+
defenses = enemy.block + enemy.ward
|
| 698 |
+
if enemy.hp <= 8:
|
| 699 |
+
return f"The boss looks at its health: {enemy.hp} HP, with {defenses} defense."
|
| 700 |
+
return f"The boss checks its health: {enemy.hp} HP, with {defenses} defense."
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
# Return the boss thought about playable hand options.
|
| 704 |
+
def boss_hand_thought(enemy: PlayerState) -> str:
|
| 705 |
+
playable = playable_indexes(enemy)
|
| 706 |
+
if not playable:
|
| 707 |
+
return "The boss considers its hand and finds no playable card."
|
| 708 |
+
count = len(playable)
|
| 709 |
+
label = "line" if count == 1 else "lines"
|
| 710 |
+
return f"The boss considers {count} playable {label} without revealing them."
|
| 711 |
+
|
| 712 |
+
|
| 713 |
# Record one played card for the battlefield showcase.
|
| 714 |
def show_play(state: RunState, owner: str, card: Card) -> RunState:
|
| 715 |
return replace(state, showcase=state.showcase + ((owner, card),))
|
|
|
|
| 729 |
return replace(state, log=(state.log + (line,))[-80:])
|
| 730 |
|
| 731 |
|
| 732 |
+
# Return HTML for one card face.
|
| 733 |
def card_html(card: Card | None, classes: str = "", style: str = "", onclick: str = "") -> str:
|
| 734 |
if card is None:
|
| 735 |
return "<div class='tabras-card empty'></div>"
|
|
|
|
| 737 |
f'<div class="tabras-card school-{card.school} {classes}" style="{style}" onclick="{onclick}">'
|
| 738 |
f"<div class='card-cost'>{card.cost}</div>"
|
| 739 |
f"<div class='card-name'>{escape_html(card.name)}</div>"
|
| 740 |
+
f"{card_art_html(card)}"
|
| 741 |
f"<div class='card-rules'>{escape_html(card.rules_text())}</div>"
|
| 742 |
f"<div class='card-flavor'>{escape_html(card.flavor)}</div>"
|
| 743 |
"</div>"
|
| 744 |
)
|
| 745 |
|
| 746 |
|
| 747 |
+
# Return a card art panel, using generated art when present.
|
| 748 |
+
def card_art_html(card: Card) -> str:
|
| 749 |
+
if not card.art_uri:
|
| 750 |
+
return f"<div class='card-art pending-art school-art-{card.school}'></div>"
|
| 751 |
+
return f'<div class="card-art generated" style="background-image:url("{escape_html(card.art_uri)}")"></div>'
|
| 752 |
+
|
| 753 |
+
|
| 754 |
# Return HTML for the full draft screen.
|
| 755 |
def draft_screen_html(state: RunState | None) -> str:
|
| 756 |
+
if state is None or state.duel is not None:
|
| 757 |
return ""
|
| 758 |
+
if not state.current_pack:
|
| 759 |
+
loading = loading_html(state.loading or "Forging draft pack")
|
| 760 |
+
return f"<div class='draft-board loading-board'>{loading}{deck_strip_html(state)}</div>"
|
| 761 |
if state.draft_step >= len(state.draft_order):
|
| 762 |
banner = "<div class='draft-banner'><h2>Deck complete</h2><p>The boss shuffles its deck…</p></div>"
|
| 763 |
else:
|
|
|
|
| 773 |
return f"<div class='draft-board'>{banner}<div class='draft-pack'>{cards}</div>{deck_strip_html(state)}</div>"
|
| 774 |
|
| 775 |
|
| 776 |
+
# Return loading HTML for asynchronous draft generation.
|
| 777 |
+
def loading_html(message: str) -> str:
|
| 778 |
+
return (
|
| 779 |
+
"<div class='draft-loading'>"
|
| 780 |
+
f"<div class='loading-title'>{escape_html(message)}</div>"
|
| 781 |
+
"<div class='loading-bar'><span></span></div>"
|
| 782 |
+
"<div class='loading-subtitle'>Your first draft pack is being prepared.</div>"
|
| 783 |
+
"</div>"
|
| 784 |
+
)
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
# Return one starter-deck card without draft click behavior.
|
| 788 |
+
def starter_card_html(card: Card) -> str:
|
| 789 |
+
return card_html(card, "starter-card")
|
| 790 |
+
|
| 791 |
+
|
| 792 |
# Return one draft card, fading the old pack out after a pick.
|
| 793 |
def draft_card_html(state: RunState, index: int, card: Card) -> str:
|
| 794 |
if state.pack_fading < 0:
|
|
|
|
| 830 |
def enemy_zone_html(state: RunState) -> str:
|
| 831 |
assert state.duel is not None
|
| 832 |
enemy = state.duel.enemy
|
| 833 |
+
thought = state.boss_thought or "The boss studies the board."
|
| 834 |
+
thinking = f"<div class='boss-thinking'>{escape_html(thought)}</div>" if state.boss_thinking else ""
|
| 835 |
return (
|
| 836 |
"<div class='zone enemy-zone'>"
|
| 837 |
f"<div class='zone-center'>{enemy_hand_html(enemy)}{hero_html(enemy, state.enemy_school, 'Boss', True, state.hp_flash[1])}{thinking}</div>"
|