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 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 AI | `nvidia/Nemotron-Mini-4B-Instruct` | Enemy play decisions |
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: 76px;
139
- margin: 0 0 12px;
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("<div class='tabras-title'><h1>Tabras</h1><p>A small-model card duel on an old wooden desk.</p></div>")
 
 
 
 
 
 
 
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
- # Start deck generation while the rules screen is shown.
 
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, "rules")
 
 
 
 
 
 
 
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>Energy</b><span>Your energy resets each round. It does not carry over.</span></div>"
1189
- "<div class='rule-tile'><b>Drafting</b><span>Pick one card from each pack. Anchor picks set the deck's direction.</span></div>"
1190
- "<div class='rule-tile'><b>Defense</b><span>Block lasts until your next turn. Ward absorbs one future hit.</span></div>"
1191
- "<div class='rule-tile'><b>Boss</b><span>The boss reads the public board, then plays from its hidden hand.</span></div>"
 
 
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 &mdash; 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

  • SHA256: 4c3a59c70f6cb6c34e931a5b54e9df51608843e84539e63d0670d466b73a7483
  • Pointer size: 130 Bytes
  • Size of remote file: 39.2 kB
assets/thumbs/animeBoss.jpg ADDED

Git LFS Details

  • SHA256: 067e233696531e29c1611f268e0efd094ebf7facd06ab836328763d381842a1d
  • Pointer size: 130 Bytes
  • Size of remote file: 36.4 kB
assets/thumbs/animeBoss_splash.jpg ADDED

Git LFS Details

  • SHA256: 31fe72e8f692bde6564b9a66f61ba84caf26ce947aa745803dfc98e1ff7286a6
  • Pointer size: 131 Bytes
  • Size of remote file: 255 kB
assets/thumbs/animeEarth.jpg ADDED

Git LFS Details

  • SHA256: 66eea2fbe50829a50f8aad40e859635aa04a4544956bd3f47c3c7013439bad72
  • Pointer size: 130 Bytes
  • Size of remote file: 43.5 kB
assets/thumbs/animeFire.jpg ADDED

Git LFS Details

  • SHA256: 83ce1d58d8962e545ac5f8c52b41c054bcc371f8e07f62f2194470f6fce24004
  • Pointer size: 130 Bytes
  • Size of remote file: 40.6 kB
assets/thumbs/animeIce.jpg ADDED

Git LFS Details

  • SHA256: dfc2db83ef3a945f4e1a6ccb48e7ed0e7e15dc6902d44373660ed69555919bae
  • Pointer size: 130 Bytes
  • Size of remote file: 44.5 kB
assets/thumbs/cyberpunk.jpg ADDED

Git LFS Details

  • SHA256: acd3c9a80402bf802803d66db899f20ef63e341c8b421157acfa7be8da186136
  • Pointer size: 130 Bytes
  • Size of remote file: 34.4 kB
assets/thumbs/cyberpunkBoss.jpg ADDED

Git LFS Details

  • SHA256: a921a4e13a1a265737ea6431cea2a8f30c6017615e6e320631ba2326a159a679
  • Pointer size: 130 Bytes
  • Size of remote file: 29 kB
assets/thumbs/cyberpunkBoss_splash.jpg ADDED

Git LFS Details

  • SHA256: cf6129fda4742f3222516abc964022aec8fee0dc727dcc682d3e140f2eaa49ad
  • Pointer size: 131 Bytes
  • Size of remote file: 224 kB
assets/thumbs/cyberpunkEarth.jpg ADDED

Git LFS Details

  • SHA256: 3c311dcb60af124a368b7ad59989307ae01fe702516a9d2fd7c6c5ec1dd61a71
  • Pointer size: 130 Bytes
  • Size of remote file: 38.5 kB
assets/thumbs/cyberpunkFire.jpg ADDED

Git LFS Details

  • SHA256: 844053eee3ae1260d2270468f27aa09c257c10fa850f7111ea90fb7ffa80e9dc
  • Pointer size: 130 Bytes
  • Size of remote file: 37 kB
assets/thumbs/cyberpunkice.jpg ADDED

Git LFS Details

  • SHA256: e6d4f1f8078fc8f5e76d304526c9e7dfb8261fead32242c59801b41e0699047e
  • Pointer size: 130 Bytes
  • Size of remote file: 39.6 kB
assets/thumbs/darkFantasy.jpg ADDED

Git LFS Details

  • SHA256: befc62f5f053eb05ff024b3e8ad14260addadb2fac762ed6a2ccadb9d24ed6a6
  • Pointer size: 130 Bytes
  • Size of remote file: 17.9 kB
assets/thumbs/darkFantasyBoss.jpg ADDED

Git LFS Details

  • SHA256: 5668199899b84e72596cc0060a50a5b0dc4b0aeab2695e2b2197178840ca05e5
  • Pointer size: 130 Bytes
  • Size of remote file: 28.4 kB
assets/thumbs/darkFantasyBoss_splash.jpg ADDED

Git LFS Details

  • SHA256: bda13396beb9d8912425938e998e63323db8087708348badac4c2792c1450fcb
  • Pointer size: 131 Bytes
  • Size of remote file: 111 kB
assets/thumbs/darkFantasyEarth.jpg ADDED

Git LFS Details

  • SHA256: 950c71e7450b26829fefb5f417d9233694c9d4feca43440fe697e3776439861b
  • Pointer size: 130 Bytes
  • Size of remote file: 33.7 kB
assets/thumbs/darkFantasyFire.jpg ADDED

Git LFS Details

  • SHA256: 19124c802210c5bd02a08452f8b350219403f7fef223e8e17ade23f67580e8c8
  • Pointer size: 130 Bytes
  • Size of remote file: 28.7 kB
assets/thumbs/darkFantasyIce.jpg ADDED

Git LFS Details

  • SHA256: 0acfadf329f980ad4b1003dab6a179e2a04db8c6c7580ca4785ca57884d3e35c
  • Pointer size: 130 Bytes
  • Size of remote file: 37.4 kB
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
- return retry if card_acceptable(retry, need, payload) else repair_llamacpp_card(fallback_raw_for_need(need, payload), payload, pack_cards)
 
 
 
 
 
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
- return [str(allowed[index % len(allowed)]) for index in range(size)]
 
 
 
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
- 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")
@@ -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[10]
 
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 + one speculative next pack; the boss deck is deferred (draft_step >= 2)
491
- # so it no longer competes with the first visible pack.
492
- assert client.calls == 2
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() == 1
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
- return max(0, int(os.environ.get("TABRAS_PREFETCH_PACKS", "1")))
 
 
302
  except ValueError:
303
- return 1
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}'><div class='hero-face'>{school_mark(school)}</div>"
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)}"