vvennelakanti commited on
Commit
a8afc36
·
1 Parent(s): 6bbf552

finished base

Browse files
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
- "4096",
 
 
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.0",
43
- "TABRAS_CARD_MAX_TOKENS": "220",
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
- choose_draft_card_steps,
 
13
  draft_screen_html,
14
  log_html,
15
- new_run,
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: radial-gradient(ellipse at 50% 20%, #3a2615 0%, #241608 55%, #140b04 100%) fixed;
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: radial-gradient(ellipse at 50% 20%, #3a2615 0%, #241608 55%, #140b04 100%) fixed !important;
 
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: #f8e7c4;
70
- text-shadow: 0 3px 18px rgba(0, 0, 0, 0.65);
71
  }
72
  .gradio-container .setup-panel {
73
- background: rgba(36, 22, 13, 0.78) !important;
74
- border: 2px solid rgba(255, 225, 172, 0.25) !important;
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, #2b2119, #17100b);
88
- border: 2px solid #bb8c4d;
89
- color: #f8ead2;
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: #ffb199; }
116
- .school-ice .card-name { color: #aadcff; }
117
- .school-earth .card-name { color: #c9e29a; }
 
 
 
118
  .card-art {
119
  height: 64px;
120
  border-radius: 6px;
121
- border: 1px dashed rgba(255, 232, 188, 0.25);
122
- background: linear-gradient(135deg, rgba(77, 52, 35, 0.55), rgba(23, 32, 42, 0.55));
 
123
  }
124
- .card-rules { font-size: 11.5px; line-height: 1.3; min-height: 44px; color: #f3e6cb; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  .card-flavor {
126
- color: #cdb997;
127
  font-size: 10px;
128
  font-style: italic;
129
- border-top: 1px solid rgba(255, 232, 188, 0.18);
130
  padding-top: 4px;
131
  }
132
 
133
  /* ---- draft screen ---- */
134
- .draft-board { display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 18px; }
 
 
 
 
 
 
 
 
 
 
135
  .draft-banner { text-align: center; }
136
- .draft-banner h2 { margin: 0; color: #f2c24d; letter-spacing: 0.08em; text-transform: uppercase; }
137
- .draft-banner p { margin: 4px 0 0; color: #cdb997; }
138
- .draft-pack { display: flex; gap: 28px; }
139
- .draft-card { width: 215px; cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease; animation: pack-in 0.5s ease backwards; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(-14px) scale(1.06); box-shadow: 0 0 26px rgba(240, 200, 110, 0.5); }
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: 42px; }
158
  .draft-card .card-art { height: 112px; }
159
- .draft-card .card-rules { font-size: 13.5px; min-height: 62px; }
160
  .draft-card .card-flavor { font-size: 12px; }
161
- .deck-strip { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; max-width: 920px; align-items: center; }
162
- .deck-strip-label { font-weight: 800; color: #f2c24d; margin-right: 6px; }
163
- .deck-chip { background: rgba(0, 0, 0, 0.35); border: 1px solid #7c5a33; border-radius: 12px; padding: 2px 10px; font-size: 11px; }
164
- .deck-chip b { color: #7fd4ff; }
165
- .deck-chip.anchor { border-color: #f2c24d; box-shadow: 0 0 8px rgba(242, 194, 77, 0.35); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  /* ---- battle board ---- */
168
  .board {
169
  position: relative;
170
- border: 4px solid #6e4f2a;
171
  border-radius: 22px;
172
  padding: 8px 14px;
173
- background: radial-gradient(ellipse at center, #5d3b1e 0%, #4a2e15 60%, #38220e 100%);
174
- box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.55), 0 24px 60px rgba(0, 0, 0, 0.5);
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 #8a6536;
184
  border-radius: 8px;
185
  padding: 6px 0 3px;
186
- background: repeating-linear-gradient(45deg, #241509 0, #241509 6px, #2f1c0d 6px, #2f1c0d 12px);
187
  }
188
- .pile span { font-weight: 900; font-size: 18px; color: #f4d69b; }
189
- .pile label { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: #b09a72; }
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%, #7a5836, #2a1a0e 75%);
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 #a77c43;
269
  margin: 0 -10px;
270
- background: repeating-linear-gradient(45deg, #2c1b12 0, #2c1b12 7px, #3a2417 7px, #3a2417 14px);
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(0, 0, 0, 0.18);
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: #e9cf9b;
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: #9fe7ff; background: rgba(30, 90, 140, 0.45); border: 1px solid #4fa3d4; }
346
- .play-label.boss { color: #ffb9a8; background: rgba(140, 40, 20, 0.45); border: 1px solid #c95a3a; }
347
  .played-card { width: 132px; }
348
- .played-card.player-play { box-shadow: 0 0 22px rgba(120, 220, 255, 0.55); }
349
- .played-card.enemy-play { box-shadow: 0 0 22px rgba(255, 90, 60, 0.6); border-color: #c95a3a; }
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(110, 230, 120, 0.5);
378
- border-color: #7fdf8a;
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(120, 255, 140, 0.45), 0 24px 50px rgba(0, 0, 0, 0.6);
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: #6b5634; }
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(8, 4, 2, 0); }
408
- 18% { background: rgba(8, 4, 2, 0.74); }
409
- 72% { background: rgba(8, 4, 2, 0.74); }
410
- 100% { background: rgba(8, 4, 2, 0); }
411
  }
412
  .splash-round {
413
  font-size: 50px;
414
  font-weight: 900;
415
  letter-spacing: 0.3em;
416
- color: #f6dd9a;
417
- text-shadow: 0 0 34px rgba(246, 221, 154, 0.85), 0 4px 8px #000;
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: #9fe7ff; text-shadow: 0 0 24px rgba(110, 200, 255, 0.8); }
427
- .splash-initiative.boss { color: #ff8d7a; text-shadow: 0 0 24px rgba(255, 80, 50, 0.8); }
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, 6, 0.92);
443
- border: 1px solid #c95a3a;
444
- color: #ffb9a8;
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(255, 90, 50, 0.6); }
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(10, 5, 2, 0.74);
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: #cdb997; }
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(20, 12, 7, 0.8) !important; border: 2px solid #6e4f2a; border-radius: 14px; padding: 8px; }
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: #e7d5af;
497
  }
498
  .log-line { padding: 4px 8px; border-left: 3px solid transparent; border-radius: 4px; line-height: 1.35; }
499
- .log-line b { color: #ffd98c; font-size: 12.5px; }
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: #4fa3d4; background: rgba(40, 90, 130, 0.2); }
503
- .log-play.log-boss { border-left-color: #c95a3a; background: rgba(140, 50, 25, 0.2); }
504
  .log-round {
505
  text-align: center;
506
- color: #f2c24d;
507
  font-weight: 800;
508
  letter-spacing: 0.06em;
509
  margin-top: 8px;
510
- border-top: 1px solid rgba(242, 194, 77, 0.3);
511
  padding-top: 6px;
512
  }
513
- .log-winner { text-align: center; color: #ffd98c; font-weight: 900; font-size: 13px; border: 1px solid #f2c24d; background: rgba(120, 85, 10, 0.3); }
514
  .log-muted { opacity: 0.55; font-style: italic; }
515
- .log-draft { color: #c9e29a; }
516
- .log-scroll > .log-line:first-child { box-shadow: inset 0 0 0 1px rgba(255, 217, 140, 0.3); }
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="setup-screen") as setup_group:
530
  with gr.Column(elem_classes=["setup-panel"]):
531
- gr.Markdown("## Set the table")
532
  name = gr.Textbox(label="Name", value="Vishnu")
533
- world = gr.Textbox(label="What's your world?", value="dark fantasy")
534
- school = gr.Radio(["fire", "ice", "earth"], label="What's your favorite school of magic?", value="fire")
535
- begin = gr.Button("Begin Draft", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
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 = [state, title_group, setup_group, draft_group, battle_group, draft_view, board_view, log_view]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
- play_now.click(show_setup, outputs=outputs)
557
- begin.click(begin_run, inputs=[name, world, school], outputs=outputs)
 
 
 
 
 
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(show_setup, outputs=outputs)
 
564
 
565
  return app
566
 
@@ -573,31 +937,72 @@ def indexed_handler(handler, index: int):
573
  return stream
574
 
575
 
576
- # Show the setup screen.
577
- def show_setup() -> list[object]:
578
- return render(None, "setup")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
 
 
 
 
580
 
581
- # Start a run from setup inputs.
582
- def begin_run(name: str, world: str, school: str) -> list[object]:
 
 
 
 
 
 
583
  client = card_client_from_env()
584
- run_state = new_run(name, world, school_as_literal(school), client)
 
 
 
 
 
 
 
 
 
 
 
 
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, "setup")
592
  return
593
  client = card_client_from_env()
594
- yield from paced_frames(choose_draft_card_steps(run_state, index, client))
 
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, "setup")
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, "setup")
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.75
631
- return 0.9
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 == "setup"),
 
 
 
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

  • SHA256: 99b9807934656d038cb6fa245b030285a289d038acab61b19f51c000cd7c8632
  • Pointer size: 132 Bytes
  • Size of remote file: 2.79 MB
assets/animeBoss.png ADDED

Git LFS Details

  • SHA256: 93e2245b2413da492d4d4a2a2525c2beb4928b2fa3b968af2cbbb485afcb5a8f
  • Pointer size: 132 Bytes
  • Size of remote file: 3.28 MB
assets/animeEarth.png ADDED

Git LFS Details

  • SHA256: 2fd52063abc4653763e42d7d82c13a95680d4ee6f89a40dc39fa6141bb001bff
  • Pointer size: 132 Bytes
  • Size of remote file: 3.16 MB
assets/animeFire.png ADDED

Git LFS Details

  • SHA256: b678f3205ccf8c345dacc995ed8dd2866493675e36141fc8ffc19855f0fb22fb
  • Pointer size: 132 Bytes
  • Size of remote file: 2.85 MB
assets/animeIce.png ADDED

Git LFS Details

  • SHA256: 5203ec9b7db09233ef6d632958d5eeb9c960fe0a8cf143d304da6e543c94b222
  • Pointer size: 132 Bytes
  • Size of remote file: 3.2 MB
assets/cyberpunk.png ADDED

Git LFS Details

  • SHA256: 03d531fc2f30574e989e912ea9907a414b227c1e549efd119dac777e9f84e237
  • Pointer size: 132 Bytes
  • Size of remote file: 2.63 MB
assets/cyberpunkBoss.png ADDED

Git LFS Details

  • SHA256: 21d935c624824ae2e6991d9c96a72a5a10d737cd2337c4e156b134f8d8bd080c
  • Pointer size: 132 Bytes
  • Size of remote file: 3.04 MB
assets/cyberpunkEarth.png ADDED

Git LFS Details

  • SHA256: b7db9fc9a83814eb9f3ba5f4b3584e1e6b2a622da0c23962dc25f23949266f11
  • Pointer size: 132 Bytes
  • Size of remote file: 3.07 MB
assets/cyberpunkFire.png ADDED

Git LFS Details

  • SHA256: 6c145572d5347e6ba0f7f318d9812f26aab3fec45c2a86267fd640818e9e882e
  • Pointer size: 132 Bytes
  • Size of remote file: 2.76 MB
assets/cyberpunkice.png ADDED

Git LFS Details

  • SHA256: 78bd276834b8e2828072ac7f116d1adc187a5bf5401a8b94c4c79abf1d9de10a
  • Pointer size: 132 Bytes
  • Size of remote file: 3 MB
assets/darkFantasy.png ADDED

Git LFS Details

  • SHA256: 704ceed16519e7727c438eeb665a384eb32ffb2a76f19d83158f1423ef52de4e
  • Pointer size: 132 Bytes
  • Size of remote file: 2.13 MB
assets/darkFantasyBoss.png ADDED

Git LFS Details

  • SHA256: 967e0201b1b26ee504029ebcd6450603d84a308eadd7d672f605ebed893481bc
  • Pointer size: 132 Bytes
  • Size of remote file: 2.49 MB
assets/darkFantasyEarth.png ADDED

Git LFS Details

  • SHA256: 837cb592405c76d8df9da0b85cc5325baf2a354a9707f9372203e596dd7e1ae2
  • Pointer size: 132 Bytes
  • Size of remote file: 2.71 MB
assets/darkFantasyFire.png ADDED

Git LFS Details

  • SHA256: 7852567c46c4d0c47cc336b393760b17049f6a2d6e80e32dc2e4e64c6cefad7d
  • Pointer size: 132 Bytes
  • Size of remote file: 2.47 MB
assets/darkFantasyIce.png ADDED

Git LFS Details

  • SHA256: 4c6f1209bce9765c559384bef7c0b29d19bf7b9da192d16985719b80e372bc18
  • Pointer size: 132 Bytes
  • Size of remote file: 2.97 MB
assets/inn_bg.jpg ADDED

Git LFS Details

  • SHA256: 8fb7015e3d4d95c17d921e5fcb658c0c9c59aa02d07925977d64b56fcebb5d1e
  • Pointer size: 131 Bytes
  • Size of remote file: 112 kB
assets/wood_board.jpg ADDED

Git LFS Details

  • SHA256: 29e36412ce2be8a40791abfb6e4ef295833395b0cf720d2356a7bfa85f29696c
  • Pointer size: 131 Bytes
  • Size of remote file: 109 kB
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", "220")),
 
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 by generating single-card outputs.
 
83
  def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]:
84
- cards: list[dict[str, Any]] = []
85
- for _ in range(int(payload.get("pack_size", 3))):
86
- cards.append(generate_llamacpp_card(self.chat, payload, 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
- raw = client.create_pack(pack_payload(school, theme, current_deck, cost, pack_size, draft_anchors))
 
 
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 card_satisfies_candidate_need(card, need):
 
 
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 card_satisfies_candidate_need(retry, need) else card
 
 
 
 
 
 
 
 
 
 
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 race plan."
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(text: str, payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]]) -> dict[str, Any]:
 
 
 
 
 
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(raw: dict[str, Any], payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]]) -> dict[str, Any]:
 
 
 
 
 
561
  allowed = tuple(payload["allowed_primitives"])
562
  effects = repair_effects(raw.get("effects"), allowed)
563
- name = str(raw.get("name") or fallback_card_name(payload["school"], effects, pack_cards))
 
 
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
- base = f"{school.title()} {' '.join(str(effect['primitive_id']).title() for effect in effects)}".strip()
589
- if base not in used:
590
- return base
591
- return f"{base} {len(used) + 1}"
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(model: str, system: str, user: str, temperature: float, max_tokens: int | None = None) -> dict[str, Any]:
 
 
 
 
 
 
 
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 == 220
 
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 assembles packs from single-card calls.
 
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": "Payoff", "effects": [{"primitive_id": "scaling"}]}}',
481
  ]
482
  )
483
  card = generate_llamacpp_card(chat, payload, [])
484
- assert card["name"] == "Payoff"
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"] == "Fire Deal Burn"
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"] == "Earth Deal"
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 "Boss is scheming" in board_html(thinking[0])
 
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 and generate the first draft pack.
54
- def new_run(player_name: str, world: str, school: School, client: CardPackClient | None = None, seed: int = 7) -> RunState:
 
 
 
 
 
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 deal_next_pack(state, client)
78
 
79
 
80
- # Choose a draft card, yielding paced battle states once drafting completes.
81
- def choose_draft_card_steps(state: RunState, index: int, client: CardPackClient | None = None) -> Steps:
82
- if index < 0 or index >= len(state.current_pack):
83
- yield add_log(state, "That draft card is not available.")
84
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- state = replace(
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(deal_next_pack(state, client), pack_fading=-1)
100
 
101
 
102
  # Choose a draft card and advance to the next pack or battle.
103
- def choose_draft_card(state: RunState, index: int, client: CardPackClient | None = None) -> RunState:
104
- return last_state(choose_draft_card_steps(state, index, client))
 
 
 
 
 
105
 
106
 
107
- # Generate the next draft pack.
108
- def deal_next_pack(state: RunState, client: CardPackClient | None = None) -> RunState:
109
- cost_index = state.draft_order[state.draft_step]
110
- cost = SYNERGY_COSTS[cost_index]
111
- pack = make_pack(client, state.school, state.world, state.player_deck, cost, state.draft_anchors)
112
- return replace(state, current_pack=pack)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
 
115
  # Start combat after drafting, yielding paced states for the opening round.
116
- def start_battle_steps(state: RunState, client: CardPackClient | None = None) -> Steps:
117
- enemy_deck = draft_enemy_deck_for_ui(client, state.enemy_school, state.world, state.rng)
 
 
 
 
 
 
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
- yield from paced_action(state, advance_to_player_steps)
 
 
 
 
 
 
 
 
 
 
 
132
 
133
 
134
  # Draft a boss deck for the UI.
135
- def draft_enemy_deck_for_ui(client: CardPackClient | None, school: School, world: str, rng: Random) -> tuple[Card, ...]:
 
 
 
 
 
 
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
- return generate_pack(client, school, world, current_deck, cost, draft_anchors=anchors)
 
160
  except Exception:
161
  pass
162
- return fallback_pack(school, world, cost, anchors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
 
165
  # Return a deterministic draft pack when no model is configured.
166
- def fallback_pack(school: School, world: str, cost: int, anchors: Sequence[Card] = ()) -> tuple[Card, ...]:
167
- plans = fallback_plans(school, anchors)
 
 
 
 
 
 
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(school: School, anchors: Sequence[Card]) -> tuple[tuple[str, tuple[EffectPlan, ...], str, str], ...]:
 
 
 
 
 
173
  if school == "fire":
174
- return (
175
- ("Cinder Rush", (EffectPlan("deal"), EffectPlan("burn")), "Heat hits first, then keeps biting.", "burning playing card on a wooden table"),
176
- ("Fuse Prayer", (EffectPlan("bomb"),), "A delayed blast tucked under the enemy's next breath.", "black powder sigil glowing under ash"),
177
- ("Kindling Draw", (EffectPlan("draw"), EffectPlan("deal")), "The next spark finds your hand.", "embers curling into paper cards"),
 
 
 
 
 
 
 
 
 
 
 
 
178
  )
 
179
  if school == "ice":
180
  return (
181
- ("Glass Tempo", (EffectPlan("initiative"), EffectPlan("vulnerable")), "The table freezes one heartbeat ahead.", "frosted hourglass over cards"),
182
- ("Needle Flurry", (EffectPlan("multi_hit"),), "Small cuts find the opening.", "ice needles striking a portrait frame"),
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 on an old desk"),
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
- yield replace(state, boss_thinking=True)
 
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 with a blank art slot for future generation.
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
- "<div class='card-art'></div>"
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 or not state.current_pack:
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&hellip;</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
- thinking = "<div class='boss-thinking'>Boss is scheming&hellip;</div>" if state.boss_thinking else ""
 
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(&quot;{escape_html(card.art_uri)}&quot;)"></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&hellip;</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>"