Codex Claude Opus 4.8 commited on
Commit
a33d5b8
·
1 Parent(s): 372c910

Pre-bake backbone card art (instant, no shimmer) + stop warming face-down enemy hand

Browse files

- 18 bundled JPEGs (6 backbone cards x 3 worlds); backbone_card is born with its
art_uri, so the base Attack/Block/Draw cards never shimmer and cost no live SDXL.
- Skip art generation for the enemy hand (rendered as card backs), freeing the art
lane so visible player cards illustrate faster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

README.md CHANGED
@@ -102,25 +102,43 @@ Install dependencies:
102
  python3 -m pip install -r requirements.txt
103
  ```
104
 
105
- Run the Gradio app without local model servers:
106
 
107
  ```bash
108
  python3 app.py
109
  ```
110
 
111
- For the local AI path, start Tabras through:
 
 
 
 
 
 
 
 
112
 
113
  ```bash
114
  python3 launch_ai.py
115
  ```
116
 
117
- `launch_ai.py` expects the MiniCPM GGUF at:
118
 
119
- ```text
120
- models/minicpm-v-4.6-gguf/MiniCPM-V-4.6-Q4_K_M.gguf
 
 
 
 
 
 
 
 
 
 
121
  ```
122
 
123
- You can also configure backends with environment variables:
124
 
125
  ```bash
126
  TABRAS_CARD_BACKEND=transformers
@@ -133,6 +151,8 @@ TABRAS_ART_MODEL=stabilityai/sdxl-turbo
133
  python3 app_hf.py
134
  ```
135
 
 
 
136
  ## Project Structure
137
 
138
  | File | Purpose |
 
102
  python3 -m pip install -r requirements.txt
103
  ```
104
 
105
+ Run the Gradio app without model servers:
106
 
107
  ```bash
108
  python3 app.py
109
  ```
110
 
111
+ That path is fully local and playable: it uses the same deterministic engine and fallback card generation, with no model server required.
112
+
113
+ For local AI card generation, install `llama-server` from llama.cpp, download the MiniCPM GGUF, and place it here:
114
+
115
+ ```text
116
+ models/minicpm-v-4.6-gguf/MiniCPM-V-4.6-Q4_K_M.gguf
117
+ ```
118
+
119
+ Then start Tabras through:
120
 
121
  ```bash
122
  python3 launch_ai.py
123
  ```
124
 
125
+ `launch_ai.py` starts `llama-server` on `127.0.0.1:8090`, points Tabras at that OpenAI-compatible endpoint, uses local MLX for the Nemotron boss by default, and uses local Diffusers for SDXL-Turbo art.
126
 
127
+ You can also point any model role at a GPU machine you control. Set the endpoint/model environment variables before launching the app:
128
+
129
+ ```bash
130
+ TABRAS_CARD_BACKEND=llamacpp
131
+ TABRAS_CARD_ENDPOINT=http://YOUR_GPU_HOST:8090/v1/chat/completions
132
+ TABRAS_CARD_MODEL=minicpm-v-4.6-q4
133
+ TABRAS_AI_BOSS=1
134
+ TABRAS_BOSS_BACKEND=openai
135
+ TABRAS_BOSS_ENDPOINT=http://YOUR_GPU_HOST:8081/v1/chat/completions
136
+ TABRAS_ART_BACKEND=modal
137
+ TABRAS_ART_ENDPOINT=http://YOUR_GPU_HOST:8082/generate
138
+ python3 app.py
139
  ```
140
 
141
+ For in-process local Transformers/Diffusers instead of OpenAI-compatible endpoints:
142
 
143
  ```bash
144
  TABRAS_CARD_BACKEND=transformers
 
151
  python3 app_hf.py
152
  ```
153
 
154
+ The submitted Space uses Modal GPU endpoints, but the same app can run with local CPU fallback, local model processes, in-process local models, or GPU endpoints that you configure.
155
+
156
  ## Project Structure
157
 
158
  | File | Purpose |
assets/cards/anime__attack_medium.jpg ADDED

Git LFS Details

  • SHA256: 74ed9a305e74566bb4989066e8a04603cb8ce38f442fb163218422284171d27b
  • Pointer size: 130 Bytes
  • Size of remote file: 25.6 kB
assets/cards/anime__attack_strong.jpg ADDED

Git LFS Details

  • SHA256: 01c7ac208a02590972a2250ad4f94e5e353e0cb92c10696dead9f7feb2aec8e7
  • Pointer size: 130 Bytes
  • Size of remote file: 23.2 kB
assets/cards/anime__attack_weak.jpg ADDED

Git LFS Details

  • SHA256: bd0ae86fbc45d611c9ee41de3fd378d6414ad954981412abb2b97b50376d0199
  • Pointer size: 130 Bytes
  • Size of remote file: 27.4 kB
assets/cards/anime__block_medium.jpg ADDED

Git LFS Details

  • SHA256: 11cee6e9aa989e41ca893df1859ed1f956211cd89510e6cd41bbf7d3f167aa96
  • Pointer size: 130 Bytes
  • Size of remote file: 24.9 kB
assets/cards/anime__block_weak.jpg ADDED

Git LFS Details

  • SHA256: bde1b962a3d3e4bb38ef3af8699dc6a2a978b553fb9f0395d084e0185d31b677
  • Pointer size: 130 Bytes
  • Size of remote file: 19.1 kB
assets/cards/anime__draw_small.jpg ADDED

Git LFS Details

  • SHA256: 21d2bee40b1b56d3cb74e6f0370fab0c6c7c90bfc45b69c8526fc2229e680ecf
  • Pointer size: 130 Bytes
  • Size of remote file: 22.6 kB
assets/cards/cyberpunk__attack_medium.jpg ADDED

Git LFS Details

  • SHA256: 9c43d5ef28c0b6e7e7970bd35ac37e25bada34867ff1cf87bb1cae5cb1f82dad
  • Pointer size: 130 Bytes
  • Size of remote file: 19.3 kB
assets/cards/cyberpunk__attack_strong.jpg ADDED

Git LFS Details

  • SHA256: 83fcb7a709ee7d1574b7dbbe7b5c66a9688914cd73a8d13fcead1e906c153e30
  • Pointer size: 130 Bytes
  • Size of remote file: 18.2 kB
assets/cards/cyberpunk__attack_weak.jpg ADDED

Git LFS Details

  • SHA256: f957cbb9dd989763c86c904fbf02d974a044534e0a2746d9de160c7a7e3b0e5b
  • Pointer size: 130 Bytes
  • Size of remote file: 17.6 kB
assets/cards/cyberpunk__block_medium.jpg ADDED

Git LFS Details

  • SHA256: eb24be1334fe2b772da088ebf5de18019c4df223102bfdcf4be0530dd382150b
  • Pointer size: 130 Bytes
  • Size of remote file: 17.4 kB
assets/cards/cyberpunk__block_weak.jpg ADDED

Git LFS Details

  • SHA256: 1fc9544f130a2cab6d9bfaa371ff466feac8348b0fbb5777a3271bf8386aadfd
  • Pointer size: 130 Bytes
  • Size of remote file: 19.9 kB
assets/cards/cyberpunk__draw_small.jpg ADDED

Git LFS Details

  • SHA256: 12b9e22a8525e9b5e6de427410dafb30edcb177225b963e1aab666d8a266e3dc
  • Pointer size: 130 Bytes
  • Size of remote file: 19.7 kB
assets/cards/darkfantasy__attack_medium.jpg ADDED

Git LFS Details

  • SHA256: 6243fe2184510a3b2f0f98498a2be76c61995afaee3224f60051edb24dc290c0
  • Pointer size: 130 Bytes
  • Size of remote file: 12.9 kB
assets/cards/darkfantasy__attack_strong.jpg ADDED

Git LFS Details

  • SHA256: 10cce5d9d783f914b3c029f2a3fa21f499299d795a1f764fd5a592d921f03b1f
  • Pointer size: 130 Bytes
  • Size of remote file: 14.2 kB
assets/cards/darkfantasy__attack_weak.jpg ADDED

Git LFS Details

  • SHA256: da0d931cf3bf494b8d46bb4179443081b0ea7569c0a282b6571959a6d21d68da
  • Pointer size: 130 Bytes
  • Size of remote file: 14.5 kB
assets/cards/darkfantasy__block_medium.jpg ADDED

Git LFS Details

  • SHA256: 78de1c4522113624d9f6cedc45c19c719bd6faeb73a2eb995828a6652f502229
  • Pointer size: 130 Bytes
  • Size of remote file: 12.9 kB
assets/cards/darkfantasy__block_weak.jpg ADDED

Git LFS Details

  • SHA256: 6d77ba3b014f4c8416175d71c77286921ecd6a27c46b95ca78adf92f3182b0f8
  • Pointer size: 130 Bytes
  • Size of remote file: 10.6 kB
assets/cards/darkfantasy__draw_small.jpg ADDED

Git LFS Details

  • SHA256: c26c7c5d5d01a27b1fb16a472f1e43f323fbe6b44032b3cd9041b8d66fdf981c
  • Pointer size: 130 Bytes
  • Size of remote file: 13.6 kB
draft.py CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  from random import Random
2
  from typing import Callable
3
 
@@ -8,6 +11,24 @@ from primitives import School
8
 
9
  PackChooser = Callable[[tuple[Card, ...], tuple[Card, ...]], int]
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  BACKBONE_CARDS: tuple[tuple[str, int, Effect], ...] = (
12
  ("Attack (weak)", 1, Effect("deal", amount=2)),
13
  ("Attack (medium)", 3, Effect("deal", amount=4)),
@@ -23,9 +44,9 @@ def backbone_deck(school: School, theme: str) -> tuple[Card, ...]:
23
  return tuple(backbone_card(name, cost, effect, school, theme) for name, cost, effect in BACKBONE_CARDS)
24
 
25
 
26
- # Build one standardized backbone card.
27
  def backbone_card(name: str, cost: int, effect: Effect, school: School, theme: str) -> Card:
28
- return Card(name, cost, school, theme, (effect,), flavor=f"Standard {name.lower()}.")
29
 
30
 
31
  # Build a full 15-card text deck with nine generated synergy cards.
 
1
+ import base64
2
+ import re
3
+ from pathlib import Path
4
  from random import Random
5
  from typing import Callable
6
 
 
11
 
12
  PackChooser = Callable[[tuple[Card, ...], tuple[Card, ...]], int]
13
 
14
+ _CARD_ART = Path(__file__).parent / "assets" / "cards"
15
+ _backbone_art_cache: dict[str, str] = {}
16
+
17
+
18
+ # Return the pre-baked art data URI for a backbone card. The six backbone cards are
19
+ # identical every run, so their art is generated once and bundled (no live SDXL call,
20
+ # no shimmer).
21
+ def prebaked_backbone_art(name: str, theme: str) -> str:
22
+ world = re.sub(r"[^a-z0-9]+", "", theme.lower())
23
+ card = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
24
+ key = f"{world}__{card}"
25
+ if key not in _backbone_art_cache:
26
+ path = _CARD_ART / f"{key}.jpg"
27
+ _backbone_art_cache[key] = (
28
+ "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii") if path.exists() else ""
29
+ )
30
+ return _backbone_art_cache[key]
31
+
32
  BACKBONE_CARDS: tuple[tuple[str, int, Effect], ...] = (
33
  ("Attack (weak)", 1, Effect("deal", amount=2)),
34
  ("Attack (medium)", 3, Effect("deal", amount=4)),
 
44
  return tuple(backbone_card(name, cost, effect, school, theme) for name, cost, effect in BACKBONE_CARDS)
45
 
46
 
47
+ # Build one standardized backbone card, with pre-baked art so it never shimmers.
48
  def backbone_card(name: str, cost: int, effect: Effect, school: School, theme: str) -> Card:
49
+ return Card(name, cost, school, theme, (effect,), flavor=f"Standard {name.lower()}.", art_uri=prebaked_backbone_art(name, theme))
50
 
51
 
52
  # Build a full 15-card text deck with nine generated synergy cards.
tests/test_ui.py CHANGED
@@ -547,6 +547,6 @@ def test_battle_warms_visible_card_art() -> None:
547
  forge.drain()
548
  state = refresh_art(state)
549
  duel = state.duel
 
 
550
  assert all(card.art_uri for card in duel.player.hand)
551
- assert all(card.art_uri for card in duel.enemy.hand)
552
- assert any(not card.art_uri for card in duel.player.deck)
 
547
  forge.drain()
548
  state = refresh_art(state)
549
  duel = state.duel
550
+ # The player's hand is visible and fully illustrated; the enemy hand renders
551
+ # face-down (card backs), so its art is intentionally not warmed.
552
  assert all(card.art_uri for card in duel.player.hand)
 
 
ui.py CHANGED
@@ -385,8 +385,9 @@ def battle_opening_steps(
385
  )
386
  draw_cards(duel.player, OPENING_HAND_SIZE)
387
  draw_cards(duel.enemy, OPENING_HAND_SIZE)
 
 
388
  warm_card_art(art_client, duel.player.hand)
389
- warm_card_art(art_client, duel.enemy.hand)
390
  state = replace(
391
  state,
392
  enemy_deck=enemy_deck,
@@ -434,8 +435,8 @@ def collect_ready_battle(
434
  def warm_battle_art(art_client: ArtClient | None, state: RunState) -> None:
435
  if state.duel is None:
436
  return
 
437
  warm_card_art(art_client, state.duel.player.hand)
438
- warm_card_art(art_client, state.duel.enemy.hand)
439
  warm_card_art(art_client, tuple(card for _, card in state.showcase))
440
 
441
 
 
385
  )
386
  draw_cards(duel.player, OPENING_HAND_SIZE)
387
  draw_cards(duel.enemy, OPENING_HAND_SIZE)
388
+ # Only warm the player's hand: the enemy hand renders face-down (card backs),
389
+ # so generating its art just starves the art lane and the visible cards.
390
  warm_card_art(art_client, duel.player.hand)
 
391
  state = replace(
392
  state,
393
  enemy_deck=enemy_deck,
 
435
  def warm_battle_art(art_client: ArtClient | None, state: RunState) -> None:
436
  if state.duel is None:
437
  return
438
+ # Player hand + the played-card showcase are visible; the enemy hand is not.
439
  warm_card_art(art_client, state.duel.player.hand)
 
440
  warm_card_art(art_client, tuple(card for _, card in state.showcase))
441
 
442