Spaces:
Sleeping
Sleeping
Codex Claude Opus 4.8 commited on
Commit ·
39c3669
1
Parent(s): 3625c13
Polish demo: boss reveal screen, portraits, varied packs, paced draft, title copy
Browse files- Boss reveal screen before rules: full splash art + "Your Enemy" + villain name
and quote (The Hollow Warden / Specter-9 / The Necrolich); battle board shows
face-cropped player vs boss portraits.
- Draft pacing: minimum loading window per pick (uniform "forging" beat), prefetch
all branches so any pick is ready, reveal first pack the moment it is ready.
- Card variety: rotate per-slot effect focus per pack, and fall back to the slot
focus (not always deal) so name-rejected packs still show varied effects.
- Name filter bans the leaked word "primitive".
- Title screen: tighter cohesive layout + model-stack pitch copy.
- README: Build Small submission section, tags, model stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- README.md +33 -6
- app.py +168 -14
- assets/thumbs/anime.jpg +3 -0
- assets/thumbs/animeBoss.jpg +3 -0
- assets/thumbs/animeBoss_splash.jpg +3 -0
- assets/thumbs/animeEarth.jpg +3 -0
- assets/thumbs/animeFire.jpg +3 -0
- assets/thumbs/animeIce.jpg +3 -0
- assets/thumbs/cyberpunk.jpg +3 -0
- assets/thumbs/cyberpunkBoss.jpg +3 -0
- assets/thumbs/cyberpunkBoss_splash.jpg +3 -0
- assets/thumbs/cyberpunkEarth.jpg +3 -0
- assets/thumbs/cyberpunkFire.jpg +3 -0
- assets/thumbs/cyberpunkice.jpg +3 -0
- assets/thumbs/darkFantasy.jpg +3 -0
- assets/thumbs/darkFantasyBoss.jpg +3 -0
- assets/thumbs/darkFantasyBoss_splash.jpg +3 -0
- assets/thumbs/darkFantasyEarth.jpg +3 -0
- assets/thumbs/darkFantasyFire.jpg +3 -0
- assets/thumbs/darkFantasyIce.jpg +3 -0
- generator.py +13 -3
- tests/test_app.py +6 -5
- tests/test_ui.py +8 -6
- ui.py +71 -7
README.md
CHANGED
|
@@ -13,8 +13,13 @@ tags:
|
|
| 13 |
- thousand-token-wood
|
| 14 |
- off-brand
|
| 15 |
- tiny-titan
|
|
|
|
|
|
|
|
|
|
| 16 |
- minicpm
|
| 17 |
- nemotron
|
|
|
|
|
|
|
| 18 |
models:
|
| 19 |
- openbmb/MiniCPM-V-4
|
| 20 |
- nvidia/Nemotron-Mini-4B-Instruct
|
|
@@ -29,6 +34,26 @@ You name a challenger, choose a world, choose a school of magic, draft a 15-card
|
|
| 29 |
|
| 30 |
The engine keeps the game fair. The model invents the card identity; deterministic Python code prices and resolves the card mechanics.
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
## What Makes It AI-Native
|
| 33 |
|
| 34 |
- **MiniCPM authors draft cards.** It proposes card concepts, names, flavor text, and effect shapes for the current deck.
|
|
@@ -56,16 +81,18 @@ That split is the core design: AI provides surprise and taste, while the engine
|
|
| 56 |
|
| 57 |
## Model Stack
|
| 58 |
|
| 59 |
-
All listed models are under the hackathon's 32B parameter limit.
|
| 60 |
|
| 61 |
-
| Role | Default model | Use |
|
| 62 |
-
| --- | --- | --- |
|
| 63 |
-
| Card author | `openbmb/MiniCPM-V-4` | Draft pack text and card concepts |
|
| 64 |
-
| Boss
|
| 65 |
-
| Art | `stabilityai/sdxl-turbo` | Fast card illustration |
|
| 66 |
|
| 67 |
The Hugging Face Space entry point is [app_hf.py](app_hf.py). The Space is a thin client: MiniCPM (cards), Nemotron (boss), and SDXL-Turbo (art) run on dedicated Modal GPU endpoints (see [modal_app.py](modal_app.py)), which the Space calls over HTTP. This keeps heavy compute off the Space (free CPU hardware) and gives each model its own autoscaled GPU.
|
| 68 |
|
|
|
|
|
|
|
| 69 |
## Running Locally
|
| 70 |
|
| 71 |
Install dependencies:
|
|
|
|
| 13 |
- thousand-token-wood
|
| 14 |
- off-brand
|
| 15 |
- tiny-titan
|
| 16 |
+
- best-demo
|
| 17 |
+
- best-agent
|
| 18 |
+
- bonus-quest-champion
|
| 19 |
- minicpm
|
| 20 |
- nemotron
|
| 21 |
+
- modal
|
| 22 |
+
- codex
|
| 23 |
models:
|
| 24 |
- openbmb/MiniCPM-V-4
|
| 25 |
- nvidia/Nemotron-Mini-4B-Instruct
|
|
|
|
| 34 |
|
| 35 |
The engine keeps the game fair. The model invents the card identity; deterministic Python code prices and resolves the card mechanics.
|
| 36 |
|
| 37 |
+
## Build Small Submission
|
| 38 |
+
|
| 39 |
+
Track: **Thousand Token Wood**.
|
| 40 |
+
|
| 41 |
+
Targeted prizes and badges:
|
| 42 |
+
|
| 43 |
+
- **Best MiniCPM Build:** MiniCPM authors the generated draft cards.
|
| 44 |
+
- **Nemotron Hardware Prize:** Nemotron drives the autonomous boss player.
|
| 45 |
+
- **Best Use of Modal:** the submitted Space calls Modal GPU endpoints for model inference.
|
| 46 |
+
- **Best Use of Codex:** Codex was used during development, with the project connected through the repo/Space workflow.
|
| 47 |
+
- **Off Brand:** Tabras uses a custom Gradio interface rather than the default Gradio look.
|
| 48 |
+
- **Tiny Titan:** the submitted runtime uses models in the 4B-and-under class.
|
| 49 |
+
- **Best Agent:** the boss is an agentic Nemotron player that observes public duel state, selects playable card indexes through a constrained JSON action schema, and executes actions inside the deterministic game engine.
|
| 50 |
+
- **Best Demo:** demo video and social post links are listed below.
|
| 51 |
+
- **Bonus Quest Champion:** Tabras combines multiple sponsor criteria and bonus badges in one submission.
|
| 52 |
+
|
| 53 |
+
Demo video: **TODO: add public demo link before submission deadline.**
|
| 54 |
+
|
| 55 |
+
Social post: https://x.com/yewzoid/status/2066647997740691678?s=20
|
| 56 |
+
|
| 57 |
## What Makes It AI-Native
|
| 58 |
|
| 59 |
- **MiniCPM authors draft cards.** It proposes card concepts, names, flavor text, and effect shapes for the current deck.
|
|
|
|
| 81 |
|
| 82 |
## Model Stack
|
| 83 |
|
| 84 |
+
All listed models are under the hackathon's 32B parameter limit. The submitted configuration uses models in the 4B-and-under class for Tiny Titan consideration.
|
| 85 |
|
| 86 |
+
| Role | Default model | Size class | Use |
|
| 87 |
+
| --- | --- | --- | --- |
|
| 88 |
+
| Card author | `openbmb/MiniCPM-V-4` | 4B-and-under class | Draft pack text and card concepts |
|
| 89 |
+
| Boss agent | `nvidia/Nemotron-Mini-4B-Instruct` | 4B | Enemy play decisions |
|
| 90 |
+
| Art | `stabilityai/sdxl-turbo` | 4B-and-under class | Fast card illustration |
|
| 91 |
|
| 92 |
The Hugging Face Space entry point is [app_hf.py](app_hf.py). The Space is a thin client: MiniCPM (cards), Nemotron (boss), and SDXL-Turbo (art) run on dedicated Modal GPU endpoints (see [modal_app.py](modal_app.py)), which the Space calls over HTTP. This keeps heavy compute off the Space (free CPU hardware) and gives each model its own autoscaled GPU.
|
| 93 |
|
| 94 |
+
Tabras also runs locally. By default, [app.py](app.py) can launch without model servers and use deterministic fallback generation. For local AI, [launch_ai.py](launch_ai.py) starts a local MiniCPM llama.cpp server, and the runtime can use local Transformers, MLX, and Diffusers backends through environment variables.
|
| 95 |
+
|
| 96 |
## Running Locally
|
| 97 |
|
| 98 |
Install dependencies:
|
app.py
CHANGED
|
@@ -15,10 +15,12 @@ from ui import (
|
|
| 15 |
HAND_PANEL_COUNT,
|
| 16 |
RunState,
|
| 17 |
board_html,
|
|
|
|
| 18 |
choose_draft_card_loading_steps,
|
| 19 |
collect_ready_battle,
|
| 20 |
collect_ready_pack,
|
| 21 |
draft_screen_html,
|
|
|
|
| 22 |
log_html,
|
| 23 |
new_run_shell,
|
| 24 |
pass_turn_steps,
|
|
@@ -127,19 +129,33 @@ button.primary {
|
|
| 127 |
}
|
| 128 |
|
| 129 |
.tabras-title {
|
| 130 |
-
min-height: 70vh;
|
| 131 |
display: flex;
|
| 132 |
flex-direction: column;
|
| 133 |
justify-content: center;
|
| 134 |
align-items: center;
|
| 135 |
text-align: center;
|
|
|
|
|
|
|
| 136 |
}
|
| 137 |
.tabras-title h1 {
|
| 138 |
-
font-size:
|
| 139 |
-
margin: 0
|
| 140 |
color: #f6f0ff;
|
| 141 |
text-shadow: 0 0 30px rgba(160, 90, 255, 0.6), 0 3px 18px rgba(0, 0, 0, 0.65);
|
| 142 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
.gradio-container .setup-panel {
|
| 144 |
background: rgba(26, 20, 46, 0.82) !important;
|
| 145 |
border: 2px solid rgba(190, 160, 255, 0.3) !important;
|
|
@@ -436,6 +452,85 @@ button.primary {
|
|
| 436 |
font-size: 16px;
|
| 437 |
line-height: 1.45;
|
| 438 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
.rules-grid {
|
| 440 |
display: grid;
|
| 441 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
@@ -531,6 +626,10 @@ button.primary {
|
|
| 531 |
color: #f4d69b;
|
| 532 |
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
|
| 533 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
.hp-gem, .block-gem, .ward-gem {
|
| 535 |
position: absolute;
|
| 536 |
width: 42px;
|
|
@@ -852,7 +951,14 @@ def build_app() -> gr.Blocks:
|
|
| 852 |
screen_state = gr.State("title")
|
| 853 |
|
| 854 |
with gr.Group(visible=True, elem_id="title-screen") as title_group:
|
| 855 |
-
gr.HTML(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
play_now = gr.Button("Play Now", variant="primary", elem_id="play-now-btn")
|
| 857 |
|
| 858 |
with gr.Group(visible=False, elem_id="name-screen") as name_group:
|
|
@@ -871,6 +977,10 @@ def build_app() -> gr.Blocks:
|
|
| 871 |
gr.HTML("<div class='step-kicker'>Step 2 of 3</div><h2>Choose your background</h2>")
|
| 872 |
gr.HTML(background_selector_html())
|
| 873 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
with gr.Group(visible=False, elem_id="rules-screen") as rules_group:
|
| 875 |
rules_view = gr.HTML()
|
| 876 |
continue_rules = gr.Button("Start Draft", variant="primary", elem_id="start-draft-btn")
|
|
@@ -902,9 +1012,11 @@ def build_app() -> gr.Blocks:
|
|
| 902 |
name_group,
|
| 903 |
school_group,
|
| 904 |
background_group,
|
|
|
|
| 905 |
rules_group,
|
| 906 |
draft_group,
|
| 907 |
battle_group,
|
|
|
|
| 908 |
rules_view,
|
| 909 |
draft_view,
|
| 910 |
board_view,
|
|
@@ -919,6 +1031,7 @@ def build_app() -> gr.Blocks:
|
|
| 919 |
button.click(value_handler(choose_background, value), outputs=outputs)
|
| 920 |
for value, button in zip(("fire", "ice", "earth"), school_buttons):
|
| 921 |
button.click(value_handler(choose_school, value), inputs=[name, world_state], outputs=outputs)
|
|
|
|
| 922 |
continue_rules.click(show_draft_from_rules, inputs=[state], outputs=outputs)
|
| 923 |
for index, button in enumerate(draft_buttons):
|
| 924 |
button.click(indexed_handler(draft_pick, index), inputs=[state], outputs=outputs)
|
|
@@ -997,13 +1110,21 @@ def choose_school(school: str, name: str, world: str) -> list[object]:
|
|
| 997 |
return start_rules(name, school, world)
|
| 998 |
|
| 999 |
|
| 1000 |
-
#
|
|
|
|
| 1001 |
def start_rules(name: str, school: str, world: str) -> list[object]:
|
| 1002 |
client = card_client_from_env()
|
| 1003 |
art_client = art_client_from_env()
|
| 1004 |
run_state = new_run_shell(name, world, school_as_literal(school), seed=Random().getrandbits(32))
|
| 1005 |
run_state = queue_next_pack(run_state, client, art_client)
|
| 1006 |
-
return render(run_state, "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
|
| 1008 |
|
| 1009 |
# Move from rules to draft, showing deck loading if the first pack is not ready.
|
|
@@ -1044,9 +1165,9 @@ def end_player_turn(run_state: RunState | None):
|
|
| 1044 |
|
| 1045 |
# Refresh visible generated art without advancing the game.
|
| 1046 |
def refresh_screen(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]:
|
| 1047 |
-
if screen not in {"title", "name", "school", "background", "rules", "draft", "battle"}:
|
| 1048 |
screen = "title"
|
| 1049 |
-
if screen in {"rules", "draft"}:
|
| 1050 |
client, art_client = card_client_from_env(), art_client_from_env()
|
| 1051 |
run_state = collect_ready_pack(refresh_art(run_state), client, art_client)
|
| 1052 |
run_state = collect_ready_battle(run_state, client, art_client)
|
|
@@ -1087,9 +1208,11 @@ def render(run_state: RunState | None, screen: str, world: str = "Dark Fantasy")
|
|
| 1087 |
gr.update(visible=screen == "name"),
|
| 1088 |
gr.update(visible=screen == "school"),
|
| 1089 |
gr.update(visible=screen == "background"),
|
|
|
|
| 1090 |
gr.update(visible=screen == "rules"),
|
| 1091 |
gr.update(visible=screen == "draft"),
|
| 1092 |
gr.update(visible=screen == "battle"),
|
|
|
|
| 1093 |
rules_html(run_state),
|
| 1094 |
draft_screen_html(run_state),
|
| 1095 |
board_html(run_state),
|
|
@@ -1177,18 +1300,49 @@ def selector_card(button_id: str, label: str, copy: str, image: str, dark: str,
|
|
| 1177 |
|
| 1178 |
|
| 1179 |
# Return the dedicated rules screen while deck generation runs.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1180 |
def rules_html(run_state: RunState | None) -> str:
|
| 1181 |
if run_state is None:
|
| 1182 |
return ""
|
| 1183 |
return (
|
| 1184 |
"<div class='rules-screen'><div class='rules-card'>"
|
| 1185 |
-
"<h1>Rules</h1>"
|
| 1186 |
-
"<p>Build a 15-card deck, then duel the boss across paired rounds.</p>"
|
| 1187 |
"<div class='rules-grid'>"
|
| 1188 |
-
"<div class='rule-tile'><b>
|
| 1189 |
-
"<div class='rule-tile'><b>
|
| 1190 |
-
"<div class='rule-tile'><b>
|
| 1191 |
-
"<div class='rule-tile'><b>
|
|
|
|
|
|
|
| 1192 |
"</div>"
|
| 1193 |
"</div></div>"
|
| 1194 |
)
|
|
|
|
| 15 |
HAND_PANEL_COUNT,
|
| 16 |
RunState,
|
| 17 |
board_html,
|
| 18 |
+
boss_splash_uri,
|
| 19 |
choose_draft_card_loading_steps,
|
| 20 |
collect_ready_battle,
|
| 21 |
collect_ready_pack,
|
| 22 |
draft_screen_html,
|
| 23 |
+
escape_html,
|
| 24 |
log_html,
|
| 25 |
new_run_shell,
|
| 26 |
pass_turn_steps,
|
|
|
|
| 129 |
}
|
| 130 |
|
| 131 |
.tabras-title {
|
|
|
|
| 132 |
display: flex;
|
| 133 |
flex-direction: column;
|
| 134 |
justify-content: center;
|
| 135 |
align-items: center;
|
| 136 |
text-align: center;
|
| 137 |
+
gap: 26px;
|
| 138 |
+
padding: 18vh 20px 0;
|
| 139 |
}
|
| 140 |
.tabras-title h1 {
|
| 141 |
+
font-size: 92px;
|
| 142 |
+
margin: 0;
|
| 143 |
color: #f6f0ff;
|
| 144 |
text-shadow: 0 0 30px rgba(160, 90, 255, 0.6), 0 3px 18px rgba(0, 0, 0, 0.65);
|
| 145 |
}
|
| 146 |
+
.tabras-sub {
|
| 147 |
+
display: flex;
|
| 148 |
+
flex-direction: column;
|
| 149 |
+
gap: 10px;
|
| 150 |
+
color: #efe6ff;
|
| 151 |
+
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8);
|
| 152 |
+
}
|
| 153 |
+
.tabras-sub .subline {
|
| 154 |
+
font-size: 21px;
|
| 155 |
+
line-height: 1.4;
|
| 156 |
+
white-space: nowrap;
|
| 157 |
+
}
|
| 158 |
+
#play-now-btn { margin-top: 26px !important; }
|
| 159 |
.gradio-container .setup-panel {
|
| 160 |
background: rgba(26, 20, 46, 0.82) !important;
|
| 161 |
border: 2px solid rgba(190, 160, 255, 0.3) !important;
|
|
|
|
| 452 |
font-size: 16px;
|
| 453 |
line-height: 1.45;
|
| 454 |
}
|
| 455 |
+
.reveal-screen {
|
| 456 |
+
min-height: 64vh;
|
| 457 |
+
display: flex;
|
| 458 |
+
flex-direction: column;
|
| 459 |
+
align-items: center;
|
| 460 |
+
justify-content: center;
|
| 461 |
+
gap: 12px;
|
| 462 |
+
padding-top: 12px;
|
| 463 |
+
text-align: center;
|
| 464 |
+
animation: reveal-in 0.6s ease backwards;
|
| 465 |
+
}
|
| 466 |
+
#reveal-next-btn {
|
| 467 |
+
width: auto !important;
|
| 468 |
+
min-width: 150px !important;
|
| 469 |
+
max-width: 200px !important;
|
| 470 |
+
align-self: center !important;
|
| 471 |
+
margin: 4px auto 0 !important;
|
| 472 |
+
}
|
| 473 |
+
#reveal-next-btn button {
|
| 474 |
+
width: auto !important;
|
| 475 |
+
min-width: 150px !important;
|
| 476 |
+
padding: 9px 26px !important;
|
| 477 |
+
}
|
| 478 |
+
@keyframes reveal-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } }
|
| 479 |
+
.reveal-kicker {
|
| 480 |
+
font-size: 15px;
|
| 481 |
+
font-weight: 900;
|
| 482 |
+
letter-spacing: 0.22em;
|
| 483 |
+
text-transform: uppercase;
|
| 484 |
+
color: #f06bff;
|
| 485 |
+
text-shadow: 0 0 18px rgba(232, 91, 255, 0.55);
|
| 486 |
+
}
|
| 487 |
+
.reveal-art {
|
| 488 |
+
width: min(440px, 86vw);
|
| 489 |
+
height: min(440px, 60vh);
|
| 490 |
+
border-radius: 16px;
|
| 491 |
+
border: 3px solid #b14ad0;
|
| 492 |
+
background-size: cover;
|
| 493 |
+
background-position: center top;
|
| 494 |
+
background-color: #160e26;
|
| 495 |
+
box-shadow: 0 0 48px rgba(177, 74, 208, 0.45), 0 24px 60px rgba(0, 0, 0, 0.6);
|
| 496 |
+
}
|
| 497 |
+
.reveal-name {
|
| 498 |
+
font-size: 40px;
|
| 499 |
+
font-weight: 900;
|
| 500 |
+
color: #f6f0ff;
|
| 501 |
+
letter-spacing: 0.04em;
|
| 502 |
+
text-shadow: 0 0 26px rgba(232, 91, 255, 0.5), 0 3px 12px rgba(0, 0, 0, 0.7);
|
| 503 |
+
}
|
| 504 |
+
.reveal-quote {
|
| 505 |
+
font-size: 19px;
|
| 506 |
+
font-style: italic;
|
| 507 |
+
color: #d9c4ee;
|
| 508 |
+
max-width: 540px;
|
| 509 |
+
}
|
| 510 |
+
.versus {
|
| 511 |
+
display: flex;
|
| 512 |
+
align-items: center;
|
| 513 |
+
justify-content: center;
|
| 514 |
+
gap: 28px;
|
| 515 |
+
margin: 6px 0 14px;
|
| 516 |
+
}
|
| 517 |
+
.versus-side { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
| 518 |
+
.versus-face {
|
| 519 |
+
width: 132px;
|
| 520 |
+
height: 132px;
|
| 521 |
+
border-radius: 50% 50% 46% 46%;
|
| 522 |
+
border: 4px solid #b08a4f;
|
| 523 |
+
background-size: cover;
|
| 524 |
+
background-position: center top;
|
| 525 |
+
background-color: #1b1430;
|
| 526 |
+
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55);
|
| 527 |
+
}
|
| 528 |
+
.versus-side:last-child .versus-face { border-color: #b14ad0; box-shadow: 0 0 26px rgba(232, 91, 255, 0.4), 0 12px 30px rgba(0, 0, 0, 0.55); }
|
| 529 |
+
.versus-face-text { display: flex; align-items: center; justify-content: center; color: #f4d69b; font-weight: 900; font-size: 22px; }
|
| 530 |
+
.versus-name { font-weight: 800; color: #f6f0ff; font-size: 18px; }
|
| 531 |
+
.versus-tag { font-size: 12px; color: #b9a8e0; letter-spacing: 0.04em; }
|
| 532 |
+
.versus-vs { font-size: 30px; font-weight: 900; color: #ffd35c; text-shadow: 0 0 16px rgba(255, 211, 92, 0.5); }
|
| 533 |
+
.villain-story { text-align: center; color: #d9d0f0; font-style: italic; max-width: 540px; margin: 0 auto; }
|
| 534 |
.rules-grid {
|
| 535 |
display: grid;
|
| 536 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
| 626 |
color: #f4d69b;
|
| 627 |
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
|
| 628 |
}
|
| 629 |
+
.hero-face.portrait {
|
| 630 |
+
background-size: cover;
|
| 631 |
+
background-position: center top;
|
| 632 |
+
}
|
| 633 |
.hp-gem, .block-gem, .ward-gem {
|
| 634 |
position: absolute;
|
| 635 |
width: 42px;
|
|
|
|
| 951 |
screen_state = gr.State("title")
|
| 952 |
|
| 953 |
with gr.Group(visible=True, elem_id="title-screen") as title_group:
|
| 954 |
+
gr.HTML(
|
| 955 |
+
"<div class='tabras-title'><h1>Tabras</h1>"
|
| 956 |
+
"<div class='tabras-sub'>"
|
| 957 |
+
"<div class='subline'>You're a spellcaster fighting an evil wizard.</div>"
|
| 958 |
+
"<div class='subline'>Draft your spellbook by selecting nine cards that are authored by MiniCPM and drawn by SDXL-Turbo.</div>"
|
| 959 |
+
"<div class='subline'>Are you powerful enough to destroy the Nemotron-enhanced wizard?</div>"
|
| 960 |
+
"</div></div>"
|
| 961 |
+
)
|
| 962 |
play_now = gr.Button("Play Now", variant="primary", elem_id="play-now-btn")
|
| 963 |
|
| 964 |
with gr.Group(visible=False, elem_id="name-screen") as name_group:
|
|
|
|
| 977 |
gr.HTML("<div class='step-kicker'>Step 2 of 3</div><h2>Choose your background</h2>")
|
| 978 |
gr.HTML(background_selector_html())
|
| 979 |
|
| 980 |
+
with gr.Group(visible=False, elem_id="reveal-screen") as reveal_group:
|
| 981 |
+
reveal_view = gr.HTML()
|
| 982 |
+
reveal_next = gr.Button("Continue", variant="primary", elem_id="reveal-next-btn")
|
| 983 |
+
|
| 984 |
with gr.Group(visible=False, elem_id="rules-screen") as rules_group:
|
| 985 |
rules_view = gr.HTML()
|
| 986 |
continue_rules = gr.Button("Start Draft", variant="primary", elem_id="start-draft-btn")
|
|
|
|
| 1012 |
name_group,
|
| 1013 |
school_group,
|
| 1014 |
background_group,
|
| 1015 |
+
reveal_group,
|
| 1016 |
rules_group,
|
| 1017 |
draft_group,
|
| 1018 |
battle_group,
|
| 1019 |
+
reveal_view,
|
| 1020 |
rules_view,
|
| 1021 |
draft_view,
|
| 1022 |
board_view,
|
|
|
|
| 1031 |
button.click(value_handler(choose_background, value), outputs=outputs)
|
| 1032 |
for value, button in zip(("fire", "ice", "earth"), school_buttons):
|
| 1033 |
button.click(value_handler(choose_school, value), inputs=[name, world_state], outputs=outputs)
|
| 1034 |
+
reveal_next.click(show_rules, inputs=[state], outputs=outputs)
|
| 1035 |
continue_rules.click(show_draft_from_rules, inputs=[state], outputs=outputs)
|
| 1036 |
for index, button in enumerate(draft_buttons):
|
| 1037 |
button.click(indexed_handler(draft_pick, index), inputs=[state], outputs=outputs)
|
|
|
|
| 1110 |
return start_rules(name, school, world)
|
| 1111 |
|
| 1112 |
|
| 1113 |
+
# Reveal the boss, then start deck generation in the background. The reveal +
|
| 1114 |
+
# rules screens give the first pack time to forge before the draft.
|
| 1115 |
def start_rules(name: str, school: str, world: str) -> list[object]:
|
| 1116 |
client = card_client_from_env()
|
| 1117 |
art_client = art_client_from_env()
|
| 1118 |
run_state = new_run_shell(name, world, school_as_literal(school), seed=Random().getrandbits(32))
|
| 1119 |
run_state = queue_next_pack(run_state, client, art_client)
|
| 1120 |
+
return render(run_state, "reveal")
|
| 1121 |
+
|
| 1122 |
+
|
| 1123 |
+
# Move from the boss reveal to the rules screen.
|
| 1124 |
+
def show_rules(run_state: RunState | None) -> list[object]:
|
| 1125 |
+
if run_state is None:
|
| 1126 |
+
return render(None, "name")
|
| 1127 |
+
return render(refresh_art(run_state), "rules")
|
| 1128 |
|
| 1129 |
|
| 1130 |
# Move from rules to draft, showing deck loading if the first pack is not ready.
|
|
|
|
| 1165 |
|
| 1166 |
# Refresh visible generated art without advancing the game.
|
| 1167 |
def refresh_screen(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]:
|
| 1168 |
+
if screen not in {"title", "name", "school", "background", "reveal", "rules", "draft", "battle"}:
|
| 1169 |
screen = "title"
|
| 1170 |
+
if screen in {"reveal", "rules", "draft"}:
|
| 1171 |
client, art_client = card_client_from_env(), art_client_from_env()
|
| 1172 |
run_state = collect_ready_pack(refresh_art(run_state), client, art_client)
|
| 1173 |
run_state = collect_ready_battle(run_state, client, art_client)
|
|
|
|
| 1208 |
gr.update(visible=screen == "name"),
|
| 1209 |
gr.update(visible=screen == "school"),
|
| 1210 |
gr.update(visible=screen == "background"),
|
| 1211 |
+
gr.update(visible=screen == "reveal"),
|
| 1212 |
gr.update(visible=screen == "rules"),
|
| 1213 |
gr.update(visible=screen == "draft"),
|
| 1214 |
gr.update(visible=screen == "battle"),
|
| 1215 |
+
reveal_html(run_state),
|
| 1216 |
rules_html(run_state),
|
| 1217 |
draft_screen_html(run_state),
|
| 1218 |
board_html(run_state),
|
|
|
|
| 1300 |
|
| 1301 |
|
| 1302 |
# Return the dedicated rules screen while deck generation runs.
|
| 1303 |
+
# Villain name + in-character quote per world, shown on the boss reveal screen.
|
| 1304 |
+
VILLAINS: dict[str, tuple[str, str]] = {
|
| 1305 |
+
"dark fantasy": ("The Hollow Warden", "“The keep has not opened its gates in an age. It will not open for you.”"),
|
| 1306 |
+
"cyberpunk": ("Specter-9", "“Your magic is a legacy format. I am the patch that deprecates it.”"),
|
| 1307 |
+
"anime": ("The Necrolich", "“I have waited aeons to rule this world. You will not stop me.”"),
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
# Return the dramatic boss reveal: full art, villain name, and a quote.
|
| 1312 |
+
def reveal_html(run_state: RunState | None) -> str:
|
| 1313 |
+
if run_state is None:
|
| 1314 |
+
return ""
|
| 1315 |
+
world = run_state.world
|
| 1316 |
+
villain_name, quote = VILLAINS.get(world.strip().lower(), VILLAINS["dark fantasy"])
|
| 1317 |
+
splash = boss_splash_uri(world)
|
| 1318 |
+
art = (
|
| 1319 |
+
f"<div class='reveal-art' style=\"background-image:url('{splash}')\"></div>"
|
| 1320 |
+
if splash
|
| 1321 |
+
else "<div class='reveal-art reveal-art-empty'></div>"
|
| 1322 |
+
)
|
| 1323 |
+
return (
|
| 1324 |
+
"<div class='reveal-screen'>"
|
| 1325 |
+
"<div class='reveal-kicker'>Your Enemy</div>"
|
| 1326 |
+
f"{art}"
|
| 1327 |
+
f"<div class='reveal-name'>{escape_html(villain_name)}</div>"
|
| 1328 |
+
f"<div class='reveal-quote'>{escape_html(quote)}</div>"
|
| 1329 |
+
"</div>"
|
| 1330 |
+
)
|
| 1331 |
+
|
| 1332 |
+
|
| 1333 |
def rules_html(run_state: RunState | None) -> str:
|
| 1334 |
if run_state is None:
|
| 1335 |
return ""
|
| 1336 |
return (
|
| 1337 |
"<div class='rules-screen'><div class='rules-card'>"
|
| 1338 |
+
"<h1>The Rules</h1>"
|
|
|
|
| 1339 |
"<div class='rules-grid'>"
|
| 1340 |
+
"<div class='rule-tile'><b>Each round</b><span>A coin flip decides who acts first — knowing the order is a weapon.</span></div>"
|
| 1341 |
+
"<div class='rule-tile'><b>Energy</b><span>Start at 1, ramp to 5. It refills every round and never carries over.</span></div>"
|
| 1342 |
+
"<div class='rule-tile'><b>Block</b><span>Stops incoming damage until your next turn, then fades.</span></div>"
|
| 1343 |
+
"<div class='rule-tile'><b>Ward</b><span>A persistent shield that absorbs one decisive hit, whenever it lands.</span></div>"
|
| 1344 |
+
"<div class='rule-tile'><b>Draft</b><span>Pick one card from each pack to build a 15-card deck.</span></div>"
|
| 1345 |
+
"<div class='rule-tile'><b>Win</b><span>Reduce the boss to 0 HP across the duel.</span></div>"
|
| 1346 |
"</div>"
|
| 1347 |
"</div></div>"
|
| 1348 |
)
|
assets/thumbs/anime.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/animeBoss.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/animeBoss_splash.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/animeEarth.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/animeFire.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/animeIce.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/cyberpunk.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/cyberpunkBoss.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/cyberpunkBoss_splash.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/cyberpunkEarth.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/cyberpunkFire.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/cyberpunkice.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/darkFantasy.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/darkFantasyBoss.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/darkFantasyBoss_splash.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/darkFantasyEarth.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/darkFantasyFire.jpg
ADDED
|
|
Git LFS Details
|
assets/thumbs/darkFantasyIce.jpg
ADDED
|
|
Git LFS Details
|
generator.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from dataclasses import dataclass
|
| 2 |
import json
|
|
|
|
| 3 |
import subprocess
|
| 4 |
import tempfile
|
| 5 |
from collections import Counter
|
|
@@ -301,8 +302,9 @@ NAME_BAN = (
|
|
| 301 |
"anime",
|
| 302 |
"fantasy",
|
| 303 |
"spell",
|
|
|
|
| 304 |
)
|
| 305 |
-
NAME_STEM_BAN = ("burn", "fast", "flam", "pressur")
|
| 306 |
# Deep per-school name pools so a duplicate is replaced with a fresh evocative
|
| 307 |
# name (drawn in order against the run's used-name set) instead of a number.
|
| 308 |
FALLBACK_NAMES: dict[str, tuple[str, ...]] = {
|
|
@@ -411,7 +413,12 @@ def generate_llamacpp_card(chat: ChatCompleter, payload: dict[str, Any], pack_ca
|
|
| 411 |
if card_acceptable(card, need, payload):
|
| 412 |
return card
|
| 413 |
retry = parse_llamacpp_card_text(chat.complete(card_system_prompt(), llamacpp_retry_prompt(payload, need)), payload, pack_cards, discard_bad_text=False)
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
| 416 |
|
| 417 |
# Return whether a generated card meets its need with a non-generic name.
|
|
@@ -818,7 +825,10 @@ def pack_focuses(payload: dict[str, Any], size: int) -> list[str]:
|
|
| 818 |
allowed = list(payload.get("allowed_primitives", []))
|
| 819 |
if not allowed:
|
| 820 |
return [""] * size
|
| 821 |
-
|
|
|
|
|
|
|
|
|
|
| 822 |
|
| 823 |
|
| 824 |
# Give every pick in a parallel-built pack a distinct name from the school pool.
|
|
|
|
| 1 |
from dataclasses import dataclass
|
| 2 |
import json
|
| 3 |
+
import random
|
| 4 |
import subprocess
|
| 5 |
import tempfile
|
| 6 |
from collections import Counter
|
|
|
|
| 302 |
"anime",
|
| 303 |
"fantasy",
|
| 304 |
"spell",
|
| 305 |
+
"primitive",
|
| 306 |
)
|
| 307 |
+
NAME_STEM_BAN = ("burn", "fast", "flam", "pressur", "primitiv")
|
| 308 |
# Deep per-school name pools so a duplicate is replaced with a fresh evocative
|
| 309 |
# name (drawn in order against the run's used-name set) instead of a number.
|
| 310 |
FALLBACK_NAMES: dict[str, tuple[str, ...]] = {
|
|
|
|
| 413 |
if card_acceptable(card, need, payload):
|
| 414 |
return card
|
| 415 |
retry = parse_llamacpp_card_text(chat.complete(card_system_prompt(), llamacpp_retry_prompt(payload, need)), payload, pack_cards, discard_bad_text=False)
|
| 416 |
+
if card_acceptable(retry, need, payload):
|
| 417 |
+
return retry
|
| 418 |
+
# Fall back to this slot's focus primitive (not always deal) so a pack whose
|
| 419 |
+
# names get rejected still shows varied effects instead of three identical cards.
|
| 420 |
+
fallback_need = f"explore primitive_id {focus}" if focus else need
|
| 421 |
+
return repair_llamacpp_card(fallback_raw_for_need(fallback_need, payload), payload, pack_cards)
|
| 422 |
|
| 423 |
|
| 424 |
# Return whether a generated card meets its need with a non-generic name.
|
|
|
|
| 825 |
allowed = list(payload.get("allowed_primitives", []))
|
| 826 |
if not allowed:
|
| 827 |
return [""] * size
|
| 828 |
+
# Rotate the focus window by a random offset each pack so packs differ from
|
| 829 |
+
# one another (not always deal/burn/bomb), while staying distinct within a pack.
|
| 830 |
+
start = random.randrange(len(allowed))
|
| 831 |
+
return [str(allowed[(start + index) % len(allowed)]) for index in range(size)]
|
| 832 |
|
| 833 |
|
| 834 |
# Give every pick in a parallel-built pack a distinct name from the school pool.
|
tests/test_app.py
CHANGED
|
@@ -104,12 +104,12 @@ def test_start_rules_queues_deck_generation(monkeypatch) -> None:
|
|
| 104 |
output = start_rules("Ada", "ice", "Anime")
|
| 105 |
state = output[0]
|
| 106 |
|
| 107 |
-
|
|
|
|
| 108 |
assert state.player_name == "Ada"
|
| 109 |
assert state.school == "ice"
|
| 110 |
-
assert "
|
| 111 |
-
assert "
|
| 112 |
-
assert "First draft pack ready" not in output[9]
|
| 113 |
|
| 114 |
forge.drain()
|
| 115 |
refresh_screen(state, "rules")
|
|
@@ -118,6 +118,7 @@ def test_start_rules_queues_deck_generation(monkeypatch) -> None:
|
|
| 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()
|
|
@@ -144,4 +145,4 @@ def test_skip_rules_shows_loading_deck(monkeypatch) -> None:
|
|
| 144 |
|
| 145 |
assert output[1] == "draft"
|
| 146 |
assert output[0].loading == "Loading your deck"
|
| 147 |
-
assert "Loading your deck" in output[
|
|
|
|
| 104 |
output = start_rules("Ada", "ice", "Anime")
|
| 105 |
state = output[0]
|
| 106 |
|
| 107 |
+
# start_rules now lands on the boss reveal screen (rules follow on Continue).
|
| 108 |
+
assert output[1] == "reveal"
|
| 109 |
assert state.player_name == "Ada"
|
| 110 |
assert state.school == "ice"
|
| 111 |
+
assert "reveal-screen" in output[10]
|
| 112 |
+
assert "The Necrolich" in output[10]
|
|
|
|
| 113 |
|
| 114 |
forge.drain()
|
| 115 |
refresh_screen(state, "rules")
|
|
|
|
| 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 |
+
monkeypatch.setattr("ui.MIN_DRAFT_LOADING_SECONDS", 0.0)
|
| 122 |
forge.reset()
|
| 123 |
client = AppPackClient()
|
| 124 |
art_client = AppArtClient()
|
|
|
|
| 145 |
|
| 146 |
assert output[1] == "draft"
|
| 147 |
assert output[0].loading == "Loading your deck"
|
| 148 |
+
assert "Loading your deck" in output[12]
|
tests/test_ui.py
CHANGED
|
@@ -102,7 +102,8 @@ def test_queue_next_pack_shows_loading() -> None:
|
|
| 102 |
|
| 103 |
|
| 104 |
# Verify a ready background pack replaces the loading state.
|
| 105 |
-
def test_collect_ready_pack_installs_pack() -> None:
|
|
|
|
| 106 |
forge.reset()
|
| 107 |
client = ForgePackClient()
|
| 108 |
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client)
|
|
@@ -116,7 +117,8 @@ def test_collect_ready_pack_installs_pack() -> None:
|
|
| 116 |
|
| 117 |
|
| 118 |
# Verify draft packs reveal text before queued art settles.
|
| 119 |
-
def test_collect_ready_pack_reveals_before_art() -> None:
|
|
|
|
| 120 |
forge.reset()
|
| 121 |
client = ForgePackClient()
|
| 122 |
art_client = GatedArtClient()
|
|
@@ -487,9 +489,9 @@ def test_new_run_warms_forge(monkeypatch) -> None:
|
|
| 487 |
client = ForgePackClient()
|
| 488 |
state = new_run("Ada", "anime", "ice", client, seed=3)
|
| 489 |
forge.drain()
|
| 490 |
-
# pack 1 +
|
| 491 |
-
# so it
|
| 492 |
-
assert client.calls ==
|
| 493 |
picked = choose_draft_card(state, 0, client)
|
| 494 |
assert all(card.name.startswith("Forged") for card in picked.current_pack)
|
| 495 |
|
|
@@ -499,7 +501,7 @@ def test_prefetch_pack_limit_env(monkeypatch) -> None:
|
|
| 499 |
monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "0")
|
| 500 |
assert prefetch_pack_limit() == 0
|
| 501 |
monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "bad")
|
| 502 |
-
assert prefetch_pack_limit() ==
|
| 503 |
|
| 504 |
|
| 505 |
# Verify battle start consumes the pre-forged boss deck.
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
# Verify a ready background pack replaces the loading state.
|
| 105 |
+
def test_collect_ready_pack_installs_pack(monkeypatch) -> None:
|
| 106 |
+
monkeypatch.setattr("ui.MIN_DRAFT_LOADING_SECONDS", 0.0)
|
| 107 |
forge.reset()
|
| 108 |
client = ForgePackClient()
|
| 109 |
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client)
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
# Verify draft packs reveal text before queued art settles.
|
| 120 |
+
def test_collect_ready_pack_reveals_before_art(monkeypatch) -> None:
|
| 121 |
+
monkeypatch.setattr("ui.MIN_DRAFT_LOADING_SECONDS", 0.0)
|
| 122 |
forge.reset()
|
| 123 |
client = ForgePackClient()
|
| 124 |
art_client = GatedArtClient()
|
|
|
|
| 489 |
client = ForgePackClient()
|
| 490 |
state = new_run("Ada", "anime", "ice", client, seed=3)
|
| 491 |
forge.drain()
|
| 492 |
+
# pack 1 + three speculative branch packs (one per visible card); the boss deck
|
| 493 |
+
# is deferred (draft_step >= 2) so it never competes with the first pack.
|
| 494 |
+
assert client.calls == 4
|
| 495 |
picked = choose_draft_card(state, 0, client)
|
| 496 |
assert all(card.name.startswith("Forged") for card in picked.current_pack)
|
| 497 |
|
|
|
|
| 501 |
monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "0")
|
| 502 |
assert prefetch_pack_limit() == 0
|
| 503 |
monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "bad")
|
| 504 |
+
assert prefetch_pack_limit() == CARD_PANEL_COUNT
|
| 505 |
|
| 506 |
|
| 507 |
# Verify battle start consumes the pre-forged boss deck.
|
ui.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
|
|
|
|
|
| 1 |
from collections.abc import Callable, Iterator
|
| 2 |
from dataclasses import dataclass, replace
|
| 3 |
import os
|
|
|
|
| 4 |
from random import Random
|
| 5 |
import sys
|
| 6 |
from typing import Sequence
|
|
@@ -19,6 +22,12 @@ from primitives import School
|
|
| 19 |
CARD_PANEL_COUNT = 3
|
| 20 |
HAND_PANEL_COUNT = 10
|
| 21 |
THEME = "dark fantasy"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
@dataclass(frozen=True)
|
|
@@ -47,6 +56,7 @@ class RunState:
|
|
| 47 |
boss_thought: str = ""
|
| 48 |
pack_fading: int = -1
|
| 49 |
loading: str = ""
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
Steps = Iterator[RunState]
|
|
@@ -129,7 +139,7 @@ def queue_next_pack(
|
|
| 129 |
return state
|
| 130 |
cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
|
| 131 |
forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
|
| 132 |
-
return replace(state, current_pack=(), loading=loading_message(state))
|
| 133 |
|
| 134 |
|
| 135 |
# Attach a queued draft pack once its background job finishes.
|
|
@@ -145,6 +155,11 @@ def collect_ready_pack(
|
|
| 145 |
if pack is None:
|
| 146 |
forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
|
| 147 |
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
pack = dedupe_pack_against_deck(pack, state.player_deck)
|
| 149 |
warm_card_art(art_client, pack)
|
| 150 |
pack = collect_ready_cards(pack)
|
|
@@ -298,9 +313,11 @@ def prefetch_next_packs(state: RunState, client: CardPackClient | None, art_clie
|
|
| 298 |
# Return how many possible next draft branches to forge speculatively.
|
| 299 |
def prefetch_pack_limit() -> int:
|
| 300 |
try:
|
| 301 |
-
|
|
|
|
|
|
|
| 302 |
except ValueError:
|
| 303 |
-
return
|
| 304 |
|
| 305 |
|
| 306 |
# Return the forge key for the boss deck of one run.
|
|
@@ -913,7 +930,7 @@ def enemy_zone_html(state: RunState) -> str:
|
|
| 913 |
thinking = f"<div class='boss-thinking'>{escape_html(thought)}</div>" if state.boss_thinking else ""
|
| 914 |
return (
|
| 915 |
"<div class='zone enemy-zone'>"
|
| 916 |
-
f"<div class='zone-center'>{enemy_hand_html(enemy)}{hero_html(enemy, state.enemy_school, 'Boss', True, state.hp_flash[1])}{thinking}</div>"
|
| 917 |
f"{piles_html(enemy)}"
|
| 918 |
"</div>"
|
| 919 |
)
|
|
@@ -926,7 +943,7 @@ def player_zone_html(state: RunState) -> str:
|
|
| 926 |
return (
|
| 927 |
"<div class='zone player-zone'>"
|
| 928 |
"<div class='zone-center'>"
|
| 929 |
-
f"{hero_html(player, state.school, state.player_name, False, state.hp_flash[0])}"
|
| 930 |
f"{mana_html(player, state.duel.round_number)}"
|
| 931 |
f"{hand_fan_html(player)}"
|
| 932 |
"</div>"
|
|
@@ -988,15 +1005,62 @@ def round_splash_html(state: RunState) -> str:
|
|
| 988 |
)
|
| 989 |
|
| 990 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
# Return a hero portrait with HP gem, defenses, status chips, and damage pop.
|
| 992 |
-
def hero_html(player: PlayerState, school: School, title: str, enemy: bool, damage: int = 0) -> str:
|
| 993 |
block = f"<div class='block-gem'>{player.block}</div>" if player.block else ""
|
| 994 |
ward = f"<div class='ward-gem'>{player.ward}</div>" if player.ward else ""
|
| 995 |
pop = f"<div class='dmg-pop'>-{damage}</div>" if damage else ""
|
| 996 |
hit = " hit" if damage else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 997 |
return (
|
| 998 |
f"<div class='hero {'enemy' if enemy else 'you'}'>"
|
| 999 |
-
f"<div class='hero-frame{hit}'>
|
| 1000 |
f"<div class='hp-gem'>{player.hp}</div>{block}{ward}{pop}</div>"
|
| 1001 |
f"<div class='hero-name'>{escape_html(title)}</div>"
|
| 1002 |
f"{status_chips_html(player)}"
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import time
|
| 3 |
from collections.abc import Callable, Iterator
|
| 4 |
from dataclasses import dataclass, replace
|
| 5 |
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
from random import Random
|
| 8 |
import sys
|
| 9 |
from typing import Sequence
|
|
|
|
| 22 |
CARD_PANEL_COUNT = 3
|
| 23 |
HAND_PANEL_COUNT = 10
|
| 24 |
THEME = "dark fantasy"
|
| 25 |
+
# Minimum draft loading-screen window: every pick shows the same brief "forging"
|
| 26 |
+
# beat, which also absorbs any pack that is not prefetched yet. Tunable via env.
|
| 27 |
+
try:
|
| 28 |
+
MIN_DRAFT_LOADING_SECONDS = float(os.environ.get("TABRAS_MIN_DRAFT_LOADING", "2.0"))
|
| 29 |
+
except ValueError:
|
| 30 |
+
MIN_DRAFT_LOADING_SECONDS = 2.0
|
| 31 |
|
| 32 |
|
| 33 |
@dataclass(frozen=True)
|
|
|
|
| 56 |
boss_thought: str = ""
|
| 57 |
pack_fading: int = -1
|
| 58 |
loading: str = ""
|
| 59 |
+
loading_since: float = 0.0
|
| 60 |
|
| 61 |
|
| 62 |
Steps = Iterator[RunState]
|
|
|
|
| 139 |
return state
|
| 140 |
cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
|
| 141 |
forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
|
| 142 |
+
return replace(state, current_pack=(), loading=loading_message(state), loading_since=time.monotonic())
|
| 143 |
|
| 144 |
|
| 145 |
# Attach a queued draft pack once its background job finishes.
|
|
|
|
| 155 |
if pack is None:
|
| 156 |
forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
|
| 157 |
return state
|
| 158 |
+
# Hold the loading beat for a minimum window so every draft transition looks
|
| 159 |
+
# the same (a deliberate "forging" pause) instead of some snapping in instantly
|
| 160 |
+
# and some lagging; the prefetched pack is already done well within it.
|
| 161 |
+
if state.loading_since and time.monotonic() - state.loading_since < MIN_DRAFT_LOADING_SECONDS:
|
| 162 |
+
return state
|
| 163 |
pack = dedupe_pack_against_deck(pack, state.player_deck)
|
| 164 |
warm_card_art(art_client, pack)
|
| 165 |
pack = collect_ready_cards(pack)
|
|
|
|
| 313 |
# Return how many possible next draft branches to forge speculatively.
|
| 314 |
def prefetch_pack_limit() -> int:
|
| 315 |
try:
|
| 316 |
+
# Prefetch every branch (one per visible card) so whichever card the
|
| 317 |
+
# player picks, its next pack is already forging in the background.
|
| 318 |
+
return max(0, int(os.environ.get("TABRAS_PREFETCH_PACKS", str(CARD_PANEL_COUNT))))
|
| 319 |
except ValueError:
|
| 320 |
+
return CARD_PANEL_COUNT
|
| 321 |
|
| 322 |
|
| 323 |
# Return the forge key for the boss deck of one run.
|
|
|
|
| 930 |
thinking = f"<div class='boss-thinking'>{escape_html(thought)}</div>" if state.boss_thinking else ""
|
| 931 |
return (
|
| 932 |
"<div class='zone enemy-zone'>"
|
| 933 |
+
f"<div class='zone-center'>{enemy_hand_html(enemy)}{hero_html(enemy, state.enemy_school, 'Boss', True, state.hp_flash[1], boss_portrait_uri(state.world))}{thinking}</div>"
|
| 934 |
f"{piles_html(enemy)}"
|
| 935 |
"</div>"
|
| 936 |
)
|
|
|
|
| 943 |
return (
|
| 944 |
"<div class='zone player-zone'>"
|
| 945 |
"<div class='zone-center'>"
|
| 946 |
+
f"{hero_html(player, state.school, state.player_name, False, state.hp_flash[0], player_portrait_uri(state.world, state.school))}"
|
| 947 |
f"{mana_html(player, state.duel.round_number)}"
|
| 948 |
f"{hand_fan_html(player)}"
|
| 949 |
"</div>"
|
|
|
|
| 1005 |
)
|
| 1006 |
|
| 1007 |
|
| 1008 |
+
# Cached portrait thumbnails (small JPEGs) keyed by asset stem, embedded as data
|
| 1009 |
+
# URIs so the board can re-render each timer tick without streaming the big PNGs.
|
| 1010 |
+
_THUMBS = Path(__file__).parent / "assets" / "thumbs"
|
| 1011 |
+
_portrait_cache: dict[str, str] = {}
|
| 1012 |
+
_WORLD_PREFIX = {"dark fantasy": "darkFantasy", "cyberpunk": "cyberpunk", "anime": "anime"}
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
# Return a thumbnail data URI for an asset stem, or "" if it is missing.
|
| 1016 |
+
def thumb_uri(stem: str) -> str:
|
| 1017 |
+
if stem not in _portrait_cache:
|
| 1018 |
+
path = _THUMBS / f"{stem}.jpg"
|
| 1019 |
+
_portrait_cache[stem] = (
|
| 1020 |
+
"data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii") if path.exists() else ""
|
| 1021 |
+
)
|
| 1022 |
+
return _portrait_cache[stem]
|
| 1023 |
+
|
| 1024 |
+
|
| 1025 |
+
# Return the asset file prefix for a world (handles the display-name casings).
|
| 1026 |
+
def world_prefix(world: str) -> str:
|
| 1027 |
+
return _WORLD_PREFIX.get(world.strip().lower(), "darkFantasy")
|
| 1028 |
+
|
| 1029 |
+
|
| 1030 |
+
# Return the player's portrait for their world + school (tries casing variants).
|
| 1031 |
+
def player_portrait_uri(world: str, school: str) -> str:
|
| 1032 |
+
prefix = world_prefix(world)
|
| 1033 |
+
for variant in (school.title(), school.lower(), school.capitalize()):
|
| 1034 |
+
uri = thumb_uri(f"{prefix}{variant}")
|
| 1035 |
+
if uri:
|
| 1036 |
+
return uri
|
| 1037 |
+
return ""
|
| 1038 |
+
|
| 1039 |
+
|
| 1040 |
+
# Return the boss portrait (circle thumbnail) for a world.
|
| 1041 |
+
def boss_portrait_uri(world: str) -> str:
|
| 1042 |
+
return thumb_uri(f"{world_prefix(world)}Boss")
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
+
# Return the full boss splash image (medium JPEG) for the reveal screen.
|
| 1046 |
+
def boss_splash_uri(world: str) -> str:
|
| 1047 |
+
return thumb_uri(f"{world_prefix(world)}Boss_splash")
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
# Return a hero portrait with HP gem, defenses, status chips, and damage pop.
|
| 1051 |
+
def hero_html(player: PlayerState, school: School, title: str, enemy: bool, damage: int = 0, portrait: str = "") -> str:
|
| 1052 |
block = f"<div class='block-gem'>{player.block}</div>" if player.block else ""
|
| 1053 |
ward = f"<div class='ward-gem'>{player.ward}</div>" if player.ward else ""
|
| 1054 |
pop = f"<div class='dmg-pop'>-{damage}</div>" if damage else ""
|
| 1055 |
hit = " hit" if damage else ""
|
| 1056 |
+
face = (
|
| 1057 |
+
f"<div class='hero-face portrait' style=\"background-image:url('{portrait}')\"></div>"
|
| 1058 |
+
if portrait
|
| 1059 |
+
else f"<div class='hero-face'>{school_mark(school)}</div>"
|
| 1060 |
+
)
|
| 1061 |
return (
|
| 1062 |
f"<div class='hero {'enemy' if enemy else 'you'}'>"
|
| 1063 |
+
f"<div class='hero-frame{hit}'>{face}"
|
| 1064 |
f"<div class='hp-gem'>{player.hp}</div>{block}{ward}{pop}</div>"
|
| 1065 |
f"<div class='hero-name'>{escape_html(title)}</div>"
|
| 1066 |
f"{status_chips_html(player)}"
|