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

Build Tabras card duel prototype

Browse files
Files changed (29) hide show
  1. .gitignore +4 -0
  2. README.md +36 -11
  3. agents.md +48 -0
  4. ai_runtime.py +61 -0
  5. app.py +656 -0
  6. boss.py +138 -0
  7. budget.py +146 -0
  8. clients.py +88 -0
  9. draft.py +82 -0
  10. game.py +235 -0
  11. generator.py +633 -0
  12. launch_ai.py +19 -0
  13. local_llm.py +277 -0
  14. play.py +388 -0
  15. primitives.py +124 -0
  16. pyproject.toml +10 -0
  17. tests/test_ai_runtime.py +39 -0
  18. tests/test_boss.py +187 -0
  19. tests/test_budget.py +95 -0
  20. tests/test_clients.py +100 -0
  21. tests/test_draft.py +108 -0
  22. tests/test_game.py +254 -0
  23. tests/test_generator.py +541 -0
  24. tests/test_local_llm.py +287 -0
  25. tests/test_play.py +314 -0
  26. tests/test_primitives.py +70 -0
  27. tests/test_ui.py +318 -0
  28. ui.py +655 -0
  29. uv.lock +3 -0
.gitignore CHANGED
@@ -51,6 +51,10 @@ coverage.xml
51
  .pytest_cache/
52
  cover/
53
 
 
 
 
 
54
  # Translations
55
  *.mo
56
  *.pot
 
51
  .pytest_cache/
52
  cover/
53
 
54
+ # Local artifacts
55
+ .DS_Store
56
+ models/
57
+
58
  # Translations
59
  *.mo
60
  *.pot
README.md CHANGED
@@ -21,7 +21,7 @@ The model NEVER sets final power values. This is what makes generated cards bala
21
  - **20 HP** per player.
22
  - **Energy** ramps each turn (1 → up to 5). Regenerating pool, no lands.
23
  - **Paired-round turns:** each round both players act; a flip decides order within the round. Knowing/manipulating order is a mechanic (Ice's Initiative).
24
- - **15-card deck:** 8 standardized backbone cards (same for all schools) + 7 LLM-generated synergy picks (drafted, deck-aware).
25
  - **Deck-out:** no reshuffle. Drawing from an empty deck deals escalating fatigue damage (1, 2, 3, …) — a finishing clock, not a managed resource. Decks are damage-dense; you're expected to win before decking out.
26
  - **Win:** reduce the opponent to 0 across a three-phase duel.
27
 
@@ -40,9 +40,9 @@ The LLM composes cards by selecting from these and writing flavor; the engine as
40
  3. **Bomb** — big delayed burst: deal X in N turns, telegraphed. High-variance. TUNE. *Deferred-timer effect.* (Fire signature pick: 4 energy → 9 damage in 3 turns.)
41
 
42
  ### Defense / survival (a first-class, flexible axis — defense enables real strategic options, not just a tax)
43
- 4. **Block** — temporary shield, this turn only. **Stacks.** `1 pt ≈ 3 block` (defense priced slightly cheaper than damage).
44
  5. **Ward** — persistent shield. **Stacks. Expensive: `1 pt = 1 block`.** All-or-nothing: a ward can only be removed early by a *single hit meeting its threshold* — chip/multi-hit damage bounces off. The breaking hit is **fully absorbed** (a bubble that eats one decisive blow, then vanishes — excess does NOT carry to HP). [DEFAULT — override if you want break-through.] *Deferred/persistent-timer effect.*
45
- 6. **Weak** — enemy deals Y less damage next turn. Defensive (damage-prevention). Prices like Block (linear). Mirror of Vulnerable.
46
 
47
  > Heal is intentionally NOT a primitive (cut for pace — healing prolongs fights against the short-aggressive goal). Defensive flexibility comes from Block + Ward + Weak instead. Revisit only if playtest shows fights ending too fast.
48
 
@@ -55,7 +55,7 @@ The LLM composes cards by selecting from these and writing flavor; the engine as
55
  10. **Multi-hit** — apply an effect N times. *Multiplies* the cost of what it hits. The single most dangerous primitive (multi-hit × Deal or × Burn). Overcost hard. Note: multi-hit chip CANNOT break a Ward (each hit is below threshold) — built-in school counterplay.
56
  11. **Vulnerable** — enemy takes X more damage for N turns. Offensive *multiplier* — combos with Deal/Multi-hit. Prices as a multiplier, not flat. TUNE.
57
  12. **Conditional** — "if [state], then bonus" (e.g., if enemy below half HP, +damage). Depth via sequencing; prices by how often the condition is live. TUNE.
58
- 13. **Scaling** — effect grows with a counter (cards played this turn, damage taken, turns elapsed). Snowball primitive — overcost, runaway games start here. TUNE.
59
 
60
  ### Vulnerable / Weak pair
61
  Vulnerable (enemy *takes* X more — affects damage you deal) and Weak (enemy *deals* Y less — affects damage you take) are mirrored levers. Despite the symmetry they price differently: Vulnerable is a multiplier (TUNE), Weak is flat damage-prevention (linear, like Block).
@@ -65,7 +65,7 @@ Burn, Bomb, and Ward all run on ONE shared deferred/persistent-effect timer syst
65
 
66
  ---
67
 
68
- ## The 8-card standardized backbone (same for every school)
69
 
70
  | Card | Cost | Effect |
71
  |------|------|--------|
@@ -74,9 +74,7 @@ Burn, Bomb, and Ward all run on ONE shared deferred/persistent-effect timer syst
74
  | Attack (strong) | 5 | deal 6 |
75
  | Block (weak) | 2 | block 3 |
76
  | Block (medium) | 3 | block 4 |
77
- | Block (strong) | 4 | block 5 |
78
  | Draw (small) | 2 | draw 1 |
79
- | Draw (large) | 3 | draw 2 |
80
 
81
  Identical across all schools. Only art/flavor reskins per run's theme. School identity lives entirely in the **draft pool**, not the backbone.
82
 
@@ -84,7 +82,7 @@ Identical across all schools. Only art/flavor reskins per run's theme. School id
84
 
85
  ## Schools (v1: three)
86
 
87
- School biases which primitives your 7 synergy picks draw from (a synergizable archetype), not your backbone. Theme = aesthetics only.
88
 
89
  Schools map to **player temperaments**, not just primitive bundles — the choice signals how you want to play:
90
 
@@ -93,7 +91,7 @@ Schools map to **player temperaments**, not just primitive bundles — the choic
93
  Signature pick (bomb): `4 energy: 9 damage, detonates in 3 turns`.
94
  - **Ice — speed (fast tempo).** For players who race: strike first, amplify, burst. NOT defensive — Ice outruns rather than turtles, so it carries no Block. Primitives: Initiative, Vulnerable, Multi-hit, Conditional.
95
  Anchor: `3 energy: deal 2, opponent goes second next round`.
96
- - **Earth — slow (durable control).** For players who outlast: grind, stack defense, win late. Defense concentrates HERE deliberately — this is the school's identity, not a tax. Primitives: Ward, Block, Scaling, Conditional.
97
  Anchor (refreshing ward): `4 energy: block 3 per turn for the next 2 turns`.
98
 
99
  Defense is deliberately NOT spread evenly: it concentrates in Earth (the slow school), is absent from Ice (speed doesn't block, it races), and is incidental in Fire (burn just out-damages). This makes the three schools feel different to *play*, not just to read.
@@ -112,6 +110,33 @@ Three models; two satisfy the Nemotron + MiniCPM sponsor requirement:
112
 
113
  **Real constraint is VRAM on the Space GPU, not the 32B rule.** A 30B-A3B boss needs full weights resident even though MoE keeps active params ~3B. Picker and boss can share one base model in two isolated contexts if VRAM is tight; the boss context must NOT see card-authoring history.
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  ---
116
 
117
  ## Components
@@ -132,7 +157,7 @@ Deterministic. Paired-round turn loop, energy ramp, initiative flip, card resolu
132
  Boss drafts its own deck from `generator.py` (synergy-biased) and the boss LLM plays it — picks pre-costed cards reactively. Boss cards capped at generation → play is inherently damage-bounded. 3 phases = 3 escalating drafted wizards (rising budget/energy). Phase N+1's deck generates during phase N combat.
133
 
134
  ### `draft.py`
135
- 8 backbone + 7 deck-aware synergy picks = 15. Draft UI structure is a thin swappable layer — finalize after core playtests.
136
 
137
  ### `app.py`
138
  Gradio Space. Cards = styled containers in rows. No engine, no animations. Core state (both HP, enemy intent, energy, card costs/effects) ALWAYS visible — never behind hover. Effect text TEMPLATED from the primitive schema (fixed per-primitive format), never free prose — parses in ~1s. Custom themed frames (Custom UI badge). Landing/menu loads model-free; generation fires on "Start," not page load (protects compute + cold-start UX).
@@ -165,7 +190,7 @@ Art, fine-tuning, polish come LAST. A playable, fun, text-only core is submittab
165
  - The model never sets numbers (except the engine-bounded, hard-capped miracle roll).
166
  - No board-state entities. Burn/Bomb/Ward/summons are deferred *effects*, never targetable units.
167
  - v1 scope is fixed: spell-only, 3 schools (Fire/Ice/Earth) × 3 themes, one art style. "If time permits" features stay deferred until the core ships and is tuned.
168
- - The 7 synergy picks must be generated **deck-aware** — picks that ignore the current deck reduce the generator to a random table.
169
 
170
  ---
171
 
 
21
  - **20 HP** per player.
22
  - **Energy** ramps each turn (1 → up to 5). Regenerating pool, no lands.
23
  - **Paired-round turns:** each round both players act; a flip decides order within the round. Knowing/manipulating order is a mechanic (Ice's Initiative).
24
+ - **15-card deck:** 6 standardized backbone cards (same for all schools) + 9 LLM-generated synergy picks (drafted, deck-aware).
25
  - **Deck-out:** no reshuffle. Drawing from an empty deck deals escalating fatigue damage (1, 2, 3, …) — a finishing clock, not a managed resource. Decks are damage-dense; you're expected to win before decking out.
26
  - **Win:** reduce the opponent to 0 across a three-phase duel.
27
 
 
40
  3. **Bomb** — big delayed burst: deal X in N turns, telegraphed. High-variance. TUNE. *Deferred-timer effect.* (Fire signature pick: 4 energy → 9 damage in 3 turns.)
41
 
42
  ### Defense / survival (a first-class, flexible axis — defense enables real strategic options, not just a tax)
43
+ 4. **Block** — temporary shield until your next turn. **Stacks.** `1 pt ≈ 3 block` (defense priced slightly cheaper than damage).
44
  5. **Ward** — persistent shield. **Stacks. Expensive: `1 pt = 1 block`.** All-or-nothing: a ward can only be removed early by a *single hit meeting its threshold* — chip/multi-hit damage bounces off. The breaking hit is **fully absorbed** (a bubble that eats one decisive blow, then vanishes — excess does NOT carry to HP). [DEFAULT — override if you want break-through.] *Deferred/persistent-timer effect.*
45
+ 6. **Weak** — opponent deals Y less damage until your next turn. Defensive (damage-prevention). Prices like Block (linear). Mirror of Vulnerable.
46
 
47
  > Heal is intentionally NOT a primitive (cut for pace — healing prolongs fights against the short-aggressive goal). Defensive flexibility comes from Block + Ward + Weak instead. Revisit only if playtest shows fights ending too fast.
48
 
 
55
  10. **Multi-hit** — apply an effect N times. *Multiplies* the cost of what it hits. The single most dangerous primitive (multi-hit × Deal or × Burn). Overcost hard. Note: multi-hit chip CANNOT break a Ward (each hit is below threshold) — built-in school counterplay.
56
  11. **Vulnerable** — enemy takes X more damage for N turns. Offensive *multiplier* — combos with Deal/Multi-hit. Prices as a multiplier, not flat. TUNE.
57
  12. **Conditional** — "if [state], then bonus" (e.g., if enemy below half HP, +damage). Depth via sequencing; prices by how often the condition is live. TUNE.
58
+ 13. **Scaling** — effect grows with a counter (cards played this turn, banked shield charge, turns elapsed). Snowball primitive — overcost, runaway games start here. TUNE. Earth scaling spends banked shield charge.
59
 
60
  ### Vulnerable / Weak pair
61
  Vulnerable (enemy *takes* X more — affects damage you deal) and Weak (enemy *deals* Y less — affects damage you take) are mirrored levers. Despite the symmetry they price differently: Vulnerable is a multiplier (TUNE), Weak is flat damage-prevention (linear, like Block).
 
65
 
66
  ---
67
 
68
+ ## The 6-card standardized backbone (same for every school)
69
 
70
  | Card | Cost | Effect |
71
  |------|------|--------|
 
74
  | Attack (strong) | 5 | deal 6 |
75
  | Block (weak) | 2 | block 3 |
76
  | Block (medium) | 3 | block 4 |
 
77
  | Draw (small) | 2 | draw 1 |
 
78
 
79
  Identical across all schools. Only art/flavor reskins per run's theme. School identity lives entirely in the **draft pool**, not the backbone.
80
 
 
82
 
83
  ## Schools (v1: three)
84
 
85
+ School biases which primitives your 9 synergy picks draw from (a synergizable archetype), not your backbone. Theme = aesthetics only.
86
 
87
  Schools map to **player temperaments**, not just primitive bundles — the choice signals how you want to play:
88
 
 
91
  Signature pick (bomb): `4 energy: 9 damage, detonates in 3 turns`.
92
  - **Ice — speed (fast tempo).** For players who race: strike first, amplify, burst. NOT defensive — Ice outruns rather than turtles, so it carries no Block. Primitives: Initiative, Vulnerable, Multi-hit, Conditional.
93
  Anchor: `3 energy: deal 2, opponent goes second next round`.
94
+ - **Earth — slow (durable control).** For players who outlast: grind, stack defense, win late. Half of damage absorbed by Block, rounded down, banks as shield charge; Ward does not bank charge. Earth Scaling releases that banked charge as burst damage, then empties it. Defense concentrates HERE deliberately — this is the school's identity, not a tax. Primitives: Ward, Block, Scaling, Conditional.
95
  Anchor (refreshing ward): `4 energy: block 3 per turn for the next 2 turns`.
96
 
97
  Defense is deliberately NOT spread evenly: it concentrates in Earth (the slow school), is absent from Ice (speed doesn't block, it races), and is incidental in Fire (burn just out-damages). This makes the three schools feel different to *play*, not just to read.
 
110
 
111
  **Real constraint is VRAM on the Space GPU, not the 32B rule.** A 30B-A3B boss needs full weights resident even though MoE keeps active params ~3B. Picker and boss can share one base model in two isolated contexts if VRAM is tight; the boss context must NOT see card-authoring history.
112
 
113
+ Local model wiring supports three modes:
114
+
115
+ ```bash
116
+ # MiniCPM through an OpenAI-compatible local chat server.
117
+ TABRAS_CARD_ENDPOINT=http://localhost:8080/v1/chat/completions
118
+ TABRAS_CARD_MODEL=minicpm-v-4.6
119
+
120
+ # MiniCPM direct Transformers model.chat path.
121
+ TABRAS_CARD_BACKEND=transformers
122
+ TABRAS_CARD_MODEL=openbmb/MiniCPM-V-4
123
+
124
+ # Nemotron through an OpenAI-compatible chat server.
125
+ TABRAS_AI_BOSS=1
126
+ TABRAS_BOSS_ENDPOINT=http://localhost:8081/v1/chat/completions
127
+ TABRAS_BOSS_MODEL=nvidia/Nemotron-Mini-4B-Instruct
128
+
129
+ # Nemotron through a completion server with the documented <extra_id_*> prompt markers.
130
+ TABRAS_AI_BOSS=1
131
+ TABRAS_BOSS_BACKEND=completion
132
+ TABRAS_BOSS_ENDPOINT=http://localhost:8081/v1/completions
133
+
134
+ # Nemotron direct Transformers path using tokenizer.apply_chat_template.
135
+ TABRAS_AI_BOSS=1
136
+ TABRAS_BOSS_BACKEND=transformers
137
+ TABRAS_BOSS_MODEL=nvidia/Nemotron-Mini-4B-Instruct
138
+ ```
139
+
140
  ---
141
 
142
  ## Components
 
157
  Boss drafts its own deck from `generator.py` (synergy-biased) and the boss LLM plays it — picks pre-costed cards reactively. Boss cards capped at generation → play is inherently damage-bounded. 3 phases = 3 escalating drafted wizards (rising budget/energy). Phase N+1's deck generates during phase N combat.
158
 
159
  ### `draft.py`
160
+ 6 backbone + 9 deck-aware synergy picks = 15. Draft UI structure is a thin swappable layer — finalize after core playtests.
161
 
162
  ### `app.py`
163
  Gradio Space. Cards = styled containers in rows. No engine, no animations. Core state (both HP, enemy intent, energy, card costs/effects) ALWAYS visible — never behind hover. Effect text TEMPLATED from the primitive schema (fixed per-primitive format), never free prose — parses in ~1s. Custom themed frames (Custom UI badge). Landing/menu loads model-free; generation fires on "Start," not page load (protects compute + cold-start UX).
 
190
  - The model never sets numbers (except the engine-bounded, hard-capped miracle roll).
191
  - No board-state entities. Burn/Bomb/Ward/summons are deferred *effects*, never targetable units.
192
  - v1 scope is fixed: spell-only, 3 schools (Fire/Ice/Earth) × 3 themes, one art style. "If time permits" features stay deferred until the core ships and is tuned.
193
+ - The 9 synergy picks must be generated **deck-aware** — picks that ignore the current deck reduce the generator to a random table.
194
 
195
  ---
196
 
agents.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md — Tabras
2
+
3
+ Engineering conventions for any agent or contributor working in this repo. Read alongside `README.md` (the design spec). README says *what* to build; this says *how* to write it.
4
+
5
+ ## Non-negotiables
6
+
7
+ ### Tests accompany all code
8
+ - No code lands without tests. A feature and its tests are one unit of work, written together — not "tests later."
9
+ - The deterministic core (`primitives.py`, `budget.py`, `game.py`) is pure logic with no model calls — it is fully testable and MUST be fully tested. This is where bugs hide and where balance lives.
10
+ - Model-dependent code (`generator.py`, `boss.py`) is tested by mocking the model: assert the tool-call schema, the deck-context wiring, and that engine numbers (not model output) set magnitudes. Never test against live model output.
11
+
12
+ ### Coverage is the bloat metric
13
+ - Track code coverage on every change. Treat uncovered lines as **bloat**: if coverage is 50%, half the code is untested weight that should either be tested or deleted.
14
+ - Target high coverage on the deterministic core specifically — it's pure and has no excuse to be uncovered.
15
+ - Coverage is a deletion signal, not just a test signal: uncovered code is a prompt to ask "is this needed at all?"
16
+
17
+ ### Functions do one thing
18
+ - One function, one responsibility. If a function does two things, it's two functions.
19
+ - As simple as possible, complex only where the problem genuinely requires it. Reach for complexity reluctantly and only when the domain demands it.
20
+ - A function you can't describe in one line (see comments) is doing too much — split it.
21
+
22
+ ### Terse, readable code
23
+ - Terseness in service of readability, never against it. Shorter is better *only* when it reads more clearly.
24
+ - No cleverness that costs a reader a second look. Obvious beats impressive.
25
+ - Delete aggressively. The best code is no code; the second best is code that's plainly necessary.
26
+
27
+ ### Comments: one line, at the function line
28
+ - One-line comment at the function definition stating what it does. That's the default.
29
+ - No block comments narrating internals, no inline play-by-play. If the body needs explaining, the function is too complex — simplify it instead of annotating it.
30
+ - Comments state *what/why* at the boundary, never *how* line-by-line (the code is the how).
31
+
32
+ ## In practice
33
+ - The one-line function comment and the "describe in one line" rule are the same rule: if you can't write the comment, the function isn't doing one thing.
34
+ - Coverage + terseness + one-thing functions reinforce each other: small single-purpose functions are trivial to test, which drives coverage, which exposes bloat to delete.
35
+ - When in doubt: smaller function, clearer name, a test, delete the rest.
36
+
37
+ ## What this looks like
38
+ ```python
39
+ # Convert a card's energy cost into its point budget.
40
+ def budget_for(cost: int) -> int:
41
+ return cost
42
+ ```
43
+ Single purpose, one-line comment at the definition, terse, testable. A companion test asserts the mapping. No internal commentary.
44
+
45
+ ## Order of work (per the README build order)
46
+ Each step ships with its tests before the next begins:
47
+ `primitives.py` → `budget.py` → `generator.py` + `game.py` → **playtest** → `boss.py` → `draft.py` → art → fine-tune.
48
+ Do not move to the next file until the current one is tested and covered.
ai_runtime.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+
6
+ DEFAULT_MINICPM_MODEL_PATH = Path("models/minicpm-v-4.6-gguf/MiniCPM-V-4.6-Q4_K_M.gguf")
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.
13
+ def chat_endpoint(port: int) -> str:
14
+ return f"http://127.0.0.1:{port}/v1/chat/completions"
15
+
16
+
17
+ # Return the llama.cpp server command for the local MiniCPM GGUF.
18
+ def minicpm_server_command(model_path: Path = DEFAULT_MINICPM_MODEL_PATH, port: int = DEFAULT_CARD_PORT) -> tuple[str, ...]:
19
+ return (
20
+ "llama-server",
21
+ "--model",
22
+ str(model_path),
23
+ "--host",
24
+ "127.0.0.1",
25
+ "--port",
26
+ str(port),
27
+ "--ctx-size",
28
+ "4096",
29
+ "--n-gpu-layers",
30
+ "99",
31
+ )
32
+
33
+
34
+ # Return the environment that forces MiniCPM card authoring and Nemotron boss play.
35
+ def local_ai_env(card_port: int = DEFAULT_CARD_PORT) -> dict[str, str]:
36
+ env = dict(os.environ)
37
+ env.update(
38
+ {
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
52
+
53
+
54
+ # Start the MiniCPM llama.cpp server process.
55
+ def start_minicpm_server(
56
+ model_path: Path = DEFAULT_MINICPM_MODEL_PATH,
57
+ port: int = DEFAULT_CARD_PORT,
58
+ ) -> subprocess.Popen[str]:
59
+ if not model_path.exists():
60
+ raise FileNotFoundError(f"MiniCPM model not found: {model_path}")
61
+ return subprocess.Popen(minicpm_server_command(model_path, port), text=True)
app.py ADDED
@@ -0,0 +1,656 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) {
23
+ const el = document.getElementById(id);
24
+ if (!el) return;
25
+ (el.tagName === 'BUTTON' ? el : el.querySelector('button')).click();
26
+ }
27
+ function tabrasPlay(id, card) {
28
+ if (card.classList.contains('launching')) return;
29
+ card.classList.add('launching');
30
+ tabrasClick(id);
31
+ }
32
+ </script>
33
+ """
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;
48
+ border: none !important;
49
+ box-shadow: none !important;
50
+ }
51
+ button.primary {
52
+ background: linear-gradient(180deg, #f2c24d, #a96f17) !important;
53
+ color: #3a2403 !important;
54
+ border: 2px solid #f6dd9a !important;
55
+ font-weight: 800 !important;
56
+ }
57
+
58
+ .tabras-title {
59
+ min-height: 70vh;
60
+ display: flex;
61
+ flex-direction: column;
62
+ justify-content: center;
63
+ align-items: center;
64
+ text-align: center;
65
+ }
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;
93
+ gap: 4px;
94
+ }
95
+ .tabras-card.empty { opacity: 0.35; }
96
+ .card-cost {
97
+ position: absolute;
98
+ top: -9px;
99
+ left: -9px;
100
+ width: 34px;
101
+ height: 34px;
102
+ border-radius: 50%;
103
+ background: radial-gradient(circle at 35% 30%, #7fd4ff, #1668c9);
104
+ border: 2px solid #d8f1ff;
105
+ color: #fff;
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ font-weight: 900;
110
+ font-size: 17px;
111
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
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; }
146
+ }
147
+ .draft-pack .draft-card.fading { animation: pack-out 0.7s ease forwards; animation-delay: 0s; pointer-events: none; cursor: default; }
148
+ .draft-pack .draft-card.picked { animation: picked-out 0.7s ease forwards; animation-delay: 0s; pointer-events: none; cursor: default; }
149
+ @keyframes pack-out {
150
+ from { opacity: 1; }
151
+ to { opacity: 0; transform: translateY(-14px) scale(0.94); filter: grayscale(0.7); }
152
+ }
153
+ @keyframes picked-out {
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; }
178
+ .zone-center { display: flex; flex-direction: column; align-items: center; gap: 6px; flex: 1; }
179
+ .piles { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; }
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; }
193
+ .hero-frame { position: relative; width: 124px; height: 124px; }
194
+ .hero-face {
195
+ width: 100%;
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;
203
+ font-weight: 900;
204
+ font-size: 22px;
205
+ color: #f4d69b;
206
+ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
207
+ }
208
+ .hp-gem, .block-gem, .ward-gem {
209
+ position: absolute;
210
+ width: 42px;
211
+ height: 42px;
212
+ border-radius: 50%;
213
+ display: flex;
214
+ align-items: center;
215
+ justify-content: center;
216
+ font-weight: 900;
217
+ font-size: 19px;
218
+ color: #fff;
219
+ text-shadow: 0 1px 2px #000;
220
+ border: 3px solid #f3d492;
221
+ }
222
+ .hp-gem { right: -10px; bottom: -6px; background: radial-gradient(circle at 35% 30%, #e0524d, #8f1612); }
223
+ .block-gem { left: -10px; bottom: -6px; background: radial-gradient(circle at 35% 30%, #c9cdd4, #5a626e); border-color: #eef2f7; }
224
+ .ward-gem { left: -10px; top: -6px; background: radial-gradient(circle at 35% 30%, #8fd0ff, #1d4d8f); border-color: #d8f1ff; }
225
+ .hero-name { font-weight: 800; color: #f4d69b; text-shadow: 0 2px 6px #000; }
226
+ .hero-frame.hit { animation: hero-shake 0.45s ease; }
227
+ .hero-frame.hit .hp-gem { animation: gem-flash 0.7s ease; }
228
+ @keyframes hero-shake {
229
+ 0%, 100% { transform: translateX(0); }
230
+ 20% { transform: translateX(-7px) rotate(-2deg); }
231
+ 40% { transform: translateX(6px) rotate(2deg); }
232
+ 60% { transform: translateX(-4px); }
233
+ 80% { transform: translateX(3px); }
234
+ }
235
+ @keyframes gem-flash {
236
+ 0%, 100% { filter: none; }
237
+ 30% { filter: brightness(2.2) saturate(1.6); transform: scale(1.25); }
238
+ }
239
+ .dmg-pop {
240
+ position: absolute;
241
+ left: 50%;
242
+ top: 22%;
243
+ transform: translateX(-50%);
244
+ font-size: 34px;
245
+ font-weight: 900;
246
+ color: #ff5340;
247
+ text-shadow: 0 0 12px rgba(255, 60, 30, 0.9), 0 2px 3px #000;
248
+ pointer-events: none;
249
+ z-index: 50;
250
+ animation: dmg-float 1.4s ease-out forwards;
251
+ }
252
+ @keyframes dmg-float {
253
+ from { opacity: 0; transform: translateX(-50%) translateY(16px) scale(0.5); }
254
+ 25% { opacity: 1; transform: translateX(-50%) translateY(-8px) scale(1.3); }
255
+ to { opacity: 0; transform: translateX(-50%) translateY(-54px) scale(1); }
256
+ }
257
+ .chips { display: flex; gap: 6px; min-height: 18px; }
258
+ .chip { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 8px; border: 1px solid; }
259
+ .chip.charge { color: #ffd98c; border-color: #c9982f; background: rgba(120, 85, 10, 0.35); }
260
+ .chip.weak { color: #a8c6ff; border-color: #4a6fb5; background: rgba(30, 55, 110, 0.35); }
261
+ .chip.vuln { color: #ffb3c2; border-color: #b54a64; background: rgba(110, 25, 45, 0.35); }
262
+
263
+ .enemy-hand { display: flex; justify-content: center; margin-bottom: -10px; }
264
+ .enemy-card-back {
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
+ }
274
+ .enemy-card-back:nth-child(odd) { transform: rotate(-3deg); }
275
+
276
+ .battlefield {
277
+ position: relative;
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;
285
+ justify-content: space-between;
286
+ padding: 6px 150px 6px 16px;
287
+ }
288
+ .round-banner {
289
+ position: absolute;
290
+ left: 16px;
291
+ top: 50%;
292
+ transform: translateY(-50%);
293
+ font-weight: 800;
294
+ letter-spacing: 0.12em;
295
+ text-transform: uppercase;
296
+ color: #e9cf9b;
297
+ font-size: 13px;
298
+ }
299
+ .end-turn {
300
+ position: absolute;
301
+ right: 14px;
302
+ top: 50%;
303
+ transform: translateY(-50%);
304
+ padding: 13px 24px;
305
+ border-radius: 26px;
306
+ border: 3px solid #f6dd9a;
307
+ background: linear-gradient(180deg, #f2c24d, #a96f17);
308
+ color: #3a2403;
309
+ font-weight: 900;
310
+ letter-spacing: 0.06em;
311
+ cursor: pointer;
312
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
313
+ }
314
+ .end-turn:hover { filter: brightness(1.12); }
315
+ .end-turn.pulse { animation: end-pulse 1.2s ease-in-out infinite; }
316
+ @keyframes end-pulse {
317
+ 0%, 100% { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); }
318
+ 50% { box-shadow: 0 0 26px rgba(246, 221, 154, 0.9), 0 6px 18px rgba(0, 0, 0, 0.45); }
319
+ }
320
+ .board.quake { animation: board-quake 0.5s ease; }
321
+ @keyframes board-quake {
322
+ 0%, 100% { transform: translate(0, 0); }
323
+ 20% { transform: translate(-6px, 3px); }
324
+ 40% { transform: translate(5px, -4px); }
325
+ 60% { transform: translate(-4px, 2px); }
326
+ 80% { transform: translate(3px, -2px); }
327
+ }
328
+ .pending-row { display: flex; gap: 8px; min-height: 34px; justify-content: center; }
329
+ .showcase { display: flex; gap: 18px; justify-content: center; align-items: flex-start; min-height: 0; perspective: 900px; }
330
+ .play-slot {
331
+ display: flex;
332
+ flex-direction: column;
333
+ align-items: center;
334
+ gap: 4px;
335
+ }
336
+ .play-slot.fresh { animation: play-in 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) backwards; }
337
+ .play-label {
338
+ font-size: 10px;
339
+ font-weight: 800;
340
+ letter-spacing: 0.14em;
341
+ text-transform: uppercase;
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; }
353
+ to { transform: none; opacity: 1; }
354
+ }
355
+ .token { display: flex; flex-direction: column; align-items: center; padding: 2px 10px; border-radius: 10px; font-size: 11px; border: 2px solid; line-height: 1.1; }
356
+ .token b { font-size: 16px; }
357
+ .token.bomb { border-color: #e0524d; background: rgba(120, 20, 10, 0.5); color: #ffb9a8; }
358
+ .token.burn { border-color: #f2913d; background: rgba(140, 70, 10, 0.45); color: #ffd9a8; }
359
+
360
+ .mana-bar { display: flex; align-items: center; gap: 6px; }
361
+ .mana { width: 19px; height: 19px; transform: rotate(45deg); border-radius: 5px; background: #1d2c3c; border: 2px solid #2f4a66; }
362
+ .mana.filled {
363
+ background: radial-gradient(circle at 35% 30%, #7fd4ff, #1668c9);
364
+ border-color: #bfe9ff;
365
+ box-shadow: 0 0 8px rgba(80, 170, 255, 0.8);
366
+ }
367
+ .mana-count { margin-left: 8px; font-weight: 800; color: #bfe9ff; }
368
+
369
+ .hand-fan { display: flex; justify-content: center; align-items: flex-end; padding: 26px 0 6px; min-height: 240px; }
370
+ .hand-card {
371
+ width: 150px;
372
+ margin: 0 -16px;
373
+ transform: rotate(var(--rot)) translateY(var(--ty));
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;
389
+ opacity: 0;
390
+ transition: transform 0.3s ease-in, opacity 0.3s ease-in;
391
+ pointer-events: none;
392
+ }
393
+ .round-splash {
394
+ position: absolute;
395
+ inset: 0;
396
+ display: flex;
397
+ flex-direction: column;
398
+ gap: 12px;
399
+ align-items: center;
400
+ justify-content: center;
401
+ z-index: 150;
402
+ pointer-events: none;
403
+ border-radius: 18px;
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 {
421
+ font-size: 24px;
422
+ font-weight: 900;
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); }
431
+ 70% { opacity: 1; }
432
+ 100% { opacity: 0; transform: scale(0.92); }
433
+ }
434
+ .boss-thinking {
435
+ position: absolute;
436
+ left: 50%;
437
+ bottom: -6px;
438
+ transform: translateX(-50%);
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;
448
+ white-space: nowrap;
449
+ animation: think-pulse 1.1s ease-in-out infinite;
450
+ }
451
+ @keyframes think-pulse {
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;
459
+ inset: 0;
460
+ display: flex;
461
+ flex-direction: column;
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
+ }
469
+ .winner-banner h1 { font-size: 64px; letter-spacing: 0.2em; margin: 0; animation: banner-in 0.7s cubic-bezier(0.2, 1.6, 0.4, 1) backwards; }
470
+ @keyframes banner-in {
471
+ from { transform: scale(2.6); opacity: 0; letter-spacing: 0.6em; }
472
+ to { transform: scale(1); opacity: 1; letter-spacing: 0.2em; }
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;
480
+ border: 3px solid #f6dd9a;
481
+ background: linear-gradient(180deg, #f2c24d, #a96f17);
482
+ font-weight: 900;
483
+ cursor: pointer;
484
+ color: #3a2403;
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;
492
+ gap: 4px;
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()
539
+
540
+ with gr.Group(visible=False, elem_id="battle-screen") as battle_group:
541
+ with gr.Row():
542
+ with gr.Column(scale=4):
543
+ board_view = gr.HTML()
544
+ with gr.Column(scale=1, min_width=240, elem_classes=["log-panel"]):
545
+ gr.Markdown("### Battle log")
546
+ log_view = gr.HTML()
547
+
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
+
567
+
568
+ # Bind one index into a streaming handler as a true generator function.
569
+ def indexed_handler(handler, index: int):
570
+ def stream(run_state):
571
+ yield from handler(run_state, index)
572
+
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
+
604
+
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
616
+ for state in steps:
617
+ if previous is not None:
618
+ time.sleep(frame_delay(previous))
619
+ yield render(state, "battle" if state.duel else "draft")
620
+ previous = state
621
+
622
+
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"}:
651
+ return value # type: ignore[return-value]
652
+ return "fire"
653
+
654
+
655
+ if __name__ == "__main__":
656
+ build_app().launch(server_name="127.0.0.1", server_port=7860, css=CSS, head=HEAD)
boss.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ import json
3
+ from typing import Any, Protocol
4
+
5
+ from game import DuelState, PlayerState, opponent
6
+ from generator import extract_json_object
7
+ from local_llm import LocalChatClient
8
+ from play import Chooser, choose_enemy_card, playable_indexes
9
+
10
+
11
+ class BossClient(Protocol):
12
+ # Return one raw boss decision payload.
13
+ def choose_cards(self, payload: dict[str, Any]) -> dict[str, Any]: # pragma: no cover
14
+ ...
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class HeuristicBossClient:
19
+ # Return a deterministic fallback boss decision.
20
+ def choose_cards(self, payload: dict[str, Any]) -> dict[str, Any]:
21
+ return {"card_indexes": []}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class NemotronBossClient:
26
+ chat: LocalChatClient
27
+
28
+ # Return one boss decision from a local Nemotron endpoint.
29
+ def choose_cards(self, payload: dict[str, Any]) -> dict[str, Any]:
30
+ text = self.chat.complete(boss_system_prompt(), boss_prompt(payload))
31
+ return json.loads(extract_json_object(text))
32
+
33
+
34
+ # Build visible state for a boss decision.
35
+ def boss_payload(state: DuelState, actor: PlayerState) -> dict[str, Any]:
36
+ target = opponent(state, actor)
37
+ return {
38
+ "round": state.round_number,
39
+ "actor": player_payload(actor),
40
+ "opponent": player_payload(target),
41
+ "hand": hand_payload(actor),
42
+ "pending": [effect.__dict__ for effect in state.pending],
43
+ "playable_indexes": playable_indexes(actor),
44
+ "instruction": "Choose playable hand indexes to play in order. Do not invent cards or numbers.",
45
+ }
46
+
47
+
48
+ # Return the system prompt for boss decisions.
49
+ def boss_system_prompt() -> str:
50
+ return "You are the Tabras boss AI. Return strict JSON only."
51
+
52
+
53
+ # Return the user prompt for one boss decision.
54
+ def boss_prompt(payload: dict[str, Any]) -> str:
55
+ return "\n".join(
56
+ (
57
+ "Choose the boss card indexes for this turn.",
58
+ "Return exactly this JSON shape: {\"card_indexes\": [int], \"reason\": str}",
59
+ "Only choose indexes from playable_indexes. Choose an ordered sequence the current energy can pay for.",
60
+ "Priorities: lethal, avoid dying, exploit shield charge, efficient damage, draw/setup.",
61
+ json.dumps(payload),
62
+ )
63
+ )
64
+
65
+
66
+ # Return visible player state for boss reasoning.
67
+ def player_payload(player: PlayerState) -> dict[str, Any]:
68
+ return {
69
+ "name": player.name,
70
+ "hp": player.hp,
71
+ "energy": player.energy,
72
+ "block": player.block,
73
+ "ward": player.ward,
74
+ "shield_charge": player.shield_charge,
75
+ "hand_count": len(player.hand),
76
+ "deck_count": len(player.deck),
77
+ "discard_count": len(player.discard),
78
+ }
79
+
80
+
81
+ # Return visible hand cards for boss reasoning.
82
+ def hand_payload(player: PlayerState) -> list[dict[str, Any]]:
83
+ return [
84
+ {"index": index, "name": card.name, "cost": card.cost, "rules_text": card.rules_text()}
85
+ for index, card in enumerate(player.hand)
86
+ ]
87
+
88
+
89
+ # Choose one boss action using model output with heuristic fallback.
90
+ def boss_chooser(client: BossClient | None = None, fallback: Chooser = choose_enemy_card) -> Chooser:
91
+ # Choose one validated model card, then fall back when empty.
92
+ def choose(state: DuelState, actor: PlayerState) -> int | None:
93
+ indexes = safe_boss_indexes(client, state, actor) if client else ()
94
+ if indexes:
95
+ return indexes[0]
96
+ return fallback(state, actor)
97
+
98
+ return choose
99
+
100
+
101
+ # Return model-selected indexes, treating model failures as no choice.
102
+ def safe_boss_indexes(client: BossClient, state: DuelState, actor: PlayerState) -> tuple[int, ...]:
103
+ try:
104
+ return boss_indexes(client, state, actor)
105
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
106
+ return ()
107
+
108
+
109
+ # Return validated model-selected card indexes.
110
+ def boss_indexes(client: BossClient, state: DuelState, actor: PlayerState) -> tuple[int, ...]:
111
+ raw = client.choose_cards(boss_payload(state, actor))
112
+ indexes = parse_card_indexes(raw)
113
+ return affordable_indexes(actor, indexes)
114
+
115
+
116
+ # Parse model-selected hand indexes.
117
+ def parse_card_indexes(raw: dict[str, Any]) -> tuple[int, ...]:
118
+ values = raw.get("card_indexes", [])
119
+ if not isinstance(values, list):
120
+ return ()
121
+ return tuple(value for value in values if isinstance(value, int))
122
+
123
+
124
+ # Return legal, affordable indexes in sequence.
125
+ def affordable_indexes(actor: PlayerState, indexes: tuple[int, ...]) -> tuple[int, ...]:
126
+ energy = actor.energy
127
+ accepted: list[int] = []
128
+ used: set[int] = set()
129
+ for index in indexes:
130
+ if index in used or index not in playable_indexes(actor):
131
+ continue
132
+ card = actor.hand[index]
133
+ if card.cost > energy:
134
+ continue
135
+ energy -= card.cost
136
+ used.add(index)
137
+ accepted.append(index)
138
+ return tuple(accepted)
budget.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from random import Random
3
+ from typing import Sequence
4
+
5
+ from primitives import Effect, PrimitiveId, School, primitive_allowed_for_school, render_effect
6
+
7
+ MAX_ENERGY_COST = 5
8
+ MIRACLE_BUDGET_CAP = 8
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class EffectPlan:
13
+ primitive_id: PrimitiveId
14
+ weight: int = 1
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CardSpec:
19
+ name: str
20
+ cost: int
21
+ school: School
22
+ theme: str
23
+ effect_plans: tuple[EffectPlan, ...]
24
+ flavor: str = ""
25
+ art_prompt: str = ""
26
+ miracle: bool = False
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Card:
31
+ name: str
32
+ cost: int
33
+ school: School
34
+ theme: str
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:
41
+ return " ".join(render_effect(effect) for effect in self.effects)
42
+
43
+
44
+ # Convert energy cost into point budget.
45
+ def budget_for_energy(cost: int) -> int:
46
+ if cost < 1 or cost > MAX_ENERGY_COST:
47
+ raise ValueError(f"card cost must be 1..{MAX_ENERGY_COST}")
48
+ return cost
49
+
50
+
51
+ # Roll the bounded high-variance miracle budget.
52
+ def miracle_budget(cost: int, rng: Random) -> int:
53
+ return min(MIRACLE_BUDGET_CAP, budget_for_energy(cost) * rng.randint(1, 3))
54
+
55
+
56
+ # Build a fully costed card from model-chosen effect shapes.
57
+ def cost_card(spec: CardSpec, rng: Random | None = None) -> Card:
58
+ validate_card_spec(spec)
59
+ point_budget = miracle_budget(spec.cost, rng or Random()) if spec.miracle else budget_for_energy(spec.cost)
60
+ effects = cost_effects(point_budget, spec.effect_plans, spec.school)
61
+ return Card(spec.name, spec.cost, spec.school, spec.theme, effects, spec.flavor, spec.art_prompt)
62
+
63
+
64
+ # Build fully costed effects from weighted primitive plans.
65
+ def cost_effects(point_budget: int, plans: Sequence[EffectPlan], school: School | None = None) -> tuple[Effect, ...]:
66
+ usable_plans = tuple(plans[:point_budget])
67
+ total_weight = sum(plan.weight for plan in usable_plans)
68
+ taxed = len(usable_plans) > 1
69
+ effects: list[Effect] = []
70
+ spent = 0
71
+ for index, plan in enumerate(usable_plans):
72
+ remaining = point_budget - spent
73
+ points = remaining if index == len(usable_plans) - 1 else max(1, point_budget * plan.weight // total_weight)
74
+ spent += points
75
+ effects.append(cost_effect(plan.primitive_id, points, taxed, school))
76
+ return tuple(effects)
77
+
78
+
79
+ # Build one fully costed effect from an allocated point budget.
80
+ def cost_effect(primitive_id: PrimitiveId, points: int, taxed: bool = False, school: School | None = None) -> Effect:
81
+ if points < 1:
82
+ raise ValueError("effect points must be positive")
83
+ match primitive_id:
84
+ case "deal":
85
+ return Effect("deal", amount=damage_amount(points, taxed))
86
+ case "burn":
87
+ return Effect("burn", amount=burn_amount(points, taxed), duration=2)
88
+ case "bomb":
89
+ return Effect("bomb", amount=damage_amount(points, taxed), delay=3)
90
+ case "block":
91
+ return Effect("block", amount=points * (2 if taxed else 3))
92
+ case "ward":
93
+ return Effect("ward", amount=points)
94
+ case "weak":
95
+ return Effect("weak", amount=damage_amount(points, taxed), duration=1)
96
+ case "draw":
97
+ return Effect("draw", amount=max(1, points * 2 // 3))
98
+ case "energy":
99
+ return Effect("energy", amount=max(1, points // 2))
100
+ case "initiative":
101
+ return Effect("initiative", duration=1)
102
+ case "multi_hit":
103
+ return Effect("multi_hit", amount=max(1, points), hits=2 if points < 4 else 3)
104
+ case "vulnerable":
105
+ return Effect("vulnerable", amount=max(1, points), duration=1 if points < 4 else 2)
106
+ case "conditional":
107
+ return Effect("conditional", amount=conditional_amount(points, taxed), condition="opponent_below_half")
108
+ case "scaling":
109
+ return Effect("scaling", amount=points, condition=scaling_condition(school))
110
+
111
+
112
+ # Return direct damage for a point allocation.
113
+ def damage_amount(points: int, taxed: bool) -> int:
114
+ return max(1, points * 2 - (1 if taxed else 0))
115
+
116
+
117
+ # Return burn damage per tick for a point allocation.
118
+ def burn_amount(points: int, taxed: bool) -> int:
119
+ return max(1, points - (1 if taxed and points > 1 else 0))
120
+
121
+
122
+ # Return maximum scaled conditional damage for a point allocation.
123
+ def conditional_amount(points: int, taxed: bool) -> int:
124
+ return max(1, points - (1 if taxed and points > 1 else 0))
125
+
126
+
127
+ # Return the scaling counter used by a school.
128
+ def scaling_condition(school: School | None) -> str:
129
+ return "shield_charge" if school == "earth" else "cards_played"
130
+
131
+
132
+ # Validate a model-authored card spec before costing it.
133
+ def validate_card_spec(spec: CardSpec) -> None:
134
+ budget_for_energy(spec.cost)
135
+ if not spec.effect_plans:
136
+ raise ValueError("card must have at least one effect")
137
+ for plan in spec.effect_plans:
138
+ validate_effect_plan(plan, spec.school)
139
+
140
+
141
+ # Validate one model-chosen effect plan.
142
+ def validate_effect_plan(plan: EffectPlan, school: School) -> None:
143
+ if plan.weight < 1:
144
+ raise ValueError("effect weight must be positive")
145
+ if not primitive_allowed_for_school(plan.primitive_id, school):
146
+ raise ValueError(f"{plan.primitive_id} is not allowed for {school}")
clients.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from boss import BossClient, NemotronBossClient
4
+ from generator import CardPackClient, LlamaCppCardClient, MiniCPMCardClient
5
+ from local_llm import (
6
+ LocalChatClient,
7
+ LocalCompletionClient,
8
+ LocalJsonChatClient,
9
+ MLXChatClient,
10
+ MiniCPMTransformersChatClient,
11
+ NemotronTransformersChatClient,
12
+ nemotron_prompt,
13
+ )
14
+
15
+ DEFAULT_CARD_ENDPOINT = "http://localhost:8080/v1/chat/completions"
16
+ DEFAULT_LLAMA_CARD_ENDPOINT = "http://127.0.0.1:8090/v1/chat/completions"
17
+ DEFAULT_BOSS_ENDPOINT = "http://localhost:8081/v1/chat/completions"
18
+ DEFAULT_BOSS_COMPLETION_ENDPOINT = "http://localhost:8081/v1/completions"
19
+ 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.
26
+ def card_client_from_env() -> CardPackClient | None:
27
+ if os.environ.get("TABRAS_CARD_BACKEND") == "llamacpp":
28
+ return LlamaCppCardClient(
29
+ LocalChatClient(
30
+ endpoint=os.environ.get("TABRAS_CARD_ENDPOINT", DEFAULT_LLAMA_CARD_ENDPOINT),
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":
38
+ model_path = os.environ.get("TABRAS_CARD_MODEL", DEFAULT_MINICPM_MODEL)
39
+ return MiniCPMCardClient(MiniCPMTransformersChatClient.load(model_path))
40
+ if os.environ.get("TABRAS_CARD_ENDPOINT") is None:
41
+ return None
42
+ return MiniCPMCardClient(
43
+ LocalChatClient(
44
+ endpoint=os.environ.get("TABRAS_CARD_ENDPOINT", DEFAULT_CARD_ENDPOINT),
45
+ model=os.environ.get("TABRAS_CARD_MODEL", DEFAULT_CARD_MODEL),
46
+ timeout_seconds=int(os.environ.get("TABRAS_CARD_TIMEOUT", "60")),
47
+ temperature=float(os.environ.get("TABRAS_CARD_TEMPERATURE", "0.8")),
48
+ )
49
+ )
50
+
51
+
52
+ # Build a boss client from environment variables.
53
+ def boss_client_from_env() -> BossClient | None:
54
+ if os.environ.get("TABRAS_AI_BOSS") != "1":
55
+ return None
56
+ if os.environ.get("TABRAS_BOSS_BACKEND") == "completion":
57
+ return NemotronBossClient(
58
+ LocalCompletionClient(
59
+ endpoint=os.environ.get("TABRAS_BOSS_ENDPOINT", DEFAULT_BOSS_COMPLETION_ENDPOINT),
60
+ model=os.environ.get("TABRAS_BOSS_MODEL", DEFAULT_BOSS_MODEL),
61
+ prompt_template=nemotron_prompt,
62
+ timeout_seconds=int(os.environ.get("TABRAS_BOSS_TIMEOUT", "20")),
63
+ temperature=float(os.environ.get("TABRAS_BOSS_TEMPERATURE", "0.2")),
64
+ max_tokens=int(os.environ.get("TABRAS_BOSS_MAX_TOKENS", "256")),
65
+ )
66
+ )
67
+ if os.environ.get("TABRAS_BOSS_BACKEND") == "transformers":
68
+ chat = NemotronTransformersChatClient.load(
69
+ os.environ.get("TABRAS_BOSS_MODEL", DEFAULT_BOSS_MODEL),
70
+ max_new_tokens=int(os.environ.get("TABRAS_BOSS_MAX_TOKENS", "256")),
71
+ temperature=float(os.environ.get("TABRAS_BOSS_TEMPERATURE", "0.2")),
72
+ )
73
+ return NemotronBossClient(chat)
74
+ if os.environ.get("TABRAS_BOSS_BACKEND") == "mlx":
75
+ chat = MLXChatClient.load(
76
+ os.environ.get("TABRAS_BOSS_MODEL", DEFAULT_MLX_BOSS_MODEL),
77
+ nemotron_prompt,
78
+ max_tokens=int(os.environ.get("TABRAS_BOSS_MAX_TOKENS", "96")),
79
+ )
80
+ return NemotronBossClient(chat)
81
+ return NemotronBossClient(
82
+ LocalChatClient(
83
+ endpoint=os.environ.get("TABRAS_BOSS_ENDPOINT", DEFAULT_BOSS_ENDPOINT),
84
+ model=os.environ.get("TABRAS_BOSS_MODEL", DEFAULT_BOSS_MODEL),
85
+ timeout_seconds=int(os.environ.get("TABRAS_BOSS_TIMEOUT", "20")),
86
+ temperature=float(os.environ.get("TABRAS_BOSS_TEMPERATURE", "0.2")),
87
+ )
88
+ )
draft.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from random import Random
2
+ from typing import Callable
3
+
4
+ from budget import Card
5
+ from generator import CardModelClient, CardPackClient, generate_card, generate_pack
6
+ from primitives import Effect
7
+ 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)),
14
+ ("Attack (strong)", 5, Effect("deal", amount=6)),
15
+ ("Block (weak)", 2, Effect("block", amount=3)),
16
+ ("Block (medium)", 3, Effect("block", amount=4)),
17
+ ("Draw (small)", 2, Effect("draw", amount=1)),
18
+ )
19
+
20
+
21
+ # Build the six standardized backbone cards for a run.
22
+ 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.
32
+ def draft_deck(client: CardModelClient, school: School, theme: str, costs: tuple[int, ...]) -> tuple[Card, ...]:
33
+ if len(costs) != 9:
34
+ raise ValueError("draft needs exactly nine synergy costs")
35
+ deck = list(backbone_deck(school, theme))
36
+ for cost in costs:
37
+ deck.append(generate_card(client, school, theme, deck, cost))
38
+ return tuple(deck)
39
+
40
+
41
+ # Build a full deck by drafting one card from each generated pack.
42
+ def draft_deck_from_packs(
43
+ client: CardPackClient,
44
+ school: School,
45
+ theme: str,
46
+ costs: tuple[int, ...],
47
+ choose_index: PackChooser,
48
+ rng: Random | None = None,
49
+ ) -> tuple[Card, ...]:
50
+ if len(costs) != 9:
51
+ raise ValueError("draft needs exactly nine synergy costs")
52
+ deck = list(backbone_deck(school, theme))
53
+ anchors: list[Card] = []
54
+ anchor_indexes = draft_anchor_indexes(len(costs), rng or Random())
55
+ for index in draft_order(len(costs), anchor_indexes):
56
+ pack = generate_pack(client, school, theme, deck, costs[index], draft_anchors=anchors)
57
+ selected = pack[validated_pack_choice(choose_index(tuple(deck), pack), pack)]
58
+ deck.append(selected)
59
+ if index in anchor_indexes:
60
+ anchors.append(selected)
61
+ return tuple(deck)
62
+
63
+
64
+ # Return random synergy-card indexes that anchor the rest of the draft.
65
+ def draft_anchor_indexes(count: int, rng: Random) -> frozenset[int]:
66
+ if count < 2:
67
+ raise ValueError("draft needs at least two anchor candidates")
68
+ return frozenset(rng.sample(range(count), 2))
69
+
70
+
71
+ # Return anchor picks first, then the remaining picks in cost order.
72
+ def draft_order(count: int, anchor_indexes: frozenset[int]) -> tuple[int, ...]:
73
+ anchors = tuple(sorted(anchor_indexes))
74
+ remaining = tuple(index for index in range(count) if index not in anchor_indexes)
75
+ return anchors + remaining
76
+
77
+
78
+ # Return a valid draft pack index or reject it.
79
+ def validated_pack_choice(index: int, pack: tuple[Card, ...]) -> int:
80
+ if index < 0 or index >= len(pack):
81
+ raise ValueError("draft choice is outside the pack")
82
+ return index
game.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from random import Random
3
+
4
+ from budget import Card
5
+ from primitives import Effect
6
+
7
+ STARTING_HP = 20
8
+ MAX_ENERGY = 5
9
+ HALF_HP = STARTING_HP // 2
10
+
11
+
12
+ @dataclass
13
+ class PlayerState:
14
+ name: str
15
+ deck: list[Card]
16
+ hand: list[Card] = field(default_factory=list)
17
+ discard: list[Card] = field(default_factory=list)
18
+ hp: int = STARTING_HP
19
+ energy: int = 0
20
+ block: int = 0
21
+ ward: int = 0
22
+ shield_charge: int = 0
23
+ fatigue: int = 1
24
+ vulnerable: int = 0
25
+ vulnerable_turns: int = 0
26
+ weak: int = 0
27
+ weak_turns: int = 0
28
+ cards_played_this_turn: int = 0
29
+
30
+
31
+ @dataclass
32
+ class PendingEffect:
33
+ primitive_id: str
34
+ owner: str
35
+ target: str
36
+ amount: int
37
+ delay: int = 0
38
+ duration: int = 0
39
+
40
+
41
+ @dataclass
42
+ class DuelState:
43
+ player: PlayerState
44
+ enemy: PlayerState
45
+ pending: list[PendingEffect] = field(default_factory=list)
46
+ round_number: int = 0
47
+ forced_second: str | None = None
48
+
49
+
50
+ # Create a player state from an ordered deck.
51
+ def create_player(name: str, deck: list[Card] | tuple[Card, ...]) -> PlayerState:
52
+ return PlayerState(name=name, deck=list(deck))
53
+
54
+
55
+ # Return the opposing player state.
56
+ def opponent(state: DuelState, actor: PlayerState) -> PlayerState:
57
+ return state.enemy if actor is state.player else state.player
58
+
59
+
60
+ # Draw cards, applying escalating fatigue on deck-out.
61
+ def draw_cards(player: PlayerState, count: int) -> None:
62
+ for _ in range(count):
63
+ if player.deck:
64
+ player.hand.append(player.deck.pop(0))
65
+ else:
66
+ player.hp -= player.fatigue
67
+ player.fatigue += 1
68
+
69
+
70
+ # Start a paired round with energy refill and one draw each.
71
+ def start_round(state: DuelState, rng: Random | None = None) -> tuple[str, str]:
72
+ state.round_number += 1
73
+ for player in (state.player, state.enemy):
74
+ begin_turn(player, state.round_number)
75
+ draw_cards(player, 1)
76
+ advance_pending(state)
77
+ return round_order(state, rng or Random())
78
+
79
+
80
+ # Prepare one player for the current round.
81
+ def begin_turn(player: PlayerState, round_number: int) -> None:
82
+ player.energy = min(MAX_ENERGY, round_number)
83
+ player.block = 0
84
+ player.cards_played_this_turn = 0
85
+ tick_statuses(player)
86
+
87
+
88
+ # Tick one-turn status durations.
89
+ def tick_statuses(player: PlayerState) -> None:
90
+ if player.vulnerable_turns > 0:
91
+ player.vulnerable_turns -= 1
92
+ if player.vulnerable_turns == 0:
93
+ player.vulnerable = 0
94
+ if player.weak_turns > 0:
95
+ player.weak_turns -= 1
96
+ if player.weak_turns == 0:
97
+ player.weak = 0
98
+
99
+
100
+ # Choose action order for the current paired round.
101
+ def round_order(state: DuelState, rng: Random) -> tuple[str, str]:
102
+ if state.forced_second == state.player.name:
103
+ state.forced_second = None
104
+ return (state.enemy.name, state.player.name)
105
+ if state.forced_second == state.enemy.name:
106
+ state.forced_second = None
107
+ return (state.player.name, state.enemy.name)
108
+ names = (state.player.name, state.enemy.name)
109
+ return names if rng.randint(0, 1) == 0 else (names[1], names[0])
110
+
111
+
112
+ # Play a card from hand by index.
113
+ def play_card_from_hand(state: DuelState, actor: PlayerState, hand_index: int) -> Card:
114
+ card = actor.hand.pop(hand_index)
115
+ play_card(state, actor, card)
116
+ actor.discard.append(card)
117
+ return card
118
+
119
+
120
+ # Resolve a card if the actor can pay its energy cost.
121
+ def play_card(state: DuelState, actor: PlayerState, card: Card) -> None:
122
+ if card.cost > actor.energy:
123
+ raise ValueError("not enough energy")
124
+ actor.energy -= card.cost
125
+ actor.cards_played_this_turn += 1
126
+ target = opponent(state, actor)
127
+ for effect in card.effects:
128
+ apply_effect(state, actor, target, effect)
129
+
130
+
131
+ # Apply one deterministic effect.
132
+ def apply_effect(state: DuelState, actor: PlayerState, target: PlayerState, effect: Effect) -> None:
133
+ match effect.primitive_id:
134
+ case "deal":
135
+ deal_damage(actor, target, effect.amount)
136
+ case "burn":
137
+ state.pending.append(PendingEffect("burn", actor.name, target.name, effect.amount, delay=1, duration=effect.duration))
138
+ case "bomb":
139
+ state.pending.append(PendingEffect("bomb", actor.name, target.name, effect.amount, delay=effect.delay))
140
+ case "block":
141
+ actor.block += effect.amount
142
+ case "ward":
143
+ actor.ward += effect.amount
144
+ case "weak":
145
+ target.weak += effect.amount
146
+ target.weak_turns = max(target.weak_turns, effect.duration)
147
+ case "draw":
148
+ draw_cards(actor, effect.amount)
149
+ case "energy":
150
+ actor.energy += effect.amount
151
+ case "initiative":
152
+ state.forced_second = target.name
153
+ case "multi_hit":
154
+ for _ in range(effect.hits):
155
+ deal_damage(actor, target, effect.amount)
156
+ case "vulnerable":
157
+ target.vulnerable += effect.amount
158
+ target.vulnerable_turns = max(target.vulnerable_turns, effect.duration)
159
+ case "conditional":
160
+ damage = conditional_damage(target, effect.amount)
161
+ if damage > 0:
162
+ deal_damage(actor, target, damage)
163
+ case "scaling":
164
+ deal_damage(actor, target, scaling_damage(actor, effect))
165
+
166
+
167
+ # Deal damage through weak, vulnerable, ward, and block.
168
+ def deal_damage(actor: PlayerState, target: PlayerState, amount: int) -> int:
169
+ damage = max(0, amount - actor.weak)
170
+ if damage == 0:
171
+ return 0
172
+ damage += target.vulnerable
173
+ if target.ward > 0:
174
+ if damage >= target.ward:
175
+ target.ward = 0
176
+ return 0
177
+ blocked = min(target.block, damage)
178
+ target.shield_charge += blocked // 2
179
+ target.block -= blocked
180
+ target.hp -= damage - blocked
181
+ return damage - blocked
182
+
183
+
184
+ # Return scaling damage and spend shield charge when used.
185
+ def scaling_damage(actor: PlayerState, effect: Effect) -> int:
186
+ if effect.condition == "shield_charge":
187
+ damage = effect.amount + actor.shield_charge
188
+ actor.shield_charge = 0
189
+ return damage
190
+ return effect.amount + actor.cards_played_this_turn
191
+
192
+
193
+ # Return missing-HP-scaled conditional damage.
194
+ def conditional_damage(target: PlayerState, amount: int) -> int:
195
+ missing_hp = max(0, STARTING_HP - target.hp)
196
+ return amount * missing_hp // STARTING_HP
197
+
198
+
199
+ # Advance all deferred effects by one schedule tick.
200
+ def advance_pending(state: DuelState) -> None:
201
+ remaining: list[PendingEffect] = []
202
+ for effect in state.pending:
203
+ if effect.delay > 0:
204
+ effect.delay -= 1
205
+ remaining.append(effect)
206
+ elif effect.primitive_id == "burn":
207
+ deal_damage(named_player(state, effect.owner), named_player(state, effect.target), effect.amount)
208
+ effect.duration -= 1
209
+ if effect.duration > 0:
210
+ remaining.append(effect)
211
+ elif effect.primitive_id == "bomb":
212
+ deal_damage(named_player(state, effect.owner), named_player(state, effect.target), effect.amount)
213
+ state.pending = remaining
214
+
215
+
216
+ # Return a player by name.
217
+ def named_player(state: DuelState, name: str) -> PlayerState:
218
+ if state.player.name == name:
219
+ return state.player
220
+ if state.enemy.name == name:
221
+ return state.enemy
222
+ raise KeyError(name)
223
+
224
+
225
+ # Return whether either side has won.
226
+ def winner(state: DuelState) -> str | None:
227
+ player_dead = state.player.hp <= 0
228
+ enemy_dead = state.enemy.hp <= 0
229
+ if player_dead and enemy_dead:
230
+ return "draw"
231
+ if enemy_dead:
232
+ return state.player.name
233
+ if player_dead:
234
+ return state.enemy.name
235
+ return None
generator.py ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ 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
+
9
+ from budget import Card, CardSpec, EffectPlan, cost_card
10
+ from local_llm import LocalChatClient
11
+ from primitives import PrimitiveId, School, school_bias, school_primitives
12
+
13
+
14
+ class CardModelClient(Protocol):
15
+ # Return one raw model-authored card payload.
16
+ def create_card(self, payload: dict[str, Any]) -> dict[str, Any]: # pragma: no cover
17
+ ...
18
+
19
+
20
+ class CardPackClient(Protocol):
21
+ # Return one raw model-authored pack payload.
22
+ def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]: # pragma: no cover
23
+ ...
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DeckCardContext:
28
+ name: str
29
+ cost: int
30
+ rules_text: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CodexCardClient:
35
+ command: tuple[str, ...] = ("codex", "exec", "--ephemeral", "--skip-git-repo-check", "-")
36
+ timeout_seconds: int = 120
37
+ cwd: str = "."
38
+
39
+ # Return one raw card payload from a Codex invocation.
40
+ def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
41
+ pack_payload = dict(payload)
42
+ pack_payload.setdefault("cost", 1)
43
+ pack_payload["pack_size"] = 1
44
+ return self.create_pack(pack_payload)["cards"][0]
45
+
46
+ # Return one raw card pack payload from a Codex invocation.
47
+ def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]:
48
+ with tempfile.TemporaryDirectory() as tmpdir:
49
+ output_path = Path(tmpdir) / "codex-card-pack.json"
50
+ command = self.command[:-1] + ("--output-last-message", str(output_path), self.command[-1])
51
+ result = run_codex_command(command, codex_pack_prompt(payload), self.cwd, self.timeout_seconds)
52
+ if result.returncode != 0:
53
+ raise RuntimeError(result.stderr.strip() or "Codex card generation failed")
54
+ return json.loads(extract_json_object(output_path.read_text()))
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class MiniCPMCardClient:
59
+ chat: LocalChatClient
60
+
61
+ # Return one raw card payload from a local MiniCPM endpoint.
62
+ def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
63
+ pack_payload = dict(payload)
64
+ pack_payload.setdefault("cost", 1)
65
+ pack_payload["pack_size"] = 1
66
+ return self.create_pack(pack_payload)["cards"][0]
67
+
68
+ # Return one raw card pack payload from a local MiniCPM endpoint.
69
+ def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]:
70
+ text = self.chat.complete(card_system_prompt(), codex_pack_prompt(payload))
71
+ return json.loads(extract_json_object(text))
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class LlamaCppCardClient:
76
+ chat: LocalChatClient
77
+
78
+ # Return one raw card payload from a llama.cpp MiniCPM endpoint.
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] = {
91
+ "name": "create_card",
92
+ "parameters": {
93
+ "type": "object",
94
+ "required": ["name", "flavor", "art_prompt", "effects"],
95
+ "properties": {
96
+ "name": {"type": "string"},
97
+ "flavor": {"type": "string"},
98
+ "art_prompt": {"type": "string"},
99
+ "effects": {
100
+ "type": "array",
101
+ "items": {
102
+ "type": "object",
103
+ "required": ["primitive_id"],
104
+ "properties": {
105
+ "primitive_id": {"type": "string"},
106
+ "weight": {"type": "integer", "minimum": 1},
107
+ },
108
+ },
109
+ },
110
+ },
111
+ },
112
+ }
113
+
114
+ PACK_SCHEMA: dict[str, Any] = {
115
+ "type": "object",
116
+ "required": ["cards"],
117
+ "properties": {
118
+ "cards": {
119
+ "type": "array",
120
+ "minItems": 3,
121
+ "maxItems": 3,
122
+ "items": TOOL_SCHEMA["parameters"],
123
+ }
124
+ },
125
+ }
126
+
127
+
128
+ # Generate one engine-costed card through a mockable model boundary.
129
+ def generate_card(
130
+ client: CardModelClient,
131
+ school: School,
132
+ theme: str,
133
+ current_deck: Sequence[Card],
134
+ cost: int,
135
+ draft_anchors: Sequence[Card] = (),
136
+ ) -> Card:
137
+ raw = client.create_card(generator_payload(school, theme, current_deck, draft_anchors))
138
+ spec = parse_card_payload(raw, school, theme, cost)
139
+ return cost_card(spec)
140
+
141
+
142
+ # Generate one engine-costed draft pack through a mockable model boundary.
143
+ def generate_pack(
144
+ client: CardPackClient,
145
+ school: School,
146
+ theme: str,
147
+ current_deck: Sequence[Card],
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")
156
+ return pack[:pack_size]
157
+
158
+
159
+ # Generate several engine-costed cards through one model call.
160
+ def generate_cards(
161
+ client: CardPackClient,
162
+ school: School,
163
+ theme: str,
164
+ current_deck: Sequence[Card],
165
+ costs: Sequence[int],
166
+ draft_anchors: Sequence[Card] = (),
167
+ ) -> tuple[Card, ...]:
168
+ raw = client.create_pack(cards_payload(school, theme, current_deck, costs, draft_anchors))
169
+ if len(raw["cards"]) != len(costs):
170
+ raise ValueError("generated card batch had the wrong size")
171
+ return tuple(cost_card(parse_card_payload(card, school, theme, cost)) for card, cost in zip(raw["cards"], costs))
172
+
173
+
174
+ # Build the deck-aware model payload for card generation.
175
+ def generator_payload(school: School, theme: str, current_deck: Sequence[Card], draft_anchors: Sequence[Card] = ()) -> dict[str, Any]:
176
+ return {
177
+ "school": school,
178
+ "theme": theme,
179
+ "school_identity": school_identity(school),
180
+ "school_bias": school_bias(school),
181
+ "primitive_guidance": primitive_guidance(school),
182
+ "allowed_primitives": school_primitives(school),
183
+ "current_deck": [card_context(card) for card in current_deck],
184
+ "current_deck_summary": deck_summary(current_deck),
185
+ "draft_anchors": [card_context(card) for card in draft_anchors],
186
+ "draft_anchor_summary": deck_summary(draft_anchors),
187
+ "draft_direction": draft_direction(school, draft_anchors),
188
+ "draft_need": draft_need(school, current_deck),
189
+ "tool_schema": TOOL_SCHEMA,
190
+ "instruction": "Choose effect shapes only; numeric magnitudes are assigned by the engine.",
191
+ }
192
+
193
+
194
+ # Build the deck-aware model payload for draft pack generation.
195
+ def pack_payload(
196
+ school: School,
197
+ theme: str,
198
+ current_deck: Sequence[Card],
199
+ cost: int,
200
+ pack_size: int,
201
+ draft_anchors: Sequence[Card] = (),
202
+ ) -> dict[str, Any]:
203
+ payload = generator_payload(school, theme, current_deck, draft_anchors)
204
+ payload["cost"] = cost
205
+ payload["pack_size"] = pack_size
206
+ payload["pack_schema"] = PACK_SCHEMA
207
+ return payload
208
+
209
+
210
+ # Build the deck-aware model payload for multi-card generation.
211
+ def cards_payload(
212
+ school: School,
213
+ theme: str,
214
+ current_deck: Sequence[Card],
215
+ costs: Sequence[int],
216
+ draft_anchors: Sequence[Card] = (),
217
+ ) -> dict[str, Any]:
218
+ payload = generator_payload(school, theme, current_deck, draft_anchors)
219
+ payload["costs"] = tuple(costs)
220
+ payload["pack_size"] = len(costs)
221
+ payload["pack_schema"] = PACK_SCHEMA
222
+ return payload
223
+
224
+
225
+ # Build the prompt sent to Codex for one draft pack.
226
+ def codex_pack_prompt(payload: dict[str, Any]) -> str:
227
+ cost_line = cost_instruction(payload)
228
+ return "\n".join(
229
+ (
230
+ "Generate a Tabras draft pack as strict JSON only.",
231
+ "Return exactly this shape: {\"cards\": [{\"name\": str, \"flavor\": str, \"art_prompt\": str, \"effects\": [{\"primitive_id\": str, \"weight\": int}]}]}",
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']}",
238
+ f"School bias: {payload['school_bias']}",
239
+ f"Primitive guidance: {payload['primitive_guidance']}",
240
+ f"Deck primitive counts: {json.dumps(payload['current_deck_summary'])}",
241
+ f"Draft anchor cards: {json.dumps(payload['draft_anchors'])}",
242
+ f"Draft anchor primitive counts: {json.dumps(payload['draft_anchor_summary'])}",
243
+ f"Draft direction: {payload['draft_direction']}",
244
+ f"Draft need: {payload['draft_need']}",
245
+ cost_line,
246
+ f"Pack size: {payload['pack_size']}",
247
+ f"Return exactly {payload['pack_size']} cards: no fewer and no extras.",
248
+ f"Allowed primitives: {json.dumps(payload['allowed_primitives'])}",
249
+ f"Current deck context: {json.dumps(payload['current_deck'])}",
250
+ )
251
+ )
252
+
253
+
254
+ # Return the system prompt for card generation.
255
+ 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]
262
+ pack_counts = pack_summary(pack_cards)
263
+ candidate_need = llama_candidate_need(payload, pack_counts)
264
+ return "\n".join(
265
+ (
266
+ "Create exactly one Tabras card as JSON.",
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']}",
274
+ f"Bias: {payload['school_bias']}",
275
+ f"Primitive guidance: {payload['primitive_guidance']}",
276
+ f"Deck primitive counts: {json.dumps(payload['current_deck_summary'])}",
277
+ f"Draft anchor cards: {json.dumps(payload['draft_anchors'])}",
278
+ f"Draft anchor primitive counts: {json.dumps(payload['draft_anchor_summary'])}",
279
+ f"Draft direction: {payload['draft_direction']}",
280
+ f"Current draft need: {payload['draft_need']}",
281
+ f"This candidate should: {candidate_need}",
282
+ f"Allowed primitive_id values: {json.dumps(payload['allowed_primitives'])}",
283
+ f"Existing deck: {json.dumps(payload['current_deck'])}",
284
+ f"Already in this pack: {json.dumps(used_names)}",
285
+ f"Pack primitive counts so far: {json.dumps(pack_counts)}",
286
+ )
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.
302
+ def llamacpp_retry_prompt(payload: dict[str, Any], need: str) -> str:
303
+ return "\n".join(
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'])}",
310
+ f"Correction requirement: {need}",
311
+ "If the requirement mentions primitive_id block, include an effect with primitive_id exactly \"block\".",
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
+ )
318
+
319
+
320
+ # Return the cost instruction for a Codex generation payload.
321
+ def cost_instruction(payload: dict[str, Any]) -> str:
322
+ if "costs" in payload:
323
+ return f"Card costs by index: {json.dumps(payload['costs'])}"
324
+ return f"Card cost for every candidate: {payload['cost']}"
325
+
326
+
327
+ # Run Codex for one card-generation prompt.
328
+ def run_codex_command(command: tuple[str, ...], prompt: str, cwd: str, timeout_seconds: int) -> subprocess.CompletedProcess[str]:
329
+ return subprocess.run(
330
+ command,
331
+ input=prompt,
332
+ text=True,
333
+ cwd=cwd,
334
+ check=False,
335
+ capture_output=True,
336
+ timeout=timeout_seconds,
337
+ )
338
+
339
+
340
+ # Convert a costed card into compact generator context.
341
+ def card_context(card: Card) -> dict[str, Any]:
342
+ return DeckCardContext(card.name, card.cost, card.rules_text()).__dict__
343
+
344
+
345
+ # Return the design identity for one school.
346
+ def school_identity(school: School) -> str:
347
+ identities = {
348
+ "fire": "Fire races with direct pressure, burn clocks, delayed bombs, and occasional scaling finishers.",
349
+ "ice": "Ice wins through tempo, forced sequencing, vulnerability windows, and precise multi-hit pressure.",
350
+ "earth": "Earth absorbs pressure with block, banks blocked damage as shield charge, then uses scaling to convert charge into burst; plain deal is secondary.",
351
+ }
352
+ return identities[school]
353
+
354
+
355
+ # Return school-specific primitive design guidance.
356
+ def primitive_guidance(school: School) -> str:
357
+ guidance = {
358
+ "fire": "Use deal for immediate pressure, burn for damage over turns, bomb for delayed burst, and scaling for finishers.",
359
+ "ice": "Use initiative for turn order, vulnerable before multi_hit or deal, and conditional for precise finishers.",
360
+ "earth": "Use block to create shield charge, scaling to spend shield charge as burst, ward for rare protection, and weak/draw as support.",
361
+ }
362
+ return guidance[school]
363
+
364
+
365
+ # Return a concise direction inferred from anchor cards.
366
+ def draft_direction(school: School, draft_anchors: Sequence[Card]) -> str:
367
+ counts = deck_summary(draft_anchors)
368
+ if not counts:
369
+ return "No anchor cards yet; generate a strong candidate that could define the deck's direction."
370
+ if school == "fire":
371
+ return fire_anchor_direction(counts)
372
+ if school == "ice":
373
+ return ice_anchor_direction(counts)
374
+ return earth_anchor_direction(counts)
375
+
376
+
377
+ # Return Fire direction from anchor primitives.
378
+ def fire_anchor_direction(counts: dict[str, int]) -> str:
379
+ if counts.get("bomb", 0) or counts.get("burn", 0):
380
+ return "Lean into a pressure clock: pair delayed damage with immediate deal, draw, or scaling finishers."
381
+ return "Lean into fast pressure: add burn, bomb, draw, or finishers that support the anchor cards."
382
+
383
+
384
+ # Return Ice direction from anchor primitives.
385
+ def ice_anchor_direction(counts: dict[str, int]) -> str:
386
+ if counts.get("vulnerable", 0) and not counts.get("multi_hit", 0):
387
+ return "Exploit vulnerability windows with multi_hit, initiative, and precise finishers."
388
+ if counts.get("multi_hit", 0) and not counts.get("vulnerable", 0):
389
+ return "Support multi-hit pressure with vulnerable, initiative, draw, or conditional finishers."
390
+ return "Build a tempo chain around initiative, vulnerable, multi_hit, and conditional payoff."
391
+
392
+
393
+ # Return Earth direction from anchor primitives.
394
+ def earth_anchor_direction(counts: dict[str, int]) -> str:
395
+ if counts.get("block", 0) and not counts.get("scaling", 0):
396
+ return "Use block as the shield-charge engine and add scaling payoff, weak, draw, or conversion."
397
+ if counts.get("scaling", 0):
398
+ return "Support shield-charge burst with block, survival, draw, weak, and occasional ward."
399
+ return "Build the absorb-then-retaliate loop with block and shield-charge payoff."
400
+
401
+
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:
408
+ return "explore primitive_id scaling as the shield-charge payoff while inventing the card fantasy freely."
409
+ if "multi_hit" in draft_need_text and pack_counts.get("multi_hit", 0) == 0:
410
+ return "explore primitive_id multi_hit as the payoff for vulnerability while inventing the card fantasy freely."
411
+ if "initiative" in draft_need_text and pack_counts.get("initiative", 0) == 0:
412
+ return "explore primitive_id initiative to control turn order while inventing the card fantasy freely."
413
+ if "plain damage is covered" in draft_need_text and pack_counts.get("deal", 0) > 0:
414
+ return "avoid another primary deal card; explore block, scaling, weak, draw, or support."
415
+ return "fit the draft need with a distinct name, fantasy, and tactical purpose."
416
+
417
+
418
+ # Return whether a card satisfies a per-candidate need.
419
+ def card_satisfies_candidate_need(card: dict[str, Any], need: str) -> bool:
420
+ effects = card.get("effects", [])
421
+ primitives = [effect.get("primitive_id") for effect in effects if isinstance(effect, dict)] if isinstance(effects, list) else []
422
+ if "primitive_id scaling" in need:
423
+ return "scaling" in primitives
424
+ if "primitive_id block" in need:
425
+ return "block" in primitives
426
+ if "primitive_id multi_hit" in need:
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()
438
+ for card in deck:
439
+ counts.update(effect.primitive_id for effect in card.effects)
440
+ return ordered_counts(counts)
441
+
442
+
443
+ # Count primitive ids represented in raw generated pack cards.
444
+ def pack_summary(cards: Sequence[dict[str, Any]]) -> dict[str, int]:
445
+ counts: Counter[str] = Counter()
446
+ for card in cards:
447
+ effects = card.get("effects", [])
448
+ if isinstance(effects, list):
449
+ counts.update(effect.get("primitive_id") for effect in effects if isinstance(effect, dict))
450
+ return ordered_counts(counts)
451
+
452
+
453
+ # Return a compact deck-aware drafting need.
454
+ def draft_need(school: School, deck: Sequence[Card]) -> str:
455
+ counts = deck_summary(deck)
456
+ if school == "earth":
457
+ return earth_draft_need(counts)
458
+ if school == "fire":
459
+ return fire_draft_need(counts)
460
+ return ice_draft_need(counts)
461
+
462
+
463
+ # Return an Earth-specific drafting need.
464
+ def earth_draft_need(counts: dict[str, int]) -> str:
465
+ protection = counts.get("block", 0) + counts.get("ward", 0)
466
+ payoff = counts.get("scaling", 0) + counts.get("deal", 0) + counts.get("conditional", 0)
467
+ if counts.get("scaling", 0) > 0 and counts.get("block", 0) <= counts.get("scaling", 0):
468
+ return "Scaling payoff exists; add block to bank shield charge before adding more plain deal."
469
+ if counts.get("block", 0) > 0 and counts.get("scaling", 0) == 0:
470
+ return "Add a shield-charge payoff using scaling; avoid making plain deal the main Earth payoff."
471
+ if counts.get("deal", 0) >= protection + 2:
472
+ return "Plain damage is covered; prefer block, scaling shield-charge payoff, weak, draw, or conversion."
473
+ if protection >= payoff + 2:
474
+ return "Protection is covered; prefer shield-charge payoff, pressure, weak, draw, or conversion instead of more ward."
475
+ if counts.get("block", 0) == 0:
476
+ return "Add block so Earth can bank shield charge before spending it."
477
+ return "Improve the absorb-then-retaliate loop with a distinct support or payoff card."
478
+
479
+
480
+ # Return a Fire-specific drafting need.
481
+ def fire_draft_need(counts: dict[str, int]) -> str:
482
+ delayed = counts.get("burn", 0) + counts.get("bomb", 0)
483
+ pressure = counts.get("deal", 0) + counts.get("scaling", 0)
484
+ if delayed >= pressure + 2:
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.
492
+ def ice_draft_need(counts: dict[str, int]) -> str:
493
+ setup = counts.get("initiative", 0) + counts.get("vulnerable", 0)
494
+ payoff = counts.get("multi_hit", 0) + counts.get("conditional", 0) + counts.get("deal", 0)
495
+ if counts.get("vulnerable", 0) > 0 and counts.get("multi_hit", 0) == 0:
496
+ return "Vulnerability setup exists; add a multi_hit payoff instead of more plain deal."
497
+ if counts.get("initiative", 0) == 0 and counts.get("vulnerable", 0) >= 2:
498
+ return "Add initiative to control turn order around the vulnerability window."
499
+ if counts.get("deal", 0) >= setup + 3:
500
+ return "Plain damage is covered; prefer initiative, vulnerable, multi_hit, conditional, draw, or tempo."
501
+ if setup >= payoff + 2:
502
+ return "Tempo setup is covered; prefer payoff damage or multi-hit pressure."
503
+ return "Add a tempo card that creates or exploits a vulnerability window."
504
+
505
+
506
+ # Return counts with empty keys removed.
507
+ def ordered_counts(counts: Counter[str]) -> dict[str, int]:
508
+ return {key: counts[key] for key in sorted(counts) if key}
509
+
510
+
511
+ # Parse model payload into a card spec without trusting model-authored numbers.
512
+ def parse_card_payload(raw: dict[str, Any], school: School, theme: str, cost: int) -> CardSpec:
513
+ effects = tuple(parse_effect_plan(effect) for effect in raw["effects"])
514
+ return CardSpec(
515
+ name=str(raw["name"]),
516
+ cost=cost,
517
+ school=school,
518
+ theme=theme,
519
+ effect_plans=effects,
520
+ flavor=str(raw.get("flavor", "")),
521
+ art_prompt=str(raw.get("art_prompt", "")),
522
+ )
523
+
524
+
525
+ # Parse model payload into costed draft cards.
526
+ def parse_pack_payload(raw: dict[str, Any], school: School, theme: str, cost: int) -> tuple[Card, ...]:
527
+ return tuple(cost_card(parse_card_payload(card, school, theme, cost)) for card in raw["cards"])
528
+
529
+
530
+ # Parse one model-authored effect plan.
531
+ def parse_effect_plan(raw: dict[str, Any]) -> EffectPlan:
532
+ primitive_id = raw["primitive_id"]
533
+ weight = max(1, int(raw.get("weight", 1)))
534
+ return EffectPlan(primitive_id=primitive_id, weight=weight) # type: ignore[arg-type]
535
+
536
+
537
+ # Normalize one-card or pack-shaped model JSON into one card object.
538
+ def normalize_card_response(raw: dict[str, Any]) -> dict[str, Any]:
539
+ if "cards" in raw and isinstance(raw["cards"], list) and raw["cards"]:
540
+ return normalize_card_response(raw["cards"][0])
541
+ if "shape" in raw and isinstance(raw["shape"], dict):
542
+ return normalize_card_response(raw["shape"])
543
+ if "effects" not in raw and len(raw) == 1:
544
+ value = next(iter(raw.values()))
545
+ if isinstance(value, dict):
546
+ return normalize_card_response(value)
547
+ return raw
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", "")),
567
+ "art_prompt": str(raw.get("art_prompt") or fallback_art_prompt(payload)),
568
+ "effects": effects,
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 []
575
+ repaired = [repair_effect(effect, allowed) for effect in effects if effect.get("primitive_id") in allowed]
576
+ return repaired[:2] or [{"primitive_id": allowed[0], "weight": 1}]
577
+
578
+
579
+ # Repair one model-authored effect to a minimal engine-owned plan.
580
+ def repair_effect(raw: dict[str, Any], allowed: Sequence[str]) -> dict[str, Any]:
581
+ primitive_id = raw["primitive_id"] if raw.get("primitive_id") in allowed else allowed[0]
582
+ return {"primitive_id": primitive_id, "weight": max(1, int(raw.get("weight", 1)))}
583
+
584
+
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.
595
+ def fallback_art_prompt(payload: dict[str, Any]) -> str:
596
+ return f"{payload['theme']} {payload['school']} card art"
597
+
598
+
599
+ # Extract the first JSON object from model text.
600
+ def extract_json_object(text: str) -> str:
601
+ start = text.find("{")
602
+ end = text.rfind("}")
603
+ if start == -1 or end == -1 or end < start:
604
+ raise ValueError("Codex response did not contain a JSON object")
605
+ candidate = balance_json_closers(text[start : end + 1])
606
+ json.loads(candidate)
607
+ return candidate
608
+
609
+
610
+ # Append missing JSON object or array closers.
611
+ def balance_json_closers(text: str) -> str:
612
+ stack: list[str] = []
613
+ in_string = False
614
+ escaped = False
615
+ for char in text:
616
+ if escaped:
617
+ escaped = False
618
+ continue
619
+ if char == "\\" and in_string:
620
+ escaped = True
621
+ continue
622
+ if char == "\"":
623
+ in_string = not in_string
624
+ continue
625
+ if in_string:
626
+ continue
627
+ if char == "{":
628
+ stack.append("}")
629
+ elif char == "[":
630
+ stack.append("]")
631
+ elif stack and char == stack[-1]:
632
+ stack.pop()
633
+ return text + "".join(reversed(stack))
launch_ai.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ai_runtime import DEFAULT_APP_PORT, DEFAULT_CARD_PORT, local_ai_env, start_minicpm_server
2
+ from app import CSS, HEAD, build_app
3
+
4
+
5
+ # Launch Tabras with MiniCPM card authoring and Nemotron boss play.
6
+ def main() -> None:
7
+ server = start_minicpm_server(port=DEFAULT_CARD_PORT)
8
+ try:
9
+ env = local_ai_env(DEFAULT_CARD_PORT)
10
+ import os
11
+
12
+ os.environ.update(env)
13
+ build_app().launch(server_name="127.0.0.1", server_port=DEFAULT_APP_PORT, css=CSS, head=HEAD)
14
+ finally:
15
+ server.terminate()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
local_llm.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from importlib.util import find_spec
5
+ from typing import Any, Callable
6
+ from urllib import request
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class LocalChatClient:
11
+ endpoint: str
12
+ model: str
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"),
23
+ headers={"Content-Type": "application/json"},
24
+ method="POST",
25
+ )
26
+ with request.urlopen(req, timeout=self.timeout_seconds) as response:
27
+ return parse_chat_response(json.loads(response.read().decode("utf-8")))
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LocalJsonChatClient:
32
+ endpoint: str
33
+ model: str
34
+ timeout_seconds: int = 60
35
+ temperature: float = 0.0
36
+ max_tokens: int = 256
37
+
38
+ # Complete one chat prompt through a JSON-constrained local endpoint.
39
+ def complete(self, system: str, user: str) -> str:
40
+ payload = json_chat_payload(self.model, system, user, self.temperature, self.max_tokens)
41
+ req = request.Request(
42
+ self.endpoint,
43
+ data=json.dumps(payload).encode("utf-8"),
44
+ headers={"Content-Type": "application/json"},
45
+ method="POST",
46
+ )
47
+ with request.urlopen(req, timeout=self.timeout_seconds) as response:
48
+ return parse_chat_response(json.loads(response.read().decode("utf-8")))
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class LocalCompletionClient:
53
+ endpoint: str
54
+ model: str
55
+ prompt_template: Callable[[str, str], str]
56
+ timeout_seconds: int = 60
57
+ temperature: float = 0.2
58
+ max_tokens: int = 256
59
+
60
+ # Complete one prompt through an OpenAI-compatible local completion endpoint.
61
+ def complete(self, system: str, user: str) -> str:
62
+ prompt = self.prompt_template(system, user)
63
+ payload = completion_payload(self.model, prompt, self.temperature, self.max_tokens)
64
+ req = request.Request(
65
+ self.endpoint,
66
+ data=json.dumps(payload).encode("utf-8"),
67
+ headers={"Content-Type": "application/json"},
68
+ method="POST",
69
+ )
70
+ with request.urlopen(req, timeout=self.timeout_seconds) as response:
71
+ return parse_completion_response(json.loads(response.read().decode("utf-8")))
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class NemotronTransformersChatClient:
76
+ model: Any
77
+ tokenizer: Any
78
+ max_new_tokens: int = 256
79
+ temperature: float = 0.2
80
+
81
+ # Complete one prompt through a local Nemotron Transformers model.
82
+ def complete(self, system: str, user: str) -> str:
83
+ messages = chat_messages(system, user)
84
+ inputs = self.tokenizer.apply_chat_template(
85
+ messages,
86
+ tokenize=True,
87
+ add_generation_prompt=True,
88
+ return_tensors="pt",
89
+ )
90
+ outputs = self.model.generate(
91
+ tensor_to_model_device(inputs, self.model),
92
+ max_new_tokens=self.max_new_tokens,
93
+ do_sample=self.temperature > 0,
94
+ temperature=self.temperature,
95
+ )
96
+ return decode_generated_text(self.tokenizer, inputs, outputs)
97
+
98
+ # Load Nemotron with its required tokenizer chat template.
99
+ @classmethod
100
+ def load(
101
+ cls,
102
+ model_path: str,
103
+ max_new_tokens: int = 256,
104
+ temperature: float = 0.2,
105
+ ) -> "NemotronTransformersChatClient": # pragma: no cover
106
+ from transformers import AutoModelForCausalLM, AutoTokenizer
107
+
108
+ tokenizer = AutoTokenizer.from_pretrained(model_path)
109
+ model = AutoModelForCausalLM.from_pretrained(model_path, **transformers_model_kwargs())
110
+ return cls(model.eval(), tokenizer, max_new_tokens=max_new_tokens, temperature=temperature)
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class MLXChatClient:
115
+ model: Any
116
+ tokenizer: Any
117
+ prompt_template: Callable[[str, str], str]
118
+ generate_func: Callable[..., str]
119
+ max_tokens: int = 128
120
+
121
+ # Complete one prompt through a local MLX model.
122
+ def complete(self, system: str, user: str) -> str:
123
+ prompt = self.prompt_template(system, user)
124
+ return self.generate_func(self.model, self.tokenizer, prompt, verbose=False, max_tokens=self.max_tokens)
125
+
126
+ # Load one MLX model for Apple Silicon inference.
127
+ @classmethod
128
+ def load(
129
+ cls,
130
+ model_path: str,
131
+ prompt_template: Callable[[str, str], str],
132
+ max_tokens: int = 128,
133
+ ) -> "MLXChatClient": # pragma: no cover
134
+ from mlx_lm import generate, load
135
+
136
+ model, tokenizer = load(model_path)
137
+ return cls(model, tokenizer, prompt_template, generate, max_tokens=max_tokens)
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class MiniCPMTransformersChatClient:
142
+ model: Any
143
+ tokenizer: Any
144
+ max_new_tokens: int = 512
145
+
146
+ # Complete one text prompt through a local MiniCPM model.chat call.
147
+ def complete(self, system: str, user: str) -> str:
148
+ return str(
149
+ self.model.chat(
150
+ msgs=[{"role": "user", "content": user}],
151
+ image=None,
152
+ tokenizer=self.tokenizer,
153
+ system_prompt=system,
154
+ sampling=False,
155
+ max_new_tokens=self.max_new_tokens,
156
+ )
157
+ )
158
+
159
+ # Load MiniCPM with trust_remote_code for its custom chat method.
160
+ @classmethod
161
+ def load(cls, model_path: str, max_new_tokens: int = 512) -> "MiniCPMTransformersChatClient": # pragma: no cover
162
+ import torch
163
+ from transformers import AutoModel, AutoTokenizer
164
+
165
+ model = AutoModel.from_pretrained(
166
+ model_path,
167
+ trust_remote_code=True,
168
+ attn_implementation="sdpa",
169
+ torch_dtype=local_torch_dtype(torch),
170
+ )
171
+ if torch.cuda.is_available():
172
+ model = model.cuda()
173
+ tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
174
+ return cls(model.eval(), tokenizer, max_new_tokens=max_new_tokens)
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": [
182
+ {"role": "system", "content": system},
183
+ {"role": "user", "content": user},
184
+ ],
185
+ "temperature": temperature,
186
+ }
187
+ if max_tokens is not None:
188
+ payload["max_tokens"] = max_tokens
189
+ return payload
190
+
191
+
192
+ # Build an OpenAI-compatible JSON-constrained chat payload.
193
+ def json_chat_payload(model: str, system: str, user: str, temperature: float, max_tokens: int) -> dict[str, Any]:
194
+ payload = chat_payload(model, system, user, temperature, max_tokens)
195
+ payload["response_format"] = {"type": "json_object"}
196
+ return payload
197
+
198
+
199
+ # Build an OpenAI-compatible text completion payload.
200
+ def completion_payload(model: str, prompt: str, temperature: float, max_tokens: int) -> dict[str, Any]:
201
+ return {
202
+ "model": model,
203
+ "prompt": prompt,
204
+ "temperature": temperature,
205
+ "max_tokens": max_tokens,
206
+ }
207
+
208
+
209
+ # Build standard system/user chat messages.
210
+ def chat_messages(system: str, user: str) -> list[dict[str, str]]:
211
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
212
+
213
+
214
+ # Render Nemotron's documented single-turn prompt markers.
215
+ def nemotron_prompt(system: str, user: str) -> str:
216
+ return f"<extra_id_0>System\n{system}\n\n<extra_id_1>User\n{user}\n<extra_id_1>Assistant\n"
217
+
218
+
219
+ # Render a text-only MiniCPM prompt for its model.chat API.
220
+ def minicpm_text_prompt(system: str, user: str) -> str:
221
+ return f"System:\n{system}\n\nUser:\n{user}"
222
+
223
+
224
+ # Parse text content from an OpenAI-compatible chat response.
225
+ def parse_chat_response(raw: dict[str, Any]) -> str:
226
+ return str(raw["choices"][0]["message"]["content"])
227
+
228
+
229
+ # Parse text content from an OpenAI-compatible completion response.
230
+ def parse_completion_response(raw: dict[str, Any]) -> str:
231
+ if "content" in raw:
232
+ return str(raw["content"])
233
+ choice = raw["choices"][0]
234
+ if "text" in choice:
235
+ return str(choice["text"])
236
+ return str(choice["message"]["content"])
237
+
238
+
239
+ # Move generated inputs onto the model device when tensors support it.
240
+ def tensor_to_model_device(inputs: Any, model: Any) -> Any:
241
+ device = getattr(model, "device", None)
242
+ if device is not None and hasattr(inputs, "to"):
243
+ return inputs.to(device)
244
+ return inputs
245
+
246
+
247
+ # Decode only tokens generated after the input prompt.
248
+ def decode_generated_text(tokenizer: Any, inputs: Any, outputs: Any) -> str:
249
+ output = outputs[0]
250
+ prompt_length = token_length(inputs)
251
+ generated = output[prompt_length:]
252
+ return str(tokenizer.decode(generated, skip_special_tokens=True)).strip()
253
+
254
+
255
+ # Return the final token dimension for tensor-like input ids.
256
+ def token_length(inputs: Any) -> int:
257
+ if hasattr(inputs, "shape"):
258
+ return int(inputs.shape[-1])
259
+ if inputs and isinstance(inputs[0], list):
260
+ return len(inputs[0])
261
+ return len(inputs)
262
+
263
+
264
+ # Return practical local loading kwargs for causal Transformers models.
265
+ def transformers_model_kwargs() -> dict[str, Any]:
266
+ kwargs: dict[str, Any] = {"torch_dtype": "auto"}
267
+ if find_spec("accelerate") is not None:
268
+ kwargs["device_map"] = "auto"
269
+ kwargs["offload_folder"] = os.environ.get("TABRAS_MODEL_OFFLOAD", "/tmp/tabras-model-offload")
270
+ return kwargs
271
+
272
+
273
+ # Return the best dtype available for MiniCPM local inference.
274
+ def local_torch_dtype(torch: Any) -> Any:
275
+ if torch.cuda.is_available():
276
+ return torch.bfloat16
277
+ return "auto"
play.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from random import Random
2
+ from typing import Callable, Protocol
3
+
4
+ from budget import Card
5
+ from draft import backbone_deck, draft_deck_from_packs
6
+ from generator import CardPackClient, CodexCardClient, deck_summary
7
+ from game import DuelState, PlayerState, conditional_damage, create_player, draw_cards, named_player, opponent, play_card_from_hand, start_round, winner
8
+ from primitives import School
9
+
10
+ Prompt = Callable[[str], str]
11
+ Printer = Callable[[str], None]
12
+ Chooser = Callable[[DuelState, PlayerState], int | None]
13
+
14
+
15
+ class BossClientLike(Protocol):
16
+ # Return one raw boss decision payload.
17
+ def choose_cards(self, payload: dict[str, object]) -> dict[str, object]: # pragma: no cover
18
+ ...
19
+
20
+ OPENING_HAND_SIZE = 3
21
+ MAX_ROUNDS = 20
22
+ SYNERGY_COSTS = (2, 2, 3, 3, 4, 4, 4, 5, 5)
23
+
24
+
25
+ # Return a compact line for one card in hand.
26
+ def card_line(index: int, card: Card) -> str:
27
+ return f"{index}: {card.name} [{card.cost}] - {card.rules_text()}"
28
+
29
+
30
+ # Return a compact line for one draft candidate.
31
+ def pack_line(index: int, card: Card) -> str:
32
+ return f"{index}: {card.name} [{card.cost}] - {card.rules_text()} | {card.flavor}"
33
+
34
+
35
+ # Return the visible combat state for one player.
36
+ def player_line(player: PlayerState) -> str:
37
+ zones = f"hand {len(player.hand)}, deck {len(player.deck)}, discard {len(player.discard)}"
38
+ return f"{player.name}: {player.hp} HP, {player.energy} energy, {player.block} block, {player.ward} ward, {player.shield_charge} charge ({zones})"
39
+
40
+
41
+ # Return a focused prompt-state line before a player chooses.
42
+ def choice_state_line(state: DuelState, actor: PlayerState) -> str:
43
+ target = opponent(state, actor)
44
+ return f"{actor.name} energy {actor.energy}. {target.name}: {target.hp} HP, {target.block} block, {target.ward} ward."
45
+
46
+
47
+ # Return currently playable hand indexes for a player.
48
+ def playable_indexes(player: PlayerState) -> tuple[int, ...]:
49
+ return tuple(index for index, card in enumerate(player.hand) if card.cost <= player.energy)
50
+
51
+
52
+ # Return a simple legal demo deck without model generation.
53
+ def demo_deck(school: School, theme: str) -> tuple[Card, ...]:
54
+ backbone = backbone_deck(school, theme)
55
+ return (backbone * 3)[:15]
56
+
57
+
58
+ # Print a player's hand.
59
+ def print_hand(player: PlayerState, print_fn: Printer) -> None:
60
+ for index, card in enumerate(player.hand):
61
+ print_fn(card_line(index, card))
62
+
63
+
64
+ # Print one generated draft pack.
65
+ def print_pack(pack: tuple[Card, ...], print_fn: Printer) -> None:
66
+ for index, card in enumerate(pack):
67
+ print_fn(pack_line(index, card))
68
+
69
+
70
+ # Prompt the human for one draft pack choice.
71
+ def prompt_pack_choice(prompt: Prompt, print_fn: Printer) -> Callable[[tuple[Card, ...], tuple[Card, ...]], int]:
72
+ # Choose one card from a generated pack.
73
+ def choose(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int:
74
+ del current_deck
75
+ while True:
76
+ print_pack(pack, print_fn)
77
+ answer = prompt("Draft card number: ").strip()
78
+ if answer.isdigit() and int(answer) < len(pack):
79
+ return int(answer)
80
+ print_fn("Choose one of the shown draft cards.")
81
+
82
+ return choose
83
+
84
+
85
+ # Draft a player deck from nine generated packs.
86
+ def draft_player_deck(
87
+ client: CardPackClient,
88
+ school: School,
89
+ theme: str,
90
+ prompt: Prompt = input,
91
+ print_fn: Printer = print,
92
+ rng: Random | None = None,
93
+ ) -> tuple[Card, ...]:
94
+ pick_number = 0
95
+ choose_pack = prompt_pack_choice(prompt, print_fn)
96
+
97
+ # Choose one pack card while printing draft progress.
98
+ def choose(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int:
99
+ nonlocal pick_number
100
+ pick_number += 1
101
+ print_fn(f"Draft pick {pick_number}/9. Current deck: {len(current_deck)} cards.")
102
+ return choose_pack(current_deck, pack)
103
+
104
+ return draft_deck_from_packs(client, school, theme, SYNERGY_COSTS, choose, rng=rng)
105
+
106
+
107
+ # Draft an enemy deck by taking the strongest card from each generated pack.
108
+ def draft_enemy_deck(
109
+ client: CardPackClient,
110
+ school: School,
111
+ theme: str,
112
+ print_fn: Printer = print,
113
+ rng: Random | None = None,
114
+ ) -> tuple[Card, ...]:
115
+ pick_number = 0
116
+
117
+ # Choose a deck-aware card from each generated pack.
118
+ def choose(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int:
119
+ nonlocal pick_number
120
+ pick_number += 1
121
+ choice = best_draft_index(current_deck, pack)
122
+ print_fn(f"Enemy generated pick {pick_number}/9: {pack[choice].name}")
123
+ return choice
124
+
125
+ return draft_deck_from_packs(client, school, theme, SYNERGY_COSTS, choose, rng=rng)
126
+
127
+
128
+ # Return the strongest candidate index in a generated pack.
129
+ def best_pack_index(pack: tuple[Card, ...]) -> int:
130
+ return max(range(len(pack)), key=lambda index: (card_score(pack[index]), -index))
131
+
132
+
133
+ # Return the strongest draft pick for the current deck's school plan.
134
+ def best_draft_index(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int:
135
+ return max(range(len(pack)), key=lambda index: (draft_card_score(current_deck, pack[index]), -index))
136
+
137
+
138
+ # Return a deck-aware draft score for one card.
139
+ def draft_card_score(current_deck: tuple[Card, ...], card: Card) -> int:
140
+ counts = deck_summary(current_deck)
141
+ return card_score(card) + school_need_bonus(card, counts) - repetition_penalty(card, counts)
142
+
143
+
144
+ # Return class-specific bonus for cards that fill missing deck needs.
145
+ def school_need_bonus(card: Card, counts: dict[str, int]) -> int:
146
+ primitives = card_primitives(card)
147
+ if card.school == "ice":
148
+ return ice_need_bonus(primitives, counts)
149
+ if card.school == "earth":
150
+ return earth_need_bonus(primitives, counts)
151
+ return fire_need_bonus(primitives, counts)
152
+
153
+
154
+ # Return Fire's draft need bonus.
155
+ def fire_need_bonus(primitives: tuple[str, ...], counts: dict[str, int]) -> int:
156
+ delayed = counts.get("burn", 0) + counts.get("bomb", 0)
157
+ pressure = counts.get("deal", 0) + counts.get("scaling", 0)
158
+ if delayed >= pressure and ("deal" in primitives or "burn" in primitives or "scaling" in primitives):
159
+ return 18
160
+ return 8 if "deal" in primitives and counts.get("deal", 0) < 5 else 0
161
+
162
+
163
+ # Return Ice's draft need bonus.
164
+ def ice_need_bonus(primitives: tuple[str, ...], counts: dict[str, int]) -> int:
165
+ if counts.get("vulnerable", 0) > 0 and counts.get("multi_hit", 0) == 0 and "multi_hit" in primitives:
166
+ return 35
167
+ if counts.get("multi_hit", 0) > 0 and counts.get("initiative", 0) == 0 and "initiative" in primitives:
168
+ return 35
169
+ if counts.get("multi_hit", 0) > counts.get("vulnerable", 0) + 1 and "vulnerable" in primitives:
170
+ return 30
171
+ if counts.get("multi_hit", 0) > counts.get("initiative", 0) + 2 and "initiative" in primitives:
172
+ return 30
173
+ return 0
174
+
175
+
176
+ # Return Earth's draft need bonus.
177
+ def earth_need_bonus(primitives: tuple[str, ...], counts: dict[str, int]) -> int:
178
+ if counts.get("scaling", 0) > 0 and counts.get("block", 0) <= counts.get("scaling", 0) and "block" in primitives:
179
+ return 45
180
+ if counts.get("deal", 0) >= counts.get("block", 0) + 2 and ("block" in primitives or "draw" in primitives or "weak" in primitives):
181
+ return 35
182
+ if counts.get("block", 0) > 0 and counts.get("scaling", 0) == 0 and "scaling" in primitives:
183
+ return 35
184
+ protection = counts.get("block", 0) + counts.get("ward", 0)
185
+ payoff = counts.get("scaling", 0) + counts.get("conditional", 0)
186
+ if protection > payoff and ("scaling" in primitives or "weak" in primitives or "draw" in primitives):
187
+ return 20
188
+ return 0
189
+
190
+
191
+ # Return a penalty for repeating already represented primitives.
192
+ def repetition_penalty(card: Card, counts: dict[str, int]) -> int:
193
+ return sum(counts.get(primitive, 0) * repeat_weight(primitive) for primitive in card_primitives(card))
194
+
195
+
196
+ # Return how strongly repeated primitives are penalized.
197
+ def repeat_weight(primitive_id: str) -> int:
198
+ if primitive_id in {"multi_hit", "bomb", "ward", "block"}:
199
+ return 5
200
+ return 3
201
+
202
+
203
+ # Return primitive ids on one card.
204
+ def card_primitives(card: Card) -> tuple[str, ...]:
205
+ return tuple(effect.primitive_id for effect in card.effects)
206
+
207
+
208
+ # Prompt the human for one card choice.
209
+ def prompt_human_choice(prompt: Prompt, print_fn: Printer) -> Chooser:
210
+ # Choose one playable card from prompted input.
211
+ def choose(state: DuelState, actor: PlayerState) -> int | None:
212
+ while True:
213
+ print_fn(choice_state_line(state, actor))
214
+ print_hand(actor, print_fn)
215
+ answer = prompt("Choose card number, or pass: ").strip().lower()
216
+ if answer in {"", "p", "pass"}:
217
+ return None
218
+ if answer.isdigit() and int(answer) in playable_indexes(actor):
219
+ return int(answer)
220
+ print_fn("That card is not playable.")
221
+
222
+ return choose
223
+
224
+
225
+ # Choose the best playable card for the enemy heuristic.
226
+ def choose_enemy_card(state: DuelState, actor: PlayerState) -> int | None:
227
+ playable = playable_indexes(actor)
228
+ if not playable:
229
+ return None
230
+ target = opponent(state, actor)
231
+ lethal = tuple(index for index in playable if immediate_hp_damage(actor, target, actor.hand[index]) >= target.hp)
232
+ if lethal:
233
+ return max(lethal, key=lambda index: (immediate_hp_damage(actor, target, actor.hand[index]), -index))
234
+ return max(playable, key=lambda index: (combat_card_score(state, actor, actor.hand[index]), -index))
235
+
236
+
237
+ # Choose the best playable card for the assistant.
238
+ def choose_assistant_card(state: DuelState, actor: PlayerState) -> int | None:
239
+ return choose_enemy_card(state, actor)
240
+
241
+
242
+ # Return a static draft score for one card.
243
+ def card_score(card: Card) -> int:
244
+ return sum(effect_score(effect.primitive_id, effect.amount, effect.hits) for effect in card.effects) - card.cost
245
+
246
+
247
+ # Return a combat-aware score for one card.
248
+ def combat_card_score(state: DuelState, actor: PlayerState, card: Card) -> int:
249
+ target = opponent(state, actor)
250
+ defense_bonus = 4 if actor.hp <= target.hp else 0
251
+ return immediate_hp_damage(actor, target, card) * 3 + card_score(card) + defense_bonus * defensive_amount(card)
252
+
253
+
254
+ # Return the defensive amount on a card.
255
+ def defensive_amount(card: Card) -> int:
256
+ return sum(effect.amount for effect in card.effects if effect.primitive_id in {"block", "ward", "weak"})
257
+
258
+
259
+ # Return approximate immediate HP damage from a card.
260
+ def immediate_hp_damage(actor: PlayerState, target: PlayerState, card: Card) -> int:
261
+ return sum(effect_hp_damage(actor, target, card, index) for index, _ in enumerate(card.effects))
262
+
263
+
264
+ # Return approximate immediate HP damage from one effect.
265
+ def effect_hp_damage(actor: PlayerState, target: PlayerState, card: Card, effect_index: int) -> int:
266
+ effect = card.effects[effect_index]
267
+ if effect.primitive_id == "deal":
268
+ return hp_damage_after_defense(actor, target, effect.amount)
269
+ if effect.primitive_id == "multi_hit":
270
+ return sum(hp_damage_after_defense(actor, target, effect.amount) for _ in range(effect.hits))
271
+ if effect.primitive_id == "conditional":
272
+ return hp_damage_after_defense(actor, target, conditional_damage(target, effect.amount))
273
+ if effect.primitive_id == "scaling":
274
+ if effect.condition == "shield_charge":
275
+ return hp_damage_after_defense(actor, target, effect.amount + actor.shield_charge)
276
+ return hp_damage_after_defense(actor, target, effect.amount + actor.cards_played_this_turn + 1)
277
+ return 0
278
+
279
+
280
+ # Return HP damage after current weak, vulnerable, ward, and block.
281
+ def hp_damage_after_defense(actor: PlayerState, target: PlayerState, amount: int) -> int:
282
+ damage = max(0, amount - actor.weak)
283
+ if damage == 0 or target.ward > 0:
284
+ return 0
285
+ damage += target.vulnerable
286
+ return max(0, damage - target.block)
287
+
288
+
289
+ # Return a primitive's rough tactical score.
290
+ def effect_score(primitive_id: str, amount: int, hits: int) -> int:
291
+ if primitive_id in {"deal", "conditional", "scaling"}:
292
+ return amount * 2
293
+ if primitive_id == "multi_hit":
294
+ return amount * hits * 2
295
+ if primitive_id in {"block", "ward", "weak"}:
296
+ return amount
297
+ if primitive_id in {"burn", "bomb", "vulnerable"}:
298
+ return amount * 2
299
+ if primitive_id in {"draw", "energy", "initiative"}:
300
+ return amount + 2
301
+ return 0
302
+
303
+
304
+ # Play cards for one actor until they pass or cannot act.
305
+ def play_turn(state: DuelState, actor: PlayerState, choose: Chooser, print_fn: Printer) -> None:
306
+ while playable_indexes(actor):
307
+ choice = choose(state, actor)
308
+ if choice is None:
309
+ expire_energy(actor)
310
+ print_fn(f"{actor.name} passes.")
311
+ return
312
+ card = play_card_from_hand(state, actor, choice)
313
+ print_fn(f"{actor.name} plays {card.name}: {card.rules_text()}")
314
+ if winner(state):
315
+ expire_energy(actor)
316
+ return
317
+ expire_energy(actor)
318
+ print_fn(f"{actor.name} has no playable cards.")
319
+
320
+
321
+ # Expire unused turn energy.
322
+ def expire_energy(player: PlayerState) -> None:
323
+ player.energy = 0
324
+
325
+
326
+ # Print both sides of the duel state.
327
+ def print_state(state: DuelState, print_fn: Printer) -> None:
328
+ print_fn(player_line(state.player))
329
+ print_fn(player_line(state.enemy))
330
+
331
+
332
+ # Run a tiny terminal duel between a human and the assistant policy.
333
+ def run_text_duel(
334
+ player_deck: tuple[Card, ...] | None = None,
335
+ enemy_deck: tuple[Card, ...] | None = None,
336
+ prompt: Prompt = input,
337
+ print_fn: Printer = print,
338
+ rng: Random | None = None,
339
+ max_rounds: int = MAX_ROUNDS,
340
+ boss_client: BossClientLike | None = None,
341
+ ) -> str:
342
+ from boss import boss_chooser
343
+
344
+ randomizer = rng or Random()
345
+ state = DuelState(
346
+ create_player("You", shuffled_deck(player_deck or demo_deck("fire", "dark fantasy"), randomizer)),
347
+ create_player("Enemy", shuffled_deck(enemy_deck or demo_deck("earth", "dark fantasy"), randomizer)),
348
+ )
349
+ draw_cards(state.player, OPENING_HAND_SIZE)
350
+ draw_cards(state.enemy, OPENING_HAND_SIZE)
351
+ human_choice = prompt_human_choice(prompt, print_fn)
352
+ enemy_choice = boss_chooser(boss_client, choose_enemy_card)
353
+ print_state(state, print_fn)
354
+ while not winner(state) and state.round_number < max_rounds:
355
+ order = start_round(state, randomizer)
356
+ print_fn(f"Round {state.round_number}: {' then '.join(order)}")
357
+ for name in order:
358
+ if winner(state):
359
+ break
360
+ actor = named_player(state, name)
361
+ chooser = human_choice if actor is state.player else enemy_choice
362
+ play_turn(state, actor, chooser, print_fn)
363
+ print_state(state, print_fn)
364
+ result = winner(state) or "no winner"
365
+ print_fn(f"Winner: {result}")
366
+ return result
367
+
368
+
369
+ # Return a shuffled copy of a deck.
370
+ def shuffled_deck(deck: tuple[Card, ...], rng: Random) -> tuple[Card, ...]:
371
+ cards = list(deck)
372
+ rng.shuffle(cards)
373
+ return tuple(cards)
374
+
375
+
376
+ # Start an interactive terminal duel.
377
+ def main() -> None: # pragma: no cover
378
+ from clients import boss_client_from_env, card_client_from_env
379
+
380
+ rng = Random()
381
+ client = card_client_from_env() or CodexCardClient(cwd=".")
382
+ player_deck = draft_player_deck(client, "fire", "dark fantasy", rng=rng)
383
+ enemy_deck = draft_enemy_deck(client, "earth", "dark fantasy", rng=rng)
384
+ run_text_duel(player_deck=player_deck, enemy_deck=enemy_deck, rng=rng, boss_client=boss_client_from_env())
385
+
386
+
387
+ if __name__ == "__main__": # pragma: no cover
388
+ main()
primitives.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+ PrimitiveId = Literal[
5
+ "deal",
6
+ "burn",
7
+ "bomb",
8
+ "block",
9
+ "ward",
10
+ "weak",
11
+ "draw",
12
+ "energy",
13
+ "initiative",
14
+ "multi_hit",
15
+ "vulnerable",
16
+ "conditional",
17
+ "scaling",
18
+ ]
19
+ School = Literal["fire", "ice", "earth"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PrimitiveSpec:
24
+ id: PrimitiveId
25
+ name: str
26
+ category: str
27
+ schools: frozenset[School]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Effect:
32
+ primitive_id: PrimitiveId
33
+ amount: int = 0
34
+ duration: int = 0
35
+ delay: int = 0
36
+ hits: int = 1
37
+ condition: str = ""
38
+
39
+
40
+ PRIMITIVES: dict[PrimitiveId, PrimitiveSpec] = {
41
+ "deal": PrimitiveSpec("deal", "Deal", "damage", frozenset(("fire",))),
42
+ "burn": PrimitiveSpec("burn", "Burn", "damage", frozenset(("fire",))),
43
+ "bomb": PrimitiveSpec("bomb", "Bomb", "damage", frozenset(("fire",))),
44
+ "block": PrimitiveSpec("block", "Block", "defense", frozenset(("earth",))),
45
+ "ward": PrimitiveSpec("ward", "Ward", "defense", frozenset(("earth",))),
46
+ "weak": PrimitiveSpec("weak", "Weak", "defense", frozenset()),
47
+ "draw": PrimitiveSpec("draw", "Draw", "tempo", frozenset()),
48
+ "energy": PrimitiveSpec("energy", "Energy", "tempo", frozenset()),
49
+ "initiative": PrimitiveSpec("initiative", "Initiative", "tempo", frozenset(("ice",))),
50
+ "multi_hit": PrimitiveSpec("multi_hit", "Multi-hit", "combo", frozenset(("ice",))),
51
+ "vulnerable": PrimitiveSpec("vulnerable", "Vulnerable", "combo", frozenset(("ice",))),
52
+ "conditional": PrimitiveSpec("conditional", "Conditional", "combo", frozenset(("ice", "earth"))),
53
+ "scaling": PrimitiveSpec("scaling", "Scaling", "combo", frozenset(("fire", "earth"))),
54
+ }
55
+
56
+ SCHOOL_PRIMITIVES: dict[School, tuple[PrimitiveId, ...]] = {
57
+ "fire": ("deal", "burn", "bomb", "scaling", "draw", "energy", "block", "conditional"),
58
+ "ice": ("deal", "initiative", "vulnerable", "multi_hit", "conditional", "draw", "energy", "block"),
59
+ "earth": ("deal", "ward", "block", "weak", "scaling", "conditional", "draw", "energy"),
60
+ }
61
+
62
+ SCHOOL_BIAS: dict[School, str] = {
63
+ "fire": "Prefer immediate Deal first, then Burn/Scaling, then occasional Bomb, Draw, Energy, or Block.",
64
+ "ice": "Prefer tempo pressure through Deal, Initiative, Vulnerable, Multi-hit, and Conditional, with occasional Draw or Block.",
65
+ "earth": "Prefer Block and Ward to bank shield charge, then Scaling to release that charge as burst damage. Add some Deal, Weak, and Draw.",
66
+ }
67
+
68
+
69
+ # Return all primitive identifiers in canonical order.
70
+ def primitive_ids() -> tuple[PrimitiveId, ...]:
71
+ return tuple(PRIMITIVES)
72
+
73
+
74
+ # Return a primitive spec by id.
75
+ def get_primitive(primitive_id: PrimitiveId) -> PrimitiveSpec:
76
+ return PRIMITIVES[primitive_id]
77
+
78
+
79
+ # Return the primitive ids available to a school.
80
+ def school_primitives(school: School) -> tuple[PrimitiveId, ...]:
81
+ return SCHOOL_PRIMITIVES[school]
82
+
83
+
84
+ # Return whether a primitive belongs in a school's draft pool.
85
+ def primitive_allowed_for_school(primitive_id: PrimitiveId, school: School) -> bool:
86
+ return primitive_id in SCHOOL_PRIMITIVES[school]
87
+
88
+
89
+ # Return a school's generation bias.
90
+ def school_bias(school: School) -> str:
91
+ return SCHOOL_BIAS[school]
92
+
93
+
94
+ # Render a costed effect into fixed card text.
95
+ def render_effect(effect: Effect) -> str:
96
+ match effect.primitive_id:
97
+ case "deal":
98
+ return f"Deal {effect.amount} damage."
99
+ case "burn":
100
+ return f"Burn {effect.amount} damage for {effect.duration} turns."
101
+ case "bomb":
102
+ return f"Bomb: deal {effect.amount} damage in {effect.delay} turns."
103
+ case "block":
104
+ return f"Gain {effect.amount} block until your next turn."
105
+ case "ward":
106
+ return f"Gain {effect.amount} ward."
107
+ case "weak":
108
+ return f"Opponent deals {effect.amount} less damage until your next turn."
109
+ case "draw":
110
+ return f"Draw {effect.amount} card{'s' if effect.amount != 1 else ''}."
111
+ case "energy":
112
+ return f"Gain {effect.amount} energy this turn."
113
+ case "initiative":
114
+ return "Opponent acts second next round."
115
+ case "multi_hit":
116
+ return f"Deal {effect.amount} damage {effect.hits} times."
117
+ case "vulnerable":
118
+ return f"Opponent takes {effect.amount} more damage for {effect.duration} turns."
119
+ case "conditional":
120
+ return f"Deal up to {effect.amount} damage based on opponent's missing HP."
121
+ case "scaling":
122
+ if effect.condition == "shield_charge":
123
+ return f"Deal {effect.amount} damage plus your banked shield charge, then empty it."
124
+ return f"Deal {effect.amount} damage plus cards played this turn."
pyproject.toml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.pytest.ini_options]
2
+ 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
10
+ fail_under = 90
tests/test_ai_runtime.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from ai_runtime import chat_endpoint, local_ai_env, minicpm_server_command, start_minicpm_server
4
+
5
+
6
+ # Verify local chat endpoints use OpenAI-compatible paths.
7
+ def test_chat_endpoint() -> None:
8
+ assert chat_endpoint(8090) == "http://127.0.0.1:8090/v1/chat/completions"
9
+
10
+
11
+ # Verify the MiniCPM server command points llama.cpp at the GGUF model.
12
+ def test_minicpm_server_command() -> None:
13
+ command = minicpm_server_command(Path("mini.gguf"), 9000)
14
+ assert command[:3] == ("llama-server", "--model", "mini.gguf")
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.
21
+ def test_local_ai_env(monkeypatch) -> None:
22
+ monkeypatch.setenv("TABRAS_CARD_ENDPOINT", "old")
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.
32
+ def test_start_minicpm_server_requires_model(tmp_path) -> None:
33
+ missing = tmp_path / "missing.gguf"
34
+ try:
35
+ start_minicpm_server(missing, 9002)
36
+ except FileNotFoundError as error:
37
+ assert str(missing) in str(error)
38
+ else:
39
+ raise AssertionError("missing MiniCPM model did not fail")
tests/test_boss.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from boss import (
4
+ HeuristicBossClient,
5
+ NemotronBossClient,
6
+ affordable_indexes,
7
+ boss_chooser,
8
+ boss_indexes,
9
+ boss_payload,
10
+ boss_prompt,
11
+ boss_system_prompt,
12
+ hand_payload,
13
+ parse_card_indexes,
14
+ player_payload,
15
+ safe_boss_indexes,
16
+ )
17
+ from budget import Card
18
+ from game import DuelState, create_player
19
+ from play import run_text_duel
20
+ from primitives import Effect
21
+
22
+
23
+ class FakeBossClient:
24
+ # Initialize the fake boss client with a fixed raw response.
25
+ def __init__(self, raw: dict[str, Any]) -> None:
26
+ self.raw = raw
27
+ self.payload: dict[str, Any] | None = None
28
+
29
+ # Capture the payload and return a fixed boss response.
30
+ def choose_cards(self, payload: dict[str, Any]) -> dict[str, Any]:
31
+ self.payload = payload
32
+ return self.raw
33
+
34
+
35
+ class FakeChat:
36
+ # Initialize the fake chat client with fixed text.
37
+ def __init__(self, text: str) -> None:
38
+ self.text = text
39
+ self.calls: list[tuple[str, str]] = []
40
+
41
+ # Capture prompts and return fixed text.
42
+ def complete(self, system: str, user: str) -> str:
43
+ self.calls.append((system, user))
44
+ return self.text
45
+
46
+
47
+ class BrokenBossClient:
48
+ # Raise like a malformed model response.
49
+ def choose_cards(self, payload: dict[str, Any]) -> dict[str, Any]:
50
+ del payload
51
+ raise ValueError("bad model output")
52
+
53
+
54
+ # Build one boss test card.
55
+ def make_card(name: str, cost: int, effect: Effect) -> Card:
56
+ return Card(name, cost, "fire", "wuxia", (effect,))
57
+
58
+
59
+ # Build one boss test duel.
60
+ def make_duel() -> DuelState:
61
+ return DuelState(create_player("You", []), create_player("Enemy", []))
62
+
63
+
64
+ # Verify boss payload exposes visible state.
65
+ def test_boss_payload() -> None:
66
+ state = make_duel()
67
+ state.enemy.energy = 3
68
+ state.enemy.shield_charge = 2
69
+ state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))]
70
+ payload = boss_payload(state, state.enemy)
71
+ assert payload["actor"]["shield_charge"] == 2
72
+ assert payload["opponent"]["name"] == "You"
73
+ assert payload["hand"][0]["rules_text"] == "Deal 2 damage."
74
+
75
+
76
+ # Verify heuristic boss client returns an empty model choice.
77
+ def test_heuristic_boss_client() -> None:
78
+ assert HeuristicBossClient().choose_cards({}) == {"card_indexes": []}
79
+
80
+
81
+ # Verify boss prompts ask for strict JSON and playable indexes.
82
+ def test_boss_prompts() -> None:
83
+ payload = {"playable_indexes": (0,), "actor": {}, "opponent": {}, "hand": []}
84
+ assert "strict JSON" in boss_system_prompt()
85
+ assert "playable_indexes" in boss_prompt(payload)
86
+
87
+
88
+ # Verify Nemotron boss client uses local chat completions.
89
+ def test_nemotron_boss_client() -> None:
90
+ chat = FakeChat('{"card_indexes": [0], "reason": "lethal"}')
91
+ client = NemotronBossClient(chat) # type: ignore[arg-type]
92
+ assert client.choose_cards({"playable_indexes": [0]})["card_indexes"] == [0]
93
+ assert "strict JSON" in chat.calls[0][0]
94
+ assert "card_indexes" in chat.calls[0][1]
95
+
96
+
97
+ # Verify player payload includes zone counts.
98
+ def test_player_payload() -> None:
99
+ player = create_player("Enemy", [make_card("Deck", 1, Effect("deal", amount=1))])
100
+ player.hand = [make_card("Hand", 1, Effect("deal", amount=1))]
101
+ player.discard = [make_card("Discard", 1, Effect("deal", amount=1))]
102
+ assert player_payload(player)["deck_count"] == 1
103
+ assert player_payload(player)["hand_count"] == 1
104
+ assert player_payload(player)["discard_count"] == 1
105
+
106
+
107
+ # Verify hand payload indexes playable cards.
108
+ def test_hand_payload() -> None:
109
+ player = create_player("Enemy", [])
110
+ player.hand = [make_card("Strike", 1, Effect("deal", amount=2))]
111
+ assert hand_payload(player) == [{"index": 0, "name": "Strike", "cost": 1, "rules_text": "Deal 2 damage."}]
112
+
113
+
114
+ # Verify model indexes parse defensively.
115
+ def test_parse_card_indexes() -> None:
116
+ assert parse_card_indexes({"card_indexes": [2, "x", 0]}) == (2, 0)
117
+ assert parse_card_indexes({"card_indexes": "bad"}) == ()
118
+ assert parse_card_indexes({}) == ()
119
+
120
+
121
+ # Verify affordability validation preserves sequence.
122
+ def test_affordable_indexes() -> None:
123
+ actor = create_player("Enemy", [])
124
+ actor.energy = 3
125
+ actor.hand = [
126
+ make_card("One", 1, Effect("deal", amount=1)),
127
+ make_card("Three", 3, Effect("deal", amount=1)),
128
+ make_card("Two", 2, Effect("deal", amount=1)),
129
+ ]
130
+ assert affordable_indexes(actor, (2, 0, 1, 2, 9)) == (2, 0)
131
+
132
+
133
+ # Verify boss indexes go through payload and validation.
134
+ def test_boss_indexes() -> None:
135
+ state = make_duel()
136
+ state.enemy.energy = 1
137
+ state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))]
138
+ client = FakeBossClient({"card_indexes": [0]})
139
+ assert boss_indexes(client, state, state.enemy) == (0,)
140
+ assert client.payload is not None
141
+ assert client.payload["actor"]["name"] == "Enemy"
142
+
143
+
144
+ # Verify boss index safety converts model failures to no choice.
145
+ def test_safe_boss_indexes_handles_model_failure() -> None:
146
+ state = make_duel()
147
+ assert safe_boss_indexes(BrokenBossClient(), state, state.enemy) == ()
148
+
149
+
150
+ # Verify boss chooser falls back when model output is invalid.
151
+ def test_boss_chooser_falls_back() -> None:
152
+ state = make_duel()
153
+ state.enemy.energy = 1
154
+ state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))]
155
+ chooser = boss_chooser(FakeBossClient({"card_indexes": [9]}))
156
+ assert chooser(state, state.enemy) == 0
157
+
158
+
159
+ # Verify boss chooser falls back when the model fails.
160
+ def test_boss_chooser_falls_back_on_model_failure() -> None:
161
+ state = make_duel()
162
+ state.enemy.energy = 1
163
+ state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))]
164
+ chooser = boss_chooser(BrokenBossClient())
165
+ assert chooser(state, state.enemy) == 0
166
+
167
+
168
+ # Verify boss chooser uses a valid model card first.
169
+ def test_boss_chooser_uses_model_card() -> None:
170
+ state = make_duel()
171
+ state.enemy.energy = 2
172
+ state.enemy.hand = [
173
+ make_card("A", 1, Effect("deal", amount=1)),
174
+ make_card("B", 2, Effect("block", amount=5)),
175
+ ]
176
+ chooser = boss_chooser(FakeBossClient({"card_indexes": [0]}))
177
+ assert chooser(state, state.enemy) == 0
178
+
179
+
180
+ # Verify run_text_duel can use a boss client for enemy turns.
181
+ def test_run_text_duel_uses_boss_client() -> None:
182
+ outputs: list[str] = []
183
+ player_deck = tuple(make_card("Wait", 5, Effect("block", amount=1)) for _ in range(4))
184
+ enemy_deck = tuple(make_card("Strike", 1, Effect("deal", amount=25)) for _ in range(4))
185
+ result = run_text_duel(player_deck, enemy_deck, lambda _: "pass", outputs.append, max_rounds=1, boss_client=FakeBossClient({"card_indexes": [0]}))
186
+ assert result == "Enemy"
187
+ assert any("Enemy plays Strike" in line for line in outputs)
tests/test_budget.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from random import Random
2
+
3
+ import pytest
4
+
5
+ from budget import CardSpec, EffectPlan, budget_for_energy, cost_card, cost_effect, cost_effects, miracle_budget
6
+
7
+
8
+ class MaxRoll:
9
+ # Return the largest requested miracle multiplier.
10
+ def randint(self, low: int, high: int) -> int:
11
+ assert (low, high) == (1, 3)
12
+ return high
13
+
14
+
15
+ # Verify energy cost maps directly to point budget.
16
+ def test_budget_for_energy_accepts_locked_range() -> None:
17
+ assert [budget_for_energy(cost) for cost in range(1, 6)] == [1, 2, 3, 4, 5]
18
+ with pytest.raises(ValueError, match="card cost"):
19
+ budget_for_energy(0)
20
+ with pytest.raises(ValueError, match="card cost"):
21
+ budget_for_energy(6)
22
+
23
+
24
+ # Verify miracle budgets are high variance but hard capped.
25
+ def test_miracle_budget_is_capped() -> None:
26
+ assert miracle_budget(5, MaxRoll()) == 8 # type: ignore[arg-type]
27
+ assert miracle_budget(2, Random(1)) <= 6
28
+
29
+
30
+ # Verify model-selected shapes become engine-owned numbers.
31
+ def test_cost_card_assigns_numbers_from_budget() -> None:
32
+ spec = CardSpec("Cinder", 3, "fire", "wuxia", (EffectPlan("deal"),), flavor="Hot.")
33
+ card = cost_card(spec)
34
+ assert card.effects[0].amount == 6
35
+ assert card.rules_text() == "Deal 6 damage."
36
+
37
+
38
+ # Verify Earth scaling releases shield charge.
39
+ def test_earth_scaling_uses_shield_charge() -> None:
40
+ spec = CardSpec("Stone Burst", 3, "earth", "wuxia", (EffectPlan("scaling"),))
41
+ card = cost_card(spec)
42
+ assert card.effects[0].condition == "shield_charge"
43
+ assert card.rules_text() == "Deal 3 damage plus your banked shield charge, then empty it."
44
+
45
+
46
+ # Verify weighted multi-effect cards spend the full budget.
47
+ def test_cost_effects_split_weighted_budget() -> None:
48
+ effects = cost_effects(4, (EffectPlan("deal", weight=1), EffectPlan("burn", weight=3)))
49
+ assert effects[0].amount == 1
50
+ assert effects[1].amount == 2
51
+ assert effects[1].duration == 2
52
+
53
+
54
+ # Verify low-cost cards cannot keep more effect clauses than they can fund.
55
+ def test_cost_effects_trims_overstuffed_plans() -> None:
56
+ effects = cost_effects(2, (EffectPlan("deal"), EffectPlan("burn"), EffectPlan("bomb")))
57
+ assert len(effects) == 2
58
+ assert [effect.primitive_id for effect in effects] == ["deal", "burn"]
59
+
60
+
61
+ @pytest.mark.parametrize(
62
+ ("primitive_id", "points", "fields"),
63
+ (
64
+ ("deal", 2, {"amount": 4}),
65
+ ("burn", 4, {"amount": 4, "duration": 2}),
66
+ ("bomb", 4, {"amount": 8, "delay": 3}),
67
+ ("block", 2, {"amount": 6}),
68
+ ("ward", 2, {"amount": 2}),
69
+ ("weak", 2, {"amount": 4, "duration": 1}),
70
+ ("draw", 3, {"amount": 2}),
71
+ ("energy", 3, {"amount": 1}),
72
+ ("initiative", 2, {"duration": 1}),
73
+ ("multi_hit", 4, {"amount": 4, "hits": 3}),
74
+ ("vulnerable", 4, {"amount": 4, "duration": 2}),
75
+ ("conditional", 2, {"amount": 2, "condition": "opponent_below_half"}),
76
+ ("scaling", 2, {"amount": 2, "condition": "cards_played"}),
77
+ ),
78
+ )
79
+ # Verify every primitive has a costing rule.
80
+ def test_cost_effect_rules(primitive_id: str, points: int, fields: dict[str, object]) -> None:
81
+ effect = cost_effect(primitive_id, points) # type: ignore[arg-type]
82
+ for name, value in fields.items():
83
+ assert getattr(effect, name) == value
84
+
85
+
86
+ # Verify invalid effect plans are rejected before costing.
87
+ def test_cost_card_rejects_invalid_specs() -> None:
88
+ with pytest.raises(ValueError, match="at least one"):
89
+ cost_card(CardSpec("Blank", 1, "fire", "wuxia", ()))
90
+ with pytest.raises(ValueError, match="weight"):
91
+ cost_card(CardSpec("Bad", 1, "fire", "wuxia", (EffectPlan("deal", 0),)))
92
+ with pytest.raises(ValueError, match="not allowed"):
93
+ cost_card(CardSpec("Bad", 1, "ice", "wuxia", (EffectPlan("bomb"),)))
94
+ with pytest.raises(ValueError, match="positive"):
95
+ cost_effect("deal", 0)
tests/test_clients.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
5
+
6
+
7
+ class FakeChat:
8
+ # Complete one fake prompt.
9
+ def complete(self, system: str, user: str) -> str:
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)
16
+ assert card_client_from_env() is None
17
+
18
+
19
+ # Verify card client reads endpoint environment.
20
+ def test_card_client_from_env(monkeypatch) -> None:
21
+ monkeypatch.setenv("TABRAS_CARD_ENDPOINT", "http://cards")
22
+ monkeypatch.setenv("TABRAS_CARD_MODEL", "mini")
23
+ client = card_client_from_env()
24
+ assert isinstance(client, MiniCPMCardClient)
25
+ assert client.chat.endpoint == "http://cards"
26
+ assert client.chat.model == "mini"
27
+
28
+
29
+ # Verify card client can use the direct MiniCPM Transformers backend.
30
+ def test_card_client_from_env_transformers(monkeypatch) -> None:
31
+ monkeypatch.setenv("TABRAS_CARD_BACKEND", "transformers")
32
+ monkeypatch.setenv("TABRAS_CARD_MODEL", "mini-local")
33
+ monkeypatch.setattr("clients.MiniCPMTransformersChatClient.load", lambda model_path: FakeChat())
34
+ client = card_client_from_env()
35
+ assert isinstance(client, MiniCPMCardClient)
36
+ assert isinstance(client.chat, FakeChat)
37
+
38
+
39
+ # Verify card client can use a llama.cpp MiniCPM backend.
40
+ def test_card_client_from_env_llamacpp(monkeypatch) -> None:
41
+ monkeypatch.setenv("TABRAS_CARD_BACKEND", "llamacpp")
42
+ monkeypatch.setenv("TABRAS_CARD_ENDPOINT", "http://cards")
43
+ monkeypatch.setenv("TABRAS_CARD_MODEL", "mini-q4")
44
+ client = card_client_from_env()
45
+ assert isinstance(client, LlamaCppCardClient)
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.
53
+ def test_boss_client_from_env_disabled(monkeypatch) -> None:
54
+ monkeypatch.delenv("TABRAS_AI_BOSS", raising=False)
55
+ assert boss_client_from_env() is None
56
+
57
+
58
+ # Verify boss client reads endpoint environment.
59
+ def test_boss_client_from_env(monkeypatch) -> None:
60
+ monkeypatch.setenv("TABRAS_AI_BOSS", "1")
61
+ monkeypatch.setenv("TABRAS_BOSS_ENDPOINT", "http://boss")
62
+ monkeypatch.setenv("TABRAS_BOSS_MODEL", "nemotron")
63
+ client = boss_client_from_env()
64
+ assert isinstance(client, NemotronBossClient)
65
+ assert client.chat.endpoint == "http://boss"
66
+ assert client.chat.model == "nemotron"
67
+
68
+
69
+ # Verify boss client can use a Nemotron formatted completion endpoint.
70
+ def test_boss_client_from_env_completion(monkeypatch) -> None:
71
+ monkeypatch.setenv("TABRAS_AI_BOSS", "1")
72
+ monkeypatch.setenv("TABRAS_BOSS_BACKEND", "completion")
73
+ monkeypatch.setenv("TABRAS_BOSS_ENDPOINT", "http://boss-completion")
74
+ client = boss_client_from_env()
75
+ assert isinstance(client, NemotronBossClient)
76
+ assert isinstance(client.chat, LocalCompletionClient)
77
+ assert client.chat.endpoint == "http://boss-completion"
78
+ assert client.chat.prompt_template is nemotron_prompt
79
+
80
+
81
+ # Verify boss client can use the direct Nemotron Transformers backend.
82
+ def test_boss_client_from_env_transformers(monkeypatch) -> None:
83
+ monkeypatch.setenv("TABRAS_AI_BOSS", "1")
84
+ monkeypatch.setenv("TABRAS_BOSS_BACKEND", "transformers")
85
+ monkeypatch.setenv("TABRAS_BOSS_MODEL", "nemotron-local")
86
+ monkeypatch.setattr("clients.NemotronTransformersChatClient.load", lambda *_args, **_kwargs: FakeChat())
87
+ client = boss_client_from_env()
88
+ assert isinstance(client, NemotronBossClient)
89
+ assert isinstance(client.chat, FakeChat)
90
+
91
+
92
+ # Verify boss client can use a quantized MLX backend.
93
+ def test_boss_client_from_env_mlx(monkeypatch) -> None:
94
+ monkeypatch.setenv("TABRAS_AI_BOSS", "1")
95
+ monkeypatch.setenv("TABRAS_BOSS_BACKEND", "mlx")
96
+ monkeypatch.setenv("TABRAS_BOSS_MODEL", "nemotron-mlx")
97
+ monkeypatch.setattr("clients.MLXChatClient.load", lambda *_args, **_kwargs: FakeChat())
98
+ client = boss_client_from_env()
99
+ assert isinstance(client, NemotronBossClient)
100
+ assert isinstance(client.chat, FakeChat)
tests/test_draft.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from random import Random
3
+
4
+ from draft import backbone_deck, draft_anchor_indexes, draft_deck, draft_deck_from_packs, draft_order, validated_pack_choice
5
+
6
+
7
+ class DraftClient:
8
+ # Initialize call tracking for draft generation.
9
+ def __init__(self) -> None:
10
+ self.calls = 0
11
+ self.deck_sizes: list[int] = []
12
+
13
+ # Return one legal earth card and record deck-aware call order.
14
+ def create_card(self, payload: dict[str, object]) -> dict[str, object]:
15
+ self.calls += 1
16
+ self.deck_sizes.append(len(payload["current_deck"])) # type: ignore[arg-type]
17
+ return {"name": f"Ward {self.calls}", "effects": [{"primitive_id": "ward"}]}
18
+
19
+
20
+ class DraftPackClient:
21
+ # Initialize call tracking for pack generation.
22
+ def __init__(self) -> None:
23
+ self.calls = 0
24
+ self.deck_sizes: list[int] = []
25
+ self.anchor_sizes: list[int] = []
26
+
27
+ # Return one legal earth pack and record deck-aware call order.
28
+ def create_pack(self, payload: dict[str, object]) -> dict[str, object]:
29
+ self.calls += 1
30
+ self.deck_sizes.append(len(payload["current_deck"])) # type: ignore[arg-type]
31
+ self.anchor_sizes.append(len(payload["draft_anchors"])) # type: ignore[arg-type]
32
+ return {
33
+ "cards": [
34
+ {"name": f"Ward {self.calls}A", "effects": [{"primitive_id": "ward"}]},
35
+ {"name": f"Ward {self.calls}B", "effects": [{"primitive_id": "block"}]},
36
+ {"name": f"Ward {self.calls}C", "effects": [{"primitive_id": "conditional"}]},
37
+ ]
38
+ }
39
+
40
+
41
+ # Verify backbone decks are six fixed text cards.
42
+ def test_backbone_deck_has_standard_cards() -> None:
43
+ deck = backbone_deck("ice", "cyberpunk")
44
+ assert len(deck) == 6
45
+ assert deck[0].name == "Attack (weak)"
46
+ assert deck[0].school == "ice"
47
+ assert deck[5].rules_text() == "Draw 1 card."
48
+
49
+
50
+ # Verify synergy drafting makes nine deck-aware model calls.
51
+ def test_draft_deck_adds_nine_synergy_cards() -> None:
52
+ client = DraftClient()
53
+ deck = draft_deck(client, "earth", "dark fantasy", (1, 2, 3, 4, 5, 1, 2, 3, 4))
54
+ assert len(deck) == 15
55
+ assert client.deck_sizes == [6, 7, 8, 9, 10, 11, 12, 13, 14]
56
+ assert deck[-1].name == "Ward 9"
57
+
58
+
59
+ # Verify pack drafting adds the selected candidate from each pack.
60
+ def test_draft_deck_from_packs_adds_selected_cards() -> None:
61
+ client = DraftPackClient()
62
+ choices: list[int] = []
63
+
64
+ # Always choose the second card in the pack.
65
+ def choose_index(current_deck: tuple[object, ...], pack: tuple[object, ...]) -> int:
66
+ assert len(pack) == 3
67
+ choices.append(len(current_deck))
68
+ return 1
69
+
70
+ deck = draft_deck_from_packs(client, "earth", "dark fantasy", (1, 2, 3, 4, 5, 1, 2, 3, 4), choose_index, rng=Random(0))
71
+ assert len(deck) == 15
72
+ assert client.deck_sizes == [6, 7, 8, 9, 10, 11, 12, 13, 14]
73
+ assert client.anchor_sizes[:2] == [0, 1]
74
+ assert client.anchor_sizes[2:] == [2, 2, 2, 2, 2, 2, 2]
75
+ assert choices == [6, 7, 8, 9, 10, 11, 12, 13, 14]
76
+ assert deck[-1].name == "Ward 9B"
77
+
78
+
79
+ # Verify anchor indexes are two random synergy positions.
80
+ def test_draft_anchor_indexes() -> None:
81
+ anchors = draft_anchor_indexes(9, Random(0))
82
+ assert len(anchors) == 2
83
+ assert anchors <= set(range(9))
84
+ with pytest.raises(ValueError, match="at least two"):
85
+ draft_anchor_indexes(1, Random(0))
86
+
87
+
88
+ # Verify draft order drafts anchors before remaining cards.
89
+ def test_draft_order_starts_with_anchors() -> None:
90
+ assert draft_order(5, frozenset((3, 1))) == (1, 3, 0, 2, 4)
91
+
92
+
93
+ # Verify draft rejects incomplete synergy cost schedules.
94
+ def test_draft_deck_requires_nine_costs() -> None:
95
+ with pytest.raises(ValueError, match="exactly nine"):
96
+ draft_deck(DraftClient(), "earth", "dark fantasy", (1,))
97
+ with pytest.raises(ValueError, match="exactly nine"):
98
+ draft_deck_from_packs(DraftPackClient(), "earth", "dark fantasy", (1,), lambda _, __: 0)
99
+
100
+
101
+ # Verify draft pack choices must point into the pack.
102
+ def test_validated_pack_choice_rejects_bad_index() -> None:
103
+ pack = backbone_deck("earth", "dark fantasy")[:3]
104
+ assert validated_pack_choice(2, pack) == 2
105
+ with pytest.raises(ValueError, match="outside"):
106
+ validated_pack_choice(3, pack)
107
+ with pytest.raises(ValueError, match="outside"):
108
+ validated_pack_choice(-1, pack)
tests/test_game.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from random import Random
2
+
3
+ import pytest
4
+
5
+ from budget import Card
6
+ from game import (
7
+ DuelState,
8
+ PendingEffect,
9
+ advance_pending,
10
+ begin_turn,
11
+ create_player,
12
+ deal_damage,
13
+ draw_cards,
14
+ named_player,
15
+ play_card,
16
+ play_card_from_hand,
17
+ round_order,
18
+ start_round,
19
+ winner,
20
+ )
21
+ from primitives import Effect
22
+
23
+
24
+ # Build a test card with fixed effects.
25
+ def make_card(cost: int, *effects: Effect) -> Card:
26
+ return Card("Test", cost, "fire", "wuxia", tuple(effects))
27
+
28
+
29
+ # Build a duel state with empty decks.
30
+ def make_duel() -> DuelState:
31
+ return DuelState(create_player("player", []), create_player("enemy", []))
32
+
33
+
34
+ # Verify draw applies escalating fatigue when the deck is empty.
35
+ def test_draw_cards_applies_fatigue() -> None:
36
+ player = create_player("player", [make_card(1, Effect("deal", amount=2))])
37
+ draw_cards(player, 3)
38
+ assert len(player.hand) == 1
39
+ assert player.hp == 17
40
+ assert player.fatigue == 3
41
+
42
+
43
+ # Verify damage resolves through block before HP.
44
+ def test_damage_uses_block() -> None:
45
+ state = make_duel()
46
+ state.enemy.block = 3
47
+ dealt = deal_damage(state.player, state.enemy, 5)
48
+ assert dealt == 2
49
+ assert state.enemy.block == 0
50
+ assert state.enemy.hp == 18
51
+ assert state.enemy.shield_charge == 1
52
+
53
+
54
+ # Verify ward absorbs chip and breaks on a single threshold hit.
55
+ def test_ward_bubble_rule() -> None:
56
+ state = make_duel()
57
+ state.enemy.ward = 4
58
+ assert deal_damage(state.player, state.enemy, 3) == 0
59
+ assert state.enemy.ward == 4
60
+ assert state.enemy.hp == 20
61
+ assert state.enemy.shield_charge == 0
62
+ assert deal_damage(state.player, state.enemy, 4) == 0
63
+ assert state.enemy.ward == 0
64
+ assert state.enemy.hp == 20
65
+ assert state.enemy.shield_charge == 0
66
+
67
+
68
+ # Verify weak and vulnerable modify outgoing damage deterministically.
69
+ def test_weak_and_vulnerable_modify_damage() -> None:
70
+ state = make_duel()
71
+ state.player.weak = 2
72
+ state.enemy.vulnerable = 3
73
+ assert deal_damage(state.player, state.enemy, 5) == 6
74
+ assert state.enemy.hp == 14
75
+ state.player.weak = 10
76
+ assert deal_damage(state.player, state.enemy, 5) == 0
77
+
78
+
79
+ # Verify playing a card spends energy and resolves effects.
80
+ def test_play_card_spends_energy_and_resolves_effects() -> None:
81
+ state = make_duel()
82
+ state.player.energy = 3
83
+ play_card(state, state.player, make_card(2, Effect("deal", amount=4), Effect("block", amount=3)))
84
+ assert state.player.energy == 1
85
+ assert state.player.block == 3
86
+ assert state.enemy.hp == 16
87
+ with pytest.raises(ValueError, match="not enough"):
88
+ play_card(state, state.player, make_card(2, Effect("deal", amount=2)))
89
+
90
+
91
+ # Verify playing from hand removes the card.
92
+ def test_play_card_from_hand() -> None:
93
+ state = make_duel()
94
+ state.player.energy = 1
95
+ state.player.hand.append(make_card(1, Effect("deal", amount=2)))
96
+ card = play_card_from_hand(state, state.player, 0)
97
+ assert card.name == "Test"
98
+ assert state.player.hand == []
99
+ assert state.enemy.hp == 18
100
+
101
+
102
+ # Verify all non-damage card effects update state.
103
+ def test_card_effects_update_state() -> None:
104
+ state = make_duel()
105
+ state.player.deck = [make_card(1, Effect("deal", amount=2))]
106
+ state.player.energy = 5
107
+ play_card(
108
+ state,
109
+ state.player,
110
+ make_card(
111
+ 1,
112
+ Effect("ward", amount=2),
113
+ Effect("weak", amount=2, duration=1),
114
+ Effect("draw", amount=1),
115
+ Effect("energy", amount=2),
116
+ Effect("initiative"),
117
+ Effect("vulnerable", amount=1, duration=1),
118
+ ),
119
+ )
120
+ assert state.player.ward == 2
121
+ assert state.player.energy == 6
122
+ assert len(state.player.hand) == 1
123
+ assert state.enemy.weak == 2
124
+ assert state.enemy.vulnerable == 1
125
+ assert state.forced_second == "enemy"
126
+
127
+
128
+ # Verify multi-hit chip cannot break ward.
129
+ def test_multi_hit_chip_cannot_break_ward() -> None:
130
+ state = make_duel()
131
+ state.enemy.ward = 3
132
+ state.player.energy = 1
133
+ play_card(state, state.player, make_card(1, Effect("multi_hit", amount=2, hits=2)))
134
+ assert state.enemy.ward == 3
135
+ assert state.enemy.hp == 20
136
+
137
+
138
+ # Verify conditional and scaling damage use current state.
139
+ def test_conditional_and_scaling_damage() -> None:
140
+ state = make_duel()
141
+ state.enemy.hp = 10
142
+ state.player.energy = 2
143
+ play_card(state, state.player, make_card(1, Effect("conditional", amount=6), Effect("scaling", amount=1)))
144
+ assert state.enemy.hp == 5
145
+
146
+
147
+ # Verify shield charge scaling releases absorbed damage as burst.
148
+ def test_shield_charge_scaling_spends_charge() -> None:
149
+ state = make_duel()
150
+ state.player.shield_charge = 4
151
+ state.player.energy = 1
152
+ play_card(state, state.player, make_card(1, Effect("scaling", amount=2, condition="shield_charge")))
153
+ assert state.enemy.hp == 14
154
+ assert state.player.shield_charge == 0
155
+
156
+
157
+ # Verify burn and bomb use the shared pending effect queue.
158
+ def test_deferred_effects_advance_from_one_queue() -> None:
159
+ state = make_duel()
160
+ state.player.energy = 2
161
+ play_card(state, state.player, make_card(1, Effect("burn", amount=2, duration=2), Effect("bomb", amount=5, delay=1)))
162
+ assert len(state.pending) == 2
163
+ advance_pending(state)
164
+ assert state.enemy.hp == 20
165
+ advance_pending(state)
166
+ assert state.enemy.hp == 13
167
+ assert len(state.pending) == 1
168
+ advance_pending(state)
169
+ assert state.enemy.hp == 11
170
+ assert state.pending == []
171
+
172
+
173
+ # Verify start_round refills energy, draws, ticks statuses, and returns order.
174
+ def test_start_round_updates_both_players() -> None:
175
+ state = DuelState(
176
+ create_player("player", [make_card(1, Effect("deal", amount=2))]),
177
+ create_player("enemy", [make_card(1, Effect("deal", amount=2))]),
178
+ )
179
+ state.player.vulnerable = 2
180
+ state.player.vulnerable_turns = 1
181
+ order = start_round(state, Random(1))
182
+ assert order in (("player", "enemy"), ("enemy", "player"))
183
+ assert state.round_number == 1
184
+ assert state.player.energy == 1
185
+ assert state.player.vulnerable == 0
186
+ assert len(state.player.hand) == 1
187
+ assert len(state.enemy.hand) == 1
188
+
189
+
190
+ # Verify multi-turn statuses decrement without clearing early.
191
+ def test_begin_turn_keeps_multi_turn_statuses() -> None:
192
+ player = create_player("player", [])
193
+ player.vulnerable = 2
194
+ player.vulnerable_turns = 2
195
+ player.weak = 1
196
+ player.weak_turns = 2
197
+ begin_turn(player, 3)
198
+ assert player.vulnerable == 2
199
+ assert player.vulnerable_turns == 1
200
+ assert player.weak == 1
201
+ assert player.weak_turns == 1
202
+
203
+
204
+ # Verify one-turn weak clears at turn start.
205
+ def test_begin_turn_clears_one_turn_weak() -> None:
206
+ player = create_player("player", [])
207
+ player.weak = 3
208
+ player.weak_turns = 1
209
+ begin_turn(player, 2)
210
+ assert player.weak == 0
211
+ assert player.weak_turns == 0
212
+
213
+
214
+ # Verify initiative forces the named side to act second once.
215
+ def test_round_order_respects_forced_second_once() -> None:
216
+ state = make_duel()
217
+ state.forced_second = "enemy"
218
+ assert round_order(state, Random(1)) == ("player", "enemy")
219
+ assert state.forced_second is None
220
+ state.forced_second = "player"
221
+ assert round_order(state, Random(1)) == ("enemy", "player")
222
+
223
+
224
+ # Verify conditional effects do nothing when their condition is false.
225
+ def test_conditional_does_not_fire_above_half_hp() -> None:
226
+ state = make_duel()
227
+ state.player.energy = 1
228
+ play_card(state, state.player, make_card(1, Effect("conditional", amount=6)))
229
+ assert state.enemy.hp == 20
230
+
231
+
232
+ # Verify an already-due bomb resolves directly from the pending queue.
233
+ def test_due_bomb_resolves_from_pending_queue() -> None:
234
+ state = make_duel()
235
+ state.pending.append(PendingEffect("bomb", "player", "enemy", 5))
236
+ advance_pending(state)
237
+ assert state.enemy.hp == 15
238
+ assert state.pending == []
239
+
240
+
241
+ # Verify named player lookup and winner states.
242
+ def test_named_player_and_winner() -> None:
243
+ state = make_duel()
244
+ assert named_player(state, "player") is state.player
245
+ assert named_player(state, "enemy") is state.enemy
246
+ with pytest.raises(KeyError):
247
+ named_player(state, "missing")
248
+ assert winner(state) is None
249
+ state.enemy.hp = 0
250
+ assert winner(state) == "player"
251
+ state.player.hp = 0
252
+ assert winner(state) == "draw"
253
+ state.enemy.hp = 1
254
+ assert winner(state) == "enemy"
tests/test_generator.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+ import subprocess
3
+
4
+ import pytest
5
+
6
+ from budget import Card, CardSpec, EffectPlan, cost_card
7
+ from generator import (
8
+ CodexCardClient,
9
+ LlamaCppCardClient,
10
+ MiniCPMCardClient,
11
+ balance_json_closers,
12
+ card_context,
13
+ codex_pack_prompt,
14
+ deck_summary,
15
+ draft_direction,
16
+ draft_need,
17
+ extract_json_object,
18
+ generate_card,
19
+ generate_pack,
20
+ generate_llamacpp_card,
21
+ generator_payload,
22
+ llamacpp_card_prompt,
23
+ llama_candidate_need,
24
+ llamacpp_retry_prompt,
25
+ normalize_card_response,
26
+ pack_payload,
27
+ parse_llamacpp_card_text,
28
+ parse_card_payload,
29
+ parse_effect_plan,
30
+ parse_pack_payload,
31
+ repair_llamacpp_card,
32
+ primitive_guidance,
33
+ school_identity,
34
+ )
35
+ from primitives import Effect
36
+
37
+
38
+ class FakeClient:
39
+ # Initialize the fake client with a fixed raw response.
40
+ def __init__(self, raw: dict[str, Any]) -> None:
41
+ self.raw = raw
42
+ self.payload: dict[str, Any] | None = None
43
+
44
+ # Capture the payload and return a fixed model response.
45
+ def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
46
+ self.payload = payload
47
+ return self.raw
48
+
49
+
50
+ class FakePackClient:
51
+ # Initialize the fake pack client with a fixed raw response.
52
+ def __init__(self, raw: dict[str, Any]) -> None:
53
+ self.raw = raw
54
+ self.payload: dict[str, Any] | None = None
55
+
56
+ # Capture the payload and return a fixed model pack response.
57
+ def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]:
58
+ self.payload = payload
59
+ return self.raw
60
+
61
+
62
+ class FakeChat:
63
+ # Initialize the fake chat client with fixed text.
64
+ def __init__(self, text: str | list[str]) -> None:
65
+ self.responses = [text] if isinstance(text, str) else text
66
+ self.calls: list[tuple[str, str]] = []
67
+
68
+ # Capture prompts and return fixed text.
69
+ def complete(self, system: str, user: str) -> str:
70
+ self.calls.append((system, user))
71
+ return self.responses[min(len(self.calls) - 1, len(self.responses) - 1)]
72
+
73
+
74
+ # Build one card for generator context tests.
75
+ def context_card() -> Card:
76
+ return cost_card(CardSpec("Spark", 1, "fire", "wuxia", (EffectPlan("deal"),)))
77
+
78
+
79
+ # Build one direct card for summary tests.
80
+ def direct_card(name: str, school: str, effects: tuple[Effect, ...]) -> Card:
81
+ return Card(name, 1, school, "wuxia", effects) # type: ignore[arg-type]
82
+
83
+
84
+ # Verify generator payload includes deck context and tool schema.
85
+ def test_generator_payload_is_deck_aware() -> None:
86
+ payload = generator_payload("fire", "wuxia", [context_card()], [context_card()])
87
+ assert payload["allowed_primitives"] == ("deal", "burn", "bomb", "scaling", "draw", "energy", "block", "conditional")
88
+ assert payload["current_deck"][0]["rules_text"] == "Deal 2 damage."
89
+ assert payload["current_deck_summary"] == {"deal": 1}
90
+ assert payload["draft_anchors"][0]["name"] == "Spark"
91
+ assert payload["draft_anchor_summary"] == {"deal": 1}
92
+ assert "fast pressure" in payload["draft_direction"]
93
+ assert "races with direct pressure" in payload["school_identity"]
94
+ assert "burn for damage over turns" in payload["primitive_guidance"]
95
+ assert "Fire" in payload["draft_need"]
96
+ assert payload["tool_schema"]["name"] == "create_card"
97
+
98
+
99
+ # Verify school identity text captures class mechanics.
100
+ def test_school_identity() -> None:
101
+ assert "shield charge" in school_identity("earth")
102
+ assert "plain deal is secondary" in school_identity("earth")
103
+
104
+
105
+ # Verify primitive guidance maps school ideas to primitive ids.
106
+ def test_primitive_guidance() -> None:
107
+ assert "scaling to spend shield charge" in primitive_guidance("earth")
108
+
109
+
110
+ # Verify draft direction is inferred from anchor primitives.
111
+ def test_draft_direction() -> None:
112
+ anchors = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))]
113
+ assert "multi_hit" in draft_direction("ice", anchors)
114
+ assert "No anchor cards" in draft_direction("fire", [])
115
+
116
+
117
+ # Verify llama.cpp candidate need points at missing scaling.
118
+ def test_llama_candidate_need_prefers_missing_scaling() -> None:
119
+ payload = pack_payload("earth", "wuxia", [direct_card("Block", "earth", (Effect("block", amount=1),))], 2, 3)
120
+ assert "primitive_id scaling" in llama_candidate_need(payload, {})
121
+
122
+
123
+ # Verify llama.cpp candidate need points at missing block.
124
+ def test_llama_candidate_need_prefers_missing_block() -> None:
125
+ deck = [direct_card("Scale", "earth", (Effect("scaling", amount=1, condition="shield_charge"),))]
126
+ payload = pack_payload("earth", "wuxia", deck, 2, 3)
127
+ assert "primitive_id block" in llama_candidate_need(payload, {})
128
+
129
+
130
+ # Verify llama.cpp candidate need points at missing multi-hit.
131
+ def test_llama_candidate_need_prefers_missing_multi_hit() -> None:
132
+ deck = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))]
133
+ payload = pack_payload("ice", "wuxia", deck, 2, 3)
134
+ assert "primitive_id multi_hit" in llama_candidate_need(payload, {})
135
+
136
+
137
+ # Verify llama.cpp candidate need points at missing initiative.
138
+ def test_llama_candidate_need_prefers_missing_initiative() -> None:
139
+ deck = [
140
+ direct_card("Vuln A", "ice", (Effect("vulnerable", amount=1),)),
141
+ direct_card("Vuln B", "ice", (Effect("vulnerable", amount=1),)),
142
+ direct_card("Hit", "ice", (Effect("multi_hit", amount=1),)),
143
+ ]
144
+ payload = pack_payload("ice", "wuxia", deck, 2, 3)
145
+ assert "primitive_id initiative" in llama_candidate_need(payload, {})
146
+
147
+
148
+ # Verify llama.cpp candidate need avoids repeated deal when covered.
149
+ def test_llama_candidate_need_avoids_repeated_deal() -> None:
150
+ deck = [
151
+ direct_card("Block", "earth", (Effect("block", amount=1),)),
152
+ direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)),
153
+ direct_card("Deal A", "earth", (Effect("deal", amount=1),)),
154
+ direct_card("Deal B", "earth", (Effect("deal", amount=1),)),
155
+ direct_card("Deal C", "earth", (Effect("deal", amount=1),)),
156
+ direct_card("Deal D", "earth", (Effect("deal", amount=1),)),
157
+ ]
158
+ payload = pack_payload("earth", "wuxia", deck, 2, 3)
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)
165
+ prompt = llamacpp_retry_prompt(payload, "explore primitive_id scaling")
166
+ assert "primitive_id exactly \"block\"" in prompt
167
+ assert "primitive_id exactly \"scaling\"" in prompt
168
+ assert "primitive_id exactly \"multi_hit\"" in prompt
169
+ assert "primitive_id exactly \"initiative\"" in prompt
170
+
171
+
172
+ # Verify deck summary counts all costed card effects.
173
+ def test_deck_summary_counts_effects() -> None:
174
+ deck = [
175
+ direct_card("Wall", "earth", (Effect("block", amount=3), Effect("ward", amount=1))),
176
+ direct_card("Payoff", "earth", (Effect("scaling", amount=2),)),
177
+ ]
178
+ assert deck_summary(deck) == {"block": 1, "scaling": 1, "ward": 1}
179
+
180
+
181
+ # Verify Earth draft need shifts toward payoff after protection.
182
+ def test_earth_draft_need_prefers_payoff_when_protected() -> None:
183
+ deck = [
184
+ direct_card("Ward A", "earth", (Effect("ward", amount=1),)),
185
+ direct_card("Ward B", "earth", (Effect("ward", amount=1),)),
186
+ direct_card("Block", "earth", (Effect("block", amount=1),)),
187
+ direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)),
188
+ ]
189
+ assert "payoff" in draft_need("earth", deck)
190
+
191
+
192
+ # Verify Earth asks for scaling when block exists without payoff.
193
+ def test_earth_draft_need_prefers_scaling_after_block() -> None:
194
+ deck = [direct_card("Block", "earth", (Effect("block", amount=1),))]
195
+ assert "scaling" in draft_need("earth", deck)
196
+
197
+
198
+ # Verify Earth asks for block when payoff exists without enough charge.
199
+ def test_earth_draft_need_prefers_block_after_scaling() -> None:
200
+ deck = [direct_card("Scale", "earth", (Effect("scaling", amount=1, condition="shield_charge"),))]
201
+ assert "add block" in draft_need("earth", deck)
202
+
203
+
204
+ # Verify Earth stops asking for plain damage once deal is covered.
205
+ def test_earth_draft_need_moves_away_from_deal() -> None:
206
+ deck = [
207
+ direct_card("Block", "earth", (Effect("block", amount=1),)),
208
+ direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)),
209
+ direct_card("Deal A", "earth", (Effect("deal", amount=1),)),
210
+ direct_card("Deal B", "earth", (Effect("deal", amount=1),)),
211
+ direct_card("Deal C", "earth", (Effect("deal", amount=1),)),
212
+ direct_card("Deal D", "earth", (Effect("deal", amount=1),)),
213
+ ]
214
+ assert "add block" in draft_need("earth", deck)
215
+
216
+
217
+ # Verify Ice asks for multi-hit after vulnerability setup.
218
+ def test_ice_draft_need_prefers_multi_hit_after_vulnerable() -> None:
219
+ deck = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))]
220
+ assert "multi_hit" in draft_need("ice", deck)
221
+
222
+
223
+ # Verify Ice asks for initiative after setup and payoff exist.
224
+ def test_ice_draft_need_prefers_initiative_after_setup_payoff() -> None:
225
+ deck = [
226
+ direct_card("Vuln A", "ice", (Effect("vulnerable", amount=1),)),
227
+ direct_card("Vuln B", "ice", (Effect("vulnerable", amount=1),)),
228
+ direct_card("Hit", "ice", (Effect("multi_hit", amount=1),)),
229
+ ]
230
+ assert "initiative" in draft_need("ice", deck)
231
+
232
+
233
+ # Verify pack payload includes cost and requested pack size.
234
+ def test_pack_payload_includes_draft_context() -> None:
235
+ payload = pack_payload("fire", "wuxia", [context_card()], cost=3, pack_size=3)
236
+ assert payload["cost"] == 3
237
+ assert payload["pack_size"] == 3
238
+ assert payload["pack_schema"]["required"] == ["cards"]
239
+
240
+
241
+ # Verify card batch payload includes a cost schedule.
242
+ def test_cards_payload_includes_cost_schedule() -> None:
243
+ from generator import cards_payload
244
+
245
+ payload = cards_payload("fire", "wuxia", [context_card()], (2, 3))
246
+ assert payload["costs"] == (2, 3)
247
+ assert payload["pack_size"] == 2
248
+ assert "immediate Deal" in payload["school_bias"]
249
+
250
+
251
+ # Verify card context exposes compact fixed rules text.
252
+ def test_card_context_is_compact() -> None:
253
+ assert card_context(context_card()) == {"name": "Spark", "cost": 1, "rules_text": "Deal 2 damage."}
254
+
255
+
256
+ # Verify model-authored magnitudes are ignored by engine costing.
257
+ def test_generate_card_ignores_model_numbers() -> None:
258
+ client = FakeClient(
259
+ {
260
+ "name": "Inferno Lie",
261
+ "flavor": "The model says 999.",
262
+ "art_prompt": "flame",
263
+ "effects": [{"primitive_id": "deal", "weight": 1, "amount": 999}],
264
+ }
265
+ )
266
+ card = generate_card(client, "fire", "wuxia", [context_card()], cost=2)
267
+ assert client.payload is not None
268
+ assert client.payload["current_deck"][0]["name"] == "Spark"
269
+ assert card.effects[0].amount == 4
270
+ assert "999" not in card.rules_text()
271
+
272
+
273
+ # Verify pack generation costs every returned candidate.
274
+ def test_generate_pack_returns_costed_candidates() -> None:
275
+ client = FakePackClient(
276
+ {
277
+ "cards": [
278
+ {"name": "A", "effects": [{"primitive_id": "deal"}]},
279
+ {"name": "B", "effects": [{"primitive_id": "burn"}]},
280
+ {"name": "C", "effects": [{"primitive_id": "bomb"}]},
281
+ ]
282
+ }
283
+ )
284
+ pack = generate_pack(client, "fire", "wuxia", [context_card()], cost=2)
285
+ assert client.payload is not None
286
+ assert client.payload["current_deck"][0]["name"] == "Spark"
287
+ assert [card.name for card in pack] == ["A", "B", "C"]
288
+ assert pack[0].rules_text() == "Deal 4 damage."
289
+
290
+
291
+ # Verify multi-card generation costs cards against their own costs.
292
+ def test_generate_cards_returns_costed_batch() -> None:
293
+ from generator import generate_cards
294
+
295
+ client = FakePackClient(
296
+ {
297
+ "cards": [
298
+ {"name": "A", "effects": [{"primitive_id": "deal"}]},
299
+ {"name": "B", "effects": [{"primitive_id": "burn"}]},
300
+ ]
301
+ }
302
+ )
303
+ cards = generate_cards(client, "fire", "wuxia", [context_card()], (2, 3))
304
+ assert [card.rules_text() for card in cards] == ["Deal 4 damage.", "Burn 3 damage for 2 turns."]
305
+
306
+
307
+ # Verify multi-card generation rejects wrong-size output.
308
+ def test_generate_cards_rejects_wrong_size() -> None:
309
+ from generator import generate_cards
310
+
311
+ client = FakePackClient({"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]})
312
+ with pytest.raises(ValueError, match="wrong size"):
313
+ generate_cards(client, "fire", "wuxia", [], (2, 3))
314
+
315
+
316
+ # Verify pack generation rejects wrong-size model output.
317
+ def test_generate_pack_rejects_wrong_size() -> None:
318
+ client = FakePackClient({"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]})
319
+ with pytest.raises(ValueError, match="wrong size"):
320
+ generate_pack(client, "fire", "wuxia", [], cost=2)
321
+
322
+
323
+ # Verify pack generation trims extra model output.
324
+ def test_generate_pack_trims_extra_cards() -> None:
325
+ client = FakePackClient(
326
+ {
327
+ "cards": [
328
+ {"name": "A", "effects": [{"primitive_id": "deal"}]},
329
+ {"name": "B", "effects": [{"primitive_id": "burn"}]},
330
+ {"name": "C", "effects": [{"primitive_id": "bomb"}]},
331
+ {"name": "D", "effects": [{"primitive_id": "draw"}]},
332
+ ]
333
+ }
334
+ )
335
+ pack = generate_pack(client, "fire", "wuxia", [], cost=2)
336
+ assert [card.name for card in pack] == ["A", "B", "C"]
337
+
338
+
339
+ # Verify invalid school output is rejected after parsing.
340
+ def test_parse_card_payload_still_goes_through_budget_validation() -> None:
341
+ raw = {"name": "Off School", "effects": [{"primitive_id": "bomb"}]}
342
+ spec = parse_card_payload(raw, "ice", "wuxia", 1)
343
+ with pytest.raises(ValueError, match="not allowed"):
344
+ cost_card(spec)
345
+
346
+
347
+ # Verify pack parsing uses engine costing.
348
+ def test_parse_pack_payload_costs_cards() -> None:
349
+ pack = parse_pack_payload({"cards": [{"name": "A", "effects": [{"primitive_id": "deal", "amount": 999}]}]}, "fire", "wuxia", 1)
350
+ assert pack[0].rules_text() == "Deal 2 damage."
351
+
352
+
353
+ # Verify invalid model weights normalize to the smallest legal weight.
354
+ def test_parse_effect_plan_clamps_weight() -> None:
355
+ assert parse_effect_plan({"primitive_id": "deal", "weight": 0}).weight == 1
356
+
357
+
358
+ # Verify Codex prompt includes constraints and deck context.
359
+ def test_codex_pack_prompt_mentions_engine_owned_numbers() -> None:
360
+ prompt = codex_pack_prompt(pack_payload("fire", "wuxia", [context_card()], 2, 3))
361
+ assert "strict JSON only" in prompt
362
+ assert "engine owns all final numbers" in prompt
363
+ assert "meaningfully different" in prompt
364
+ assert "Class identity" in prompt
365
+ assert "Primitive guidance" in prompt
366
+ assert "Draft anchor cards" in prompt
367
+ assert "Draft direction" in prompt
368
+ assert "Deck primitive counts" in prompt
369
+ assert "Draft need" in prompt
370
+ assert "Return exactly 3 cards" in prompt
371
+ assert "Spark" in prompt
372
+
373
+
374
+ # Verify Codex prompt supports batch cost schedules.
375
+ def test_codex_pack_prompt_mentions_cost_schedule() -> None:
376
+ from generator import cards_payload
377
+
378
+ prompt = codex_pack_prompt(cards_payload("fire", "wuxia", [], (2, 3)))
379
+ assert "Card costs by index: [2, 3]" in prompt
380
+
381
+
382
+ # Verify JSON extraction tolerates surrounding model text.
383
+ def test_extract_json_object() -> None:
384
+ assert extract_json_object("text {\"cards\": []} more") == "{\"cards\": []}"
385
+ with pytest.raises(ValueError, match="JSON object"):
386
+ extract_json_object("no json")
387
+
388
+
389
+ # Verify JSON extraction repairs missing closing delimiters.
390
+ def test_extract_json_object_repairs_missing_closer() -> None:
391
+ assert extract_json_object('{"cards": [{"name": "A"}]') == '{"cards": [{"name": "A"}]}'
392
+
393
+
394
+ # Verify JSON closer balancing ignores braces inside strings.
395
+ def test_balance_json_closers_ignores_strings() -> None:
396
+ assert balance_json_closers('{"text": "}"}') == '{"text": "}"}'
397
+
398
+
399
+ # Verify Codex client reads the final-message file.
400
+ def test_codex_client_invokes_subprocess(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
401
+ calls: list[dict[str, Any]] = []
402
+
403
+ # Fake subprocess.run by writing the requested output file.
404
+ def fake_run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
405
+ output_path = command[command.index("--output-last-message") + 1]
406
+ calls.append({"command": command, "kwargs": kwargs})
407
+ with open(output_path, "w") as output:
408
+ output.write('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
409
+ return subprocess.CompletedProcess(command, 0, "", "")
410
+
411
+ monkeypatch.setattr("generator.subprocess.run", fake_run)
412
+ client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path))
413
+ raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 1))
414
+ assert raw["cards"][0]["name"] == "A"
415
+ assert "--output-last-message" in calls[0]["command"]
416
+ assert calls[0]["kwargs"]["cwd"] == str(tmp_path)
417
+
418
+
419
+ # Verify single-card Codex calls reuse the pack path.
420
+ def test_codex_client_create_card_uses_pack(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
421
+ # Fake subprocess.run by writing one valid card.
422
+ def fake_run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
423
+ output_path = command[command.index("--output-last-message") + 1]
424
+ with open(output_path, "w") as output:
425
+ output.write('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
426
+ return subprocess.CompletedProcess(command, 0, "", "")
427
+
428
+ monkeypatch.setattr("generator.subprocess.run", fake_run)
429
+ client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path))
430
+ raw = client.create_card(generator_payload("fire", "wuxia", []))
431
+ assert raw["name"] == "A"
432
+
433
+
434
+ # Verify MiniCPM card client uses local chat completions.
435
+ def test_minicpm_card_client() -> None:
436
+ chat = FakeChat('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
437
+ client = MiniCPMCardClient(chat) # type: ignore[arg-type]
438
+ raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 1))
439
+ assert raw["cards"][0]["name"] == "A"
440
+ assert "strict JSON" in chat.calls[0][0]
441
+ assert "Allowed primitives" in chat.calls[0][1]
442
+
443
+
444
+ # Verify MiniCPM single-card calls reuse the pack path.
445
+ def test_minicpm_card_client_create_card() -> None:
446
+ chat = FakeChat('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
447
+ client = MiniCPMCardClient(chat) # type: ignore[arg-type]
448
+ assert client.create_card(generator_payload("fire", "wuxia", []))["name"] == "A"
449
+
450
+
451
+ # Verify llama.cpp card prompt asks for one compact card.
452
+ def test_llamacpp_card_prompt() -> None:
453
+ payload = pack_payload("fire", "wuxia", [context_card()], 2, 3)
454
+ prompt = llamacpp_card_prompt(payload, [{"name": "A", "effects": [{"primitive_id": "burn"}]}])
455
+ assert "Create exactly one Tabras card" in prompt
456
+ assert "Fit the school identity" in prompt
457
+ assert "Primitive guidance" in prompt
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
+
474
+ # Verify llama.cpp card generation retries missed candidate needs.
475
+ def test_generate_llamacpp_card_retries_missed_need() -> None:
476
+ payload = pack_payload("earth", "wuxia", [direct_card("Block", "earth", (Effect("block", amount=1),))], 2, 3)
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)
503
+ card = repair_llamacpp_card({"effects": [{"primitive_id": "bomb"}]}, payload, [])
504
+ assert card["effects"] == [{"primitive_id": "deal", "weight": 1}]
505
+
506
+
507
+ # Verify one-card normalization accepts pack-shaped responses.
508
+ def test_normalize_card_response() -> None:
509
+ assert normalize_card_response({"cards": [{"name": "A"}]}) == {"name": "A"}
510
+
511
+
512
+ # Verify one-card normalization unwraps named wrapper objects.
513
+ def test_normalize_card_response_unwraps_wrapper() -> None:
514
+ raw = {"earth_tabras": {"name": "Earth Tabras", "effects": [{"primitive_id": "scaling"}]}}
515
+ assert normalize_card_response(raw) == {"name": "Earth Tabras", "effects": [{"primitive_id": "scaling"}]}
516
+
517
+
518
+ # Verify one-card normalization unwraps MiniCPM shape wrappers.
519
+ def test_normalize_card_response_unwraps_shape() -> None:
520
+ raw = {"shape": {"name": "Shield of Scale", "effects": [{"primitive_id": "scaling"}]}, "school": "earth"}
521
+ assert normalize_card_response(raw) == {"name": "Shield of Scale", "effects": [{"primitive_id": "scaling"}]}
522
+
523
+
524
+ # Verify malformed llama.cpp card JSON becomes a legal fallback card.
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
+
532
+ # Verify Codex client reports subprocess failures.
533
+ def test_codex_client_reports_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
534
+ # Fake subprocess.run with a failed Codex result.
535
+ def fake_run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
536
+ return subprocess.CompletedProcess(command, 1, "", "blocked")
537
+
538
+ monkeypatch.setattr("generator.subprocess.run", fake_run)
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))
tests/test_local_llm.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any
3
+
4
+ from local_llm import (
5
+ LocalChatClient,
6
+ LocalCompletionClient,
7
+ LocalJsonChatClient,
8
+ MLXChatClient,
9
+ MiniCPMTransformersChatClient,
10
+ NemotronTransformersChatClient,
11
+ chat_messages,
12
+ chat_payload,
13
+ completion_payload,
14
+ decode_generated_text,
15
+ json_chat_payload,
16
+ minicpm_text_prompt,
17
+ nemotron_prompt,
18
+ parse_chat_response,
19
+ parse_completion_response,
20
+ tensor_to_model_device,
21
+ token_length,
22
+ transformers_model_kwargs,
23
+ )
24
+
25
+
26
+ class FakeResponse:
27
+ # Initialize a fake HTTP response body.
28
+ def __init__(self, raw: dict[str, Any]) -> None:
29
+ self.raw = raw
30
+
31
+ # Enter the fake response context.
32
+ def __enter__(self) -> "FakeResponse":
33
+ return self
34
+
35
+ # Exit the fake response context.
36
+ def __exit__(self, *_: object) -> None:
37
+ return None
38
+
39
+ # Return encoded response bytes.
40
+ def read(self) -> bytes:
41
+ return json.dumps(self.raw).encode("utf-8")
42
+
43
+
44
+ class FakeInputs(list):
45
+ # Initialize tensor-like fake inputs.
46
+ def __init__(self) -> None:
47
+ super().__init__([[1, 2]])
48
+ self.shape = (1, 2)
49
+ self.moved_to: str | None = None
50
+
51
+ # Capture device moves.
52
+ def to(self, device: str) -> "FakeInputs":
53
+ self.moved_to = device
54
+ return self
55
+
56
+
57
+ class FakeTokenizer:
58
+ # Initialize a fake tokenizer.
59
+ def __init__(self) -> None:
60
+ self.messages: list[dict[str, str]] = []
61
+
62
+ # Capture chat template messages.
63
+ def apply_chat_template(self, messages: list[dict[str, str]], **_: Any) -> FakeInputs:
64
+ self.messages = messages
65
+ return FakeInputs()
66
+
67
+ # Decode generated token ids.
68
+ def decode(self, tokens: list[int], **_: Any) -> str:
69
+ return f"decoded:{tokens}"
70
+
71
+
72
+ class FakeModel:
73
+ device = "cuda"
74
+
75
+ # Return a generated token sequence with prompt prefix included.
76
+ def generate(self, inputs: FakeInputs, **kwargs: Any) -> list[list[int]]:
77
+ self.inputs = inputs
78
+ self.kwargs = kwargs
79
+ return [[1, 2, 3, 4]]
80
+
81
+
82
+ class FakeMiniCPMModel:
83
+ # Capture MiniCPM chat kwargs.
84
+ def chat(self, **kwargs: Any) -> str:
85
+ self.kwargs = kwargs
86
+ return "mini"
87
+
88
+
89
+ class FakeMLXGenerate:
90
+ # Capture MLX generation arguments.
91
+ def __call__(self, *args: Any, **kwargs: Any) -> str:
92
+ self.args = args
93
+ self.kwargs = kwargs
94
+ return "mlx"
95
+
96
+
97
+ class FakeTorch:
98
+ bfloat16 = "bf16"
99
+
100
+ class cuda:
101
+ # Report no CUDA for dtype fallback tests.
102
+ @staticmethod
103
+ def is_available() -> bool:
104
+ return False
105
+
106
+
107
+ class FakeCudaTorch:
108
+ bfloat16 = "bf16"
109
+
110
+ class cuda:
111
+ # Report CUDA for dtype selection tests.
112
+ @staticmethod
113
+ def is_available() -> bool:
114
+ return True
115
+
116
+
117
+ # Verify chat payload shape matches OpenAI-compatible endpoints.
118
+ def test_chat_payload() -> None:
119
+ payload = chat_payload("local", "sys", "user", 0.5)
120
+ assert payload["model"] == "local"
121
+ assert payload["messages"][0]["role"] == "system"
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.
128
+ def test_completion_payload() -> None:
129
+ payload = completion_payload("local", "prompt", 0.4, 99)
130
+ assert payload == {"model": "local", "prompt": "prompt", "temperature": 0.4, "max_tokens": 99}
131
+
132
+
133
+ # Verify JSON chat payload requests JSON object output.
134
+ def test_json_chat_payload() -> None:
135
+ payload = json_chat_payload("local", "sys", "user", 0.0, 55)
136
+ assert payload["response_format"] == {"type": "json_object"}
137
+ assert payload["max_tokens"] == 55
138
+
139
+
140
+ # Verify chat message payloads use standard roles.
141
+ def test_chat_messages() -> None:
142
+ assert chat_messages("sys", "user") == [{"role": "system", "content": "sys"}, {"role": "user", "content": "user"}]
143
+
144
+
145
+ # Verify Nemotron prompt markers match the documented template.
146
+ def test_nemotron_prompt() -> None:
147
+ assert nemotron_prompt("sys", "user") == "<extra_id_0>System\nsys\n\n<extra_id_1>User\nuser\n<extra_id_1>Assistant\n"
148
+
149
+
150
+ # Verify MiniCPM receives system and user text in one chat prompt.
151
+ def test_minicpm_text_prompt() -> None:
152
+ assert minicpm_text_prompt("sys", "user") == "System:\nsys\n\nUser:\nuser"
153
+
154
+
155
+ # Verify chat response parsing returns assistant content.
156
+ def test_parse_chat_response() -> None:
157
+ assert parse_chat_response({"choices": [{"message": {"content": "ok"}}]}) == "ok"
158
+
159
+
160
+ # Verify completion response parsing supports common local shapes.
161
+ def test_parse_completion_response() -> None:
162
+ assert parse_completion_response({"content": "llama"}) == "llama"
163
+ assert parse_completion_response({"choices": [{"text": "done"}]}) == "done"
164
+ assert parse_completion_response({"choices": [{"message": {"content": "chat"}}]}) == "chat"
165
+
166
+
167
+ # Verify local chat client posts JSON and returns content.
168
+ def test_local_chat_client(monkeypatch: Any) -> None:
169
+ calls: list[Any] = []
170
+
171
+ # Capture the request and return a fake response.
172
+ def fake_urlopen(req: Any, timeout: int) -> FakeResponse:
173
+ calls.append((req, timeout))
174
+ return FakeResponse({"choices": [{"message": {"content": "done"}}]})
175
+
176
+ monkeypatch.setattr("local_llm.request.urlopen", fake_urlopen)
177
+ client = LocalChatClient("http://localhost/v1/chat/completions", "local", timeout_seconds=3)
178
+ assert client.complete("sys", "user") == "done"
179
+ assert calls[0][1] == 3
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.
186
+ def test_local_json_chat_client(monkeypatch: Any) -> None:
187
+ calls: list[Any] = []
188
+
189
+ # Capture the request and return a fake response.
190
+ def fake_urlopen(req: Any, timeout: int) -> FakeResponse:
191
+ calls.append((req, timeout))
192
+ return FakeResponse({"choices": [{"message": {"content": "{\"ok\": true}"}}]})
193
+
194
+ monkeypatch.setattr("local_llm.request.urlopen", fake_urlopen)
195
+ client = LocalJsonChatClient("http://localhost/v1/chat/completions", "local", timeout_seconds=5, max_tokens=22)
196
+ assert client.complete("sys", "user") == "{\"ok\": true}"
197
+ payload = json.loads(calls[0][0].data.decode("utf-8"))
198
+ assert payload["response_format"] == {"type": "json_object"}
199
+ assert payload["max_tokens"] == 22
200
+ assert calls[0][1] == 5
201
+
202
+
203
+ # Verify local completion client posts a rendered prompt and returns content.
204
+ def test_local_completion_client(monkeypatch: Any) -> None:
205
+ calls: list[Any] = []
206
+
207
+ # Capture the request and return a fake response.
208
+ def fake_urlopen(req: Any, timeout: int) -> FakeResponse:
209
+ calls.append((req, timeout))
210
+ return FakeResponse({"choices": [{"text": "done"}]})
211
+
212
+ monkeypatch.setattr("local_llm.request.urlopen", fake_urlopen)
213
+ client = LocalCompletionClient("http://localhost/v1/completions", "local", nemotron_prompt, timeout_seconds=4, max_tokens=12)
214
+ assert client.complete("sys", "user") == "done"
215
+ payload = json.loads(calls[0][0].data.decode("utf-8"))
216
+ assert payload["prompt"] == nemotron_prompt("sys", "user")
217
+ assert payload["max_tokens"] == 12
218
+ assert calls[0][1] == 4
219
+
220
+
221
+ # Verify tensor device moves are optional.
222
+ def test_tensor_to_model_device() -> None:
223
+ inputs = FakeInputs()
224
+ assert tensor_to_model_device(inputs, FakeModel()).moved_to == "cuda"
225
+ assert tensor_to_model_device([1, 2], object()) == [1, 2]
226
+
227
+
228
+ # Verify token length handles tensor-like and list inputs.
229
+ def test_token_length() -> None:
230
+ assert token_length(FakeInputs()) == 2
231
+ assert token_length([[1, 2, 3]]) == 3
232
+ assert token_length([1, 2]) == 2
233
+
234
+
235
+ # Verify generated decoding removes the prompt prefix.
236
+ def test_decode_generated_text() -> None:
237
+ assert decode_generated_text(FakeTokenizer(), FakeInputs(), [[1, 2, 3]]) == "decoded:[3]"
238
+
239
+
240
+ # Verify Nemotron Transformers client uses tokenizer chat templates.
241
+ def test_nemotron_transformers_chat_client() -> None:
242
+ tokenizer = FakeTokenizer()
243
+ model = FakeModel()
244
+ client = NemotronTransformersChatClient(model, tokenizer, max_new_tokens=7, temperature=0.0)
245
+ assert client.complete("sys", "user") == "decoded:[3, 4]"
246
+ assert tokenizer.messages[0]["role"] == "system"
247
+ assert model.inputs.moved_to == "cuda"
248
+ assert model.kwargs["max_new_tokens"] == 7
249
+ assert model.kwargs["do_sample"] is False
250
+
251
+
252
+ # Verify MLX chat client renders prompts and calls generate.
253
+ def test_mlx_chat_client() -> None:
254
+ generate = FakeMLXGenerate()
255
+ client = MLXChatClient("model", "tok", nemotron_prompt, generate, max_tokens=11)
256
+ assert client.complete("sys", "user") == "mlx"
257
+ assert generate.args == ("model", "tok", nemotron_prompt("sys", "user"))
258
+ assert generate.kwargs == {"verbose": False, "max_tokens": 11}
259
+
260
+
261
+ # Verify MiniCPM Transformers client uses model.chat with no image.
262
+ def test_minicpm_transformers_chat_client() -> None:
263
+ model = FakeMiniCPMModel()
264
+ client = MiniCPMTransformersChatClient(model, tokenizer="tok", max_new_tokens=77)
265
+ assert client.complete("sys", "user") == "mini"
266
+ assert model.kwargs["image"] is None
267
+ assert model.kwargs["tokenizer"] == "tok"
268
+ assert model.kwargs["system_prompt"] == "sys"
269
+ assert model.kwargs["sampling"] is False
270
+ assert model.kwargs["max_new_tokens"] == 77
271
+ assert model.kwargs["msgs"][0]["content"] == "user"
272
+
273
+
274
+ # Verify Transformers model kwargs use auto dtype.
275
+ def test_transformers_model_kwargs(monkeypatch: Any) -> None:
276
+ monkeypatch.setenv("TABRAS_MODEL_OFFLOAD", "/tmp/custom-offload")
277
+ assert transformers_model_kwargs()["torch_dtype"] == "auto"
278
+ if "device_map" in transformers_model_kwargs():
279
+ assert transformers_model_kwargs()["offload_folder"] == "/tmp/custom-offload"
280
+
281
+
282
+ # Verify MiniCPM dtype follows CUDA availability.
283
+ def test_local_torch_dtype() -> None:
284
+ from local_llm import local_torch_dtype
285
+
286
+ assert local_torch_dtype(FakeTorch) == "auto"
287
+ assert local_torch_dtype(FakeCudaTorch) == "bf16"
tests/test_play.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from random import Random
2
+
3
+ from budget import Card
4
+ from game import DuelState, create_player
5
+ from play import (
6
+ best_pack_index,
7
+ best_draft_index,
8
+ card_line,
9
+ card_primitives,
10
+ choose_assistant_card,
11
+ choose_enemy_card,
12
+ choice_state_line,
13
+ combat_card_score,
14
+ demo_deck,
15
+ draft_enemy_deck,
16
+ draft_card_score,
17
+ draft_player_deck,
18
+ ice_need_bonus,
19
+ earth_need_bonus,
20
+ effect_score,
21
+ immediate_hp_damage,
22
+ pack_line,
23
+ playable_indexes,
24
+ player_line,
25
+ play_turn,
26
+ prompt_pack_choice,
27
+ prompt_human_choice,
28
+ run_text_duel,
29
+ shuffled_deck,
30
+ )
31
+ from primitives import Effect
32
+
33
+
34
+ # Build a play-loop test card.
35
+ def make_card(name: str, cost: int, effect: Effect) -> Card:
36
+ return Card(name, cost, "fire", "wuxia", (effect,))
37
+
38
+
39
+ # Build a play-loop test duel.
40
+ def make_duel() -> DuelState:
41
+ return DuelState(create_player("You", []), create_player("Enemy", []))
42
+
43
+
44
+ class PlayPackClient:
45
+ # Initialize call tracking for play draft generation.
46
+ def __init__(self) -> None:
47
+ self.calls = 0
48
+
49
+ # Return one legal fire pack.
50
+ def create_pack(self, payload: dict[str, object]) -> dict[str, object]:
51
+ self.calls += 1
52
+ if "costs" in payload:
53
+ costs = payload["costs"] # type: ignore[assignment]
54
+ return {
55
+ "cards": [
56
+ {"name": f"Spark {self.calls}-{index}", "flavor": "A", "effects": [{"primitive_id": "deal"}]}
57
+ for index, _ in enumerate(costs, 1)
58
+ ]
59
+ }
60
+ return {
61
+ "cards": [
62
+ {"name": f"Spark {self.calls}A", "flavor": "A", "effects": [{"primitive_id": "deal"}]},
63
+ {"name": f"Spark {self.calls}B", "flavor": "B", "effects": [{"primitive_id": "burn"}]},
64
+ {"name": f"Spark {self.calls}C", "flavor": "C", "effects": [{"primitive_id": "scaling"}]},
65
+ ]
66
+ }
67
+
68
+
69
+ # Verify card and player display lines stay compact.
70
+ def test_display_lines() -> None:
71
+ card = make_card("Spark", 1, Effect("deal", amount=2))
72
+ player = create_player("You", [])
73
+ player.energy = 1
74
+ player.block = 2
75
+ player.ward = 3
76
+ assert card_line(0, card) == "0: Spark [1] - Deal 2 damage."
77
+ assert player_line(player) == "You: 20 HP, 1 energy, 2 block, 3 ward, 0 charge (hand 0, deck 0, discard 0)"
78
+
79
+
80
+ # Verify choice state shows the active energy and target defenses.
81
+ def test_choice_state_line() -> None:
82
+ state = make_duel()
83
+ state.player.energy = 3
84
+ state.enemy.hp = 12
85
+ state.enemy.block = 2
86
+ state.enemy.ward = 1
87
+ assert choice_state_line(state, state.player) == "You energy 3. Enemy: 12 HP, 2 block, 1 ward."
88
+
89
+
90
+ # Verify draft pack lines include flavor.
91
+ def test_pack_line() -> None:
92
+ card = Card("Spark", 1, "fire", "wuxia", (Effect("deal", amount=2),), flavor="Hot.")
93
+ assert pack_line(0, card) == "0: Spark [1] - Deal 2 damage. | Hot."
94
+
95
+
96
+ # Verify playable indexes filter by current energy.
97
+ def test_playable_indexes() -> None:
98
+ player = create_player("You", [])
99
+ player.energy = 2
100
+ player.hand = [
101
+ make_card("One", 1, Effect("deal", amount=2)),
102
+ make_card("Three", 3, Effect("deal", amount=6)),
103
+ ]
104
+ assert playable_indexes(player) == (0,)
105
+
106
+
107
+ # Verify the enemy chooses lethal before other playable cards.
108
+ def test_choose_enemy_card_prefers_lethal() -> None:
109
+ state = make_duel()
110
+ state.enemy.energy = 3
111
+ state.player.hp = 2
112
+ state.enemy.hand = [
113
+ make_card("Cheap", 1, Effect("deal", amount=2)),
114
+ make_card("Big", 3, Effect("block", amount=10)),
115
+ ]
116
+ assert choose_enemy_card(state, state.enemy) == 0
117
+ assert choose_assistant_card(state, state.enemy) == 0
118
+
119
+
120
+ # Verify the enemy passes without playable cards.
121
+ def test_choose_enemy_card_passes_without_playable_cards() -> None:
122
+ state = make_duel()
123
+ state.enemy.energy = 0
124
+ state.enemy.hand = [make_card("Big", 3, Effect("deal", amount=4))]
125
+ assert choose_assistant_card(state, state.enemy) is None
126
+
127
+
128
+ # Verify the enemy values defense when behind.
129
+ def test_choose_enemy_card_values_defense_when_behind() -> None:
130
+ state = make_duel()
131
+ state.enemy.hp = 5
132
+ state.player.hp = 12
133
+ state.enemy.energy = 2
134
+ state.enemy.hand = [
135
+ make_card("Small hit", 1, Effect("deal", amount=2)),
136
+ make_card("Guard", 2, Effect("block", amount=6)),
137
+ ]
138
+ assert choose_enemy_card(state, state.enemy) == 1
139
+
140
+
141
+ # Verify human input accepts pass and retries invalid choices.
142
+ def test_prompt_human_choice() -> None:
143
+ outputs: list[str] = []
144
+ answers = iter(["9", "0"])
145
+ state = make_duel()
146
+ state.player.energy = 1
147
+ state.player.hand = [make_card("Spark", 1, Effect("deal", amount=2))]
148
+ chooser = prompt_human_choice(lambda _: next(answers), outputs.append)
149
+ assert chooser(state, state.player) == 0
150
+ assert "That card is not playable." in outputs
151
+ assert "You energy 1. Enemy: 20 HP, 0 block, 0 ward." in outputs
152
+ pass_chooser = prompt_human_choice(lambda _: "pass", outputs.append)
153
+ assert pass_chooser(state, state.player) is None
154
+
155
+
156
+ # Verify draft prompt accepts one shown card and retries invalid input.
157
+ def test_prompt_pack_choice() -> None:
158
+ outputs: list[str] = []
159
+ answers = iter(["5", "1"])
160
+ pack = (
161
+ make_card("A", 1, Effect("deal", amount=2)),
162
+ make_card("B", 1, Effect("deal", amount=2)),
163
+ make_card("C", 1, Effect("deal", amount=2)),
164
+ )
165
+ chooser = prompt_pack_choice(lambda _: next(answers), outputs.append)
166
+ assert chooser((), pack) == 1
167
+ assert "Choose one of the shown draft cards." in outputs
168
+
169
+
170
+ # Verify player drafting produces a 15-card deck from nine packs.
171
+ def test_draft_player_deck() -> None:
172
+ outputs: list[str] = []
173
+ client = PlayPackClient()
174
+ deck = draft_player_deck(client, "fire", "dark fantasy", lambda _: "2", outputs.append)
175
+ assert len(deck) == 15
176
+ assert client.calls == 9
177
+ assert deck[-1].name == "Spark 9C"
178
+ assert "Draft pick 9/9. Current deck: 14 cards." in outputs
179
+
180
+
181
+ # Verify enemy drafting chooses the highest-scoring generated card.
182
+ def test_draft_enemy_deck() -> None:
183
+ outputs: list[str] = []
184
+ client = PlayPackClient()
185
+ deck = draft_enemy_deck(client, "fire", "dark fantasy", outputs.append, Random(0))
186
+ assert len(deck) == 15
187
+ assert client.calls == 9
188
+ assert any("Enemy generated pick 9/9" in line for line in outputs)
189
+
190
+
191
+ # Verify pack selection uses static card scoring.
192
+ def test_best_pack_index() -> None:
193
+ pack = (
194
+ make_card("Block", 1, Effect("block", amount=3)),
195
+ make_card("Hit", 1, Effect("deal", amount=4)),
196
+ make_card("Draw", 1, Effect("draw", amount=1)),
197
+ )
198
+ assert best_pack_index(pack) == 1
199
+
200
+
201
+ # Verify deck-aware draft selection can choose synergy over raw damage.
202
+ def test_best_draft_index_uses_school_needs() -> None:
203
+ current_deck = (Card("Setup", 1, "ice", "wuxia", (Effect("vulnerable", amount=1),)),)
204
+ pack = (
205
+ Card("Damage", 2, "ice", "wuxia", (Effect("deal", amount=8),)),
206
+ Card("Payoff", 2, "ice", "wuxia", (Effect("multi_hit", amount=1, hits=2),)),
207
+ )
208
+ assert best_draft_index(current_deck, pack) == 1
209
+ assert draft_card_score(current_deck, pack[1]) > draft_card_score(current_deck, pack[0])
210
+
211
+
212
+ # Verify Ice bonus rewards missing payoff.
213
+ def test_ice_need_bonus_rewards_missing_payoff() -> None:
214
+ assert ice_need_bonus(("multi_hit",), {"vulnerable": 1}) > 0
215
+ assert card_primitives(make_card("Hit", 1, Effect("deal", amount=1))) == ("deal",)
216
+
217
+
218
+ # Verify Earth bonus rewards block after scaling payoff.
219
+ def test_earth_need_bonus_rewards_block_engine() -> None:
220
+ assert earth_need_bonus(("block",), {"scaling": 2, "block": 1}) > earth_need_bonus(("deal",), {"scaling": 2, "block": 1})
221
+
222
+
223
+ # Verify tactical score helpers cover primitive categories.
224
+ def test_score_helpers() -> None:
225
+ state = make_duel()
226
+ state.enemy.hp = 5
227
+ state.player.hp = 10
228
+ card = make_card("Guard", 1, Effect("block", amount=3))
229
+ assert combat_card_score(state, state.enemy, card) > combat_card_score(state, state.player, card)
230
+ assert effect_score("multi_hit", 2, 3) == 12
231
+ assert effect_score("burn", 2, 1) == 4
232
+ assert effect_score("initiative", 0, 1) == 2
233
+ assert effect_score("unknown", 0, 1) == 0
234
+
235
+
236
+ # Verify immediate damage estimates cover tactical effect shapes.
237
+ def test_immediate_hp_damage_shapes() -> None:
238
+ state = make_duel()
239
+ state.enemy.block = 1
240
+ assert immediate_hp_damage(state.player, state.enemy, make_card("Hits", 1, Effect("multi_hit", amount=2, hits=2))) == 2
241
+ state.enemy.block = 0
242
+ state.enemy.hp = 10
243
+ assert immediate_hp_damage(state.player, state.enemy, make_card("Finish", 1, Effect("conditional", amount=3))) == 1
244
+ state.player.cards_played_this_turn = 1
245
+ assert immediate_hp_damage(state.player, state.enemy, make_card("Grow", 1, Effect("scaling", amount=2))) == 4
246
+ state.player.shield_charge = 5
247
+ assert immediate_hp_damage(state.player, state.enemy, make_card("Burst", 1, Effect("scaling", amount=2, condition="shield_charge"))) == 7
248
+ state.enemy.ward = 1
249
+ assert immediate_hp_damage(state.player, state.enemy, make_card("Stopped", 1, Effect("deal", amount=9))) == 0
250
+ state.enemy.ward = 0
251
+ state.player.weak = 9
252
+ assert immediate_hp_damage(state.player, state.enemy, make_card("Weak", 1, Effect("deal", amount=3))) == 0
253
+
254
+
255
+ # Verify play_turn resolves selected cards until pass.
256
+ def test_play_turn_resolves_cards() -> None:
257
+ outputs: list[str] = []
258
+ state = make_duel()
259
+ state.player.energy = 2
260
+ state.player.hand = [
261
+ make_card("Spark", 1, Effect("deal", amount=2)),
262
+ make_card("Guard", 1, Effect("block", amount=3)),
263
+ ]
264
+ choices = iter([0, None])
265
+ play_turn(state, state.player, lambda _, __: next(choices), outputs.append)
266
+ assert state.enemy.hp == 18
267
+ assert state.player.hand[0].name == "Guard"
268
+ assert state.player.discard[0].name == "Spark"
269
+ assert outputs[-1] == "You passes."
270
+
271
+
272
+ # Verify the demo deck has the locked deck size.
273
+ def test_demo_deck_has_fifteen_cards() -> None:
274
+ deck = demo_deck("fire", "dark fantasy")
275
+ assert len(deck) == 15
276
+ assert deck[0].rules_text() == "Deal 2 damage."
277
+
278
+
279
+ # Verify deck shuffling is deterministic from the run RNG.
280
+ def test_shuffled_deck_uses_rng() -> None:
281
+ deck = tuple(make_card(str(index), 1, Effect("deal", amount=1)) for index in range(5))
282
+ shuffled = shuffled_deck(deck, Random(1))
283
+ assert [card.name for card in shuffled] != [card.name for card in deck]
284
+ assert [card.name for card in shuffled] == [card.name for card in shuffled_deck(deck, Random(1))]
285
+
286
+
287
+ # Verify a short scripted duel can produce a winner.
288
+ def test_run_text_duel_returns_winner() -> None:
289
+ outputs: list[str] = []
290
+ player_deck = tuple(make_card("Strike", 1, Effect("deal", amount=25)) for _ in range(4))
291
+ enemy_deck = (
292
+ make_card("Wait", 5, Effect("block", amount=1)),
293
+ make_card("Wait", 5, Effect("block", amount=1)),
294
+ make_card("Wait", 5, Effect("block", amount=1)),
295
+ make_card("Wait", 5, Effect("block", amount=1)),
296
+ )
297
+ result = run_text_duel(player_deck, enemy_deck, lambda _: "0", outputs.append, Random(0), max_rounds=1)
298
+ assert result == "You"
299
+ assert outputs[-1] == "Winner: You"
300
+
301
+
302
+ # Verify the round stops immediately when the first actor wins.
303
+ def test_run_text_duel_stops_after_first_actor_win() -> None:
304
+ outputs: list[str] = []
305
+ player_deck = tuple(make_card("Strike", 1, Effect("deal", amount=25)) for _ in range(4))
306
+ enemy_deck = (
307
+ make_card("Wait", 1, Effect("deal", amount=1)),
308
+ make_card("Wait", 1, Effect("deal", amount=1)),
309
+ make_card("Wait", 1, Effect("deal", amount=1)),
310
+ make_card("Wait", 1, Effect("deal", amount=1)),
311
+ )
312
+ result = run_text_duel(player_deck, enemy_deck, lambda _: "0", outputs.append, Random(3), max_rounds=1)
313
+ assert result == "You"
314
+ assert not any(line.startswith("Enemy plays") for line in outputs)
tests/test_primitives.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from primitives import Effect, get_primitive, primitive_allowed_for_school, primitive_ids, render_effect, school_bias, school_primitives
4
+
5
+
6
+ # Verify the fixed primitive vocabulary stays complete.
7
+ def test_primitive_ids_are_the_locked_thirteen() -> None:
8
+ assert primitive_ids() == (
9
+ "deal",
10
+ "burn",
11
+ "bomb",
12
+ "block",
13
+ "ward",
14
+ "weak",
15
+ "draw",
16
+ "energy",
17
+ "initiative",
18
+ "multi_hit",
19
+ "vulnerable",
20
+ "conditional",
21
+ "scaling",
22
+ )
23
+
24
+
25
+ # Verify school draft pools match the README.
26
+ def test_school_primitives_match_spec() -> None:
27
+ assert school_primitives("fire") == ("deal", "burn", "bomb", "scaling", "draw", "energy", "block", "conditional")
28
+ assert school_primitives("ice") == ("deal", "initiative", "vulnerable", "multi_hit", "conditional", "draw", "energy", "block")
29
+ assert school_primitives("earth") == ("deal", "ward", "block", "weak", "scaling", "conditional", "draw", "energy")
30
+ assert primitive_allowed_for_school("deal", "fire")
31
+ assert primitive_allowed_for_school("deal", "ice")
32
+ assert not primitive_allowed_for_school("bomb", "ice")
33
+
34
+
35
+ # Verify school bias explains generation weights.
36
+ def test_school_bias() -> None:
37
+ assert "immediate Deal" in school_bias("fire")
38
+ assert "shield charge" in school_bias("earth")
39
+
40
+
41
+ # Verify primitive lookup returns the stored schema.
42
+ def test_get_primitive_returns_spec() -> None:
43
+ primitive = get_primitive("ward")
44
+ assert primitive.name == "Ward"
45
+ assert primitive.category == "defense"
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ ("effect", "text"),
50
+ (
51
+ (Effect("deal", amount=4), "Deal 4 damage."),
52
+ (Effect("burn", amount=2, duration=3), "Burn 2 damage for 3 turns."),
53
+ (Effect("bomb", amount=9, delay=3), "Bomb: deal 9 damage in 3 turns."),
54
+ (Effect("block", amount=6), "Gain 6 block until your next turn."),
55
+ (Effect("ward", amount=2), "Gain 2 ward."),
56
+ (Effect("weak", amount=2), "Opponent deals 2 less damage until your next turn."),
57
+ (Effect("draw", amount=1), "Draw 1 card."),
58
+ (Effect("draw", amount=2), "Draw 2 cards."),
59
+ (Effect("energy", amount=1), "Gain 1 energy this turn."),
60
+ (Effect("initiative"), "Opponent acts second next round."),
61
+ (Effect("multi_hit", amount=2, hits=3), "Deal 2 damage 3 times."),
62
+ (Effect("vulnerable", amount=1, duration=2), "Opponent takes 1 more damage for 2 turns."),
63
+ (Effect("conditional", amount=6), "Deal up to 6 damage based on opponent's missing HP."),
64
+ (Effect("scaling", amount=2), "Deal 2 damage plus cards played this turn."),
65
+ (Effect("scaling", amount=2, condition="shield_charge"), "Deal 2 damage plus your banked shield charge, then empty it."),
66
+ ),
67
+ )
68
+ # Verify every effect renders to fixed parseable text.
69
+ def test_render_effect(effect: Effect, text: str) -> None:
70
+ assert render_effect(effect) == text
tests/test_ui.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
19
+ # Build a run state that has finished drafting and entered battle.
20
+ def battle_state(seed: int = 2):
21
+ state = new_run("Ada", "dark fantasy", "fire", seed=seed)
22
+ for _ in range(9):
23
+ state = choose_draft_card(state, 0)
24
+ return state
25
+
26
+
27
+ # Verify enemy school rotation is deterministic.
28
+ def test_enemy_school_for() -> None:
29
+ assert enemy_school_for("fire") == "earth"
30
+ assert enemy_school_for("earth") == "ice"
31
+ assert enemy_school_for("ice") == "fire"
32
+
33
+
34
+ # Verify fallback packs produce three costed card panels.
35
+ def test_fallback_pack() -> None:
36
+ pack = fallback_pack("earth", "dark fantasy", 3)
37
+ assert len(pack) == CARD_PANEL_COUNT
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)
44
+ assert state.player_name == "Ada"
45
+ assert state.enemy_school == "fire"
46
+ assert state.duel is None
47
+ html = draft_screen_html(state)
48
+ assert html.count("tabras-card") == CARD_PANEL_COUNT
49
+ assert all(f"draft-btn-{index}" in html for index in range(CARD_PANEL_COUNT))
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()
56
+ assert state.duel is not None
57
+ assert len(state.player_deck) == 15
58
+ assert len(state.enemy_deck) == 15
59
+ assert "Boss" in state.duel.enemy.name
60
+ assert draft_screen_html(state) == ""
61
+
62
+
63
+ # Verify the board renders heroes, mana, hand, and end-turn control.
64
+ def test_board_html_shows_battle_state() -> None:
65
+ state = battle_state()
66
+ html = board_html(state)
67
+ assert "hp-gem" in html
68
+ assert "mana-bar" in html
69
+ assert "hand-fan" in html
70
+ assert "end-turn-btn" in html
71
+ assert html.count("hand-card") >= len(state.duel.player.hand)
72
+
73
+
74
+ # Verify the board is empty before battle.
75
+ def test_board_html_empty_outside_battle() -> None:
76
+ assert board_html(None) == ""
77
+ assert board_html(new_run("Ada", "anime", "ice", seed=1)) == ""
78
+
79
+
80
+ # Verify picking a draft card first fades the old pack, then deals the next.
81
+ def test_draft_pick_fades_between_packs() -> None:
82
+ state = new_run("Ada", "anime", "ice", seed=1)
83
+ old_pack = state.current_pack
84
+ steps = list(choose_draft_card_steps(state, 1))
85
+ fading, fresh = steps[0], steps[1]
86
+ assert fading.pack_fading == 1
87
+ assert fading.current_pack == old_pack
88
+ html = draft_screen_html(fading)
89
+ assert html.count("fading") == 2
90
+ assert "picked" in html
91
+ assert "draft-btn" not in html
92
+ assert fresh.pack_fading == -1
93
+ assert "draft-btn-0" in draft_screen_html(fresh)
94
+
95
+
96
+ # Verify playing a card never ends the turn; only END TURN brings the boss out.
97
+ def test_playing_card_keeps_turn() -> None:
98
+ state = battle_state()
99
+ state.duel.player.energy = sum(card.cost for card in state.duel.player.hand)
100
+ for _ in range(len(state.duel.player.hand)):
101
+ state = play_hand_card(state, 0)
102
+ assert all(owner != "Boss" for owner, _ in state.showcase)
103
+ assert state.duel.round_number == battle_state().duel.round_number
104
+
105
+
106
+ # Verify the battlefield drops END TURN and the your-turn banner when the duel ends.
107
+ def test_battlefield_quiets_after_winner() -> None:
108
+ state = battle_state()
109
+ live = board_html(state)
110
+ assert "END TURN" in live and "your turn" in live
111
+ state.duel.enemy.hp = 0
112
+ over = board_html(state)
113
+ assert "END TURN" not in over
114
+ assert "your turn" not in over
115
+ assert "duel over" in over
116
+
117
+
118
+ # Verify playing a hand card consumes it and logs the play.
119
+ def test_play_hand_card_plays_and_logs() -> None:
120
+ state = battle_state()
121
+ state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
122
+ before = len(state.duel.player.hand)
123
+ played = play_hand_card(state, 0)
124
+ assert any("plays" in line for line in played.log)
125
+ assert len(played.duel.player.hand) <= before
126
+
127
+
128
+ # Verify an unaffordable card is refused with a log line.
129
+ def test_play_hand_card_refuses_expensive_card() -> None:
130
+ state = battle_state()
131
+ expensive = next((i for i, card in enumerate(state.duel.player.hand) if card.cost > state.duel.player.energy), None)
132
+ if expensive is None:
133
+ return
134
+ played = play_hand_card(state, expensive)
135
+ assert "needs" in played.log[-1]
136
+ assert len(played.duel.player.hand) == len(state.duel.player.hand)
137
+
138
+
139
+ # Verify out-of-range plays leave the state unchanged.
140
+ def test_play_hand_card_ignores_bad_index() -> None:
141
+ state = battle_state()
142
+ assert play_hand_card(state, 99) is state
143
+ assert play_hand_card(state, -1) is state
144
+
145
+
146
+ # Verify passing advances the battle log.
147
+ def test_pass_turn_updates_log() -> None:
148
+ state = battle_state(seed=4)
149
+ passed = pass_turn(state)
150
+ assert any("passes" in line for line in passed.log)
151
+
152
+
153
+ # Verify played cards land in the showcase with an owner label.
154
+ def test_showcase_records_plays() -> None:
155
+ state = battle_state()
156
+ state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
157
+ played = play_hand_card(state, 0)
158
+ assert played.showcase[0][0] == "Ada"
159
+ html = board_html(played)
160
+ assert "played-card" in html
161
+ assert "You played" in html
162
+
163
+
164
+ # Verify a round turnover step announces the round and initiative on a darkened board.
165
+ def test_round_splash_announces_initiative() -> None:
166
+ state = battle_state()
167
+ steps = list(pass_turn_steps(state))
168
+ splash = next(step for step in steps if step.round_flash)
169
+ html = board_html(splash)
170
+ assert f"ROUND {splash.round_flash}" in html
171
+ assert "YOU GO FIRST" in html or "BOSS GOES FIRST" in html
172
+ assert "splash-initiative" in html
173
+
174
+
175
+ # Verify the boss takes a visible thinking step before playing.
176
+ def test_boss_turn_shows_thinking() -> None:
177
+ state = battle_state(seed=4)
178
+ for _ in range(3):
179
+ steps = list(pass_turn_steps(state))
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)
192
+ for _ in range(3):
193
+ steps = list(pass_turn_steps(state))
194
+ counts = [len(step.showcase) for step in steps if step.showcase]
195
+ if counts:
196
+ assert counts == sorted(counts)
197
+ final = next(step for step in reversed(steps) if step.showcase)
198
+ assert board_html(final).count("play-slot fresh") == 1
199
+ return
200
+ state = steps[-1]
201
+ raise AssertionError("boss never played a card")
202
+
203
+
204
+ # Verify playable hand cards launch through the animated play handler.
205
+ def test_hand_cards_use_play_handler() -> None:
206
+ state = battle_state()
207
+ state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
208
+ assert "tabrasPlay('hand-btn-0', this)" in board_html(state)
209
+
210
+
211
+ # Verify play log entries are structured with owner, card, and effect text.
212
+ def test_log_html_styles_plays_and_rounds() -> None:
213
+ state = battle_state()
214
+ state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
215
+ html = log_html(play_hand_card(state, 0))
216
+ assert "log-play log-you" in html
217
+ assert "log-owner" in html
218
+ assert "log-round" in html
219
+
220
+
221
+ # Verify boss plays join the showcase after the player passes.
222
+ def test_showcase_includes_boss_plays() -> None:
223
+ state = battle_state(seed=4)
224
+ for _ in range(3):
225
+ state = pass_turn(state)
226
+ assert any(owner == "Boss" for owner, _ in state.showcase)
227
+
228
+
229
+ # Verify per-step damage flashes sum to the action's total HP loss.
230
+ def test_hp_flash_tracks_damage() -> None:
231
+ state = battle_state(seed=4)
232
+ for _ in range(8):
233
+ hp_before = state.duel.player.hp
234
+ steps = list(pass_turn_steps(state))
235
+ flashes = [step for step in steps if step.hp_flash[0] > 0]
236
+ assert sum(step.hp_flash[0] for step in steps) == max(0, hp_before - steps[-1].duel.player.hp)
237
+ if flashes:
238
+ assert "dmg-pop" in board_html(flashes[0])
239
+ return
240
+ state = steps[-1]
241
+ raise AssertionError("boss never dealt damage in eight rounds")
242
+
243
+
244
+ # Verify unplayable hand cards render dimmed without a click handler.
245
+ def test_hand_fan_marks_unplayable_cards() -> None:
246
+ state = battle_state()
247
+ player = state.duel.player
248
+ if all(card.cost <= player.energy for card in player.hand):
249
+ player.energy = 0
250
+ html = board_html(state)
251
+ assert "unplayable" in html
252
+
253
+
254
+ # Verify mana crystals reflect energy against the round cap.
255
+ def test_mana_html_fills_to_energy() -> None:
256
+ state = battle_state()
257
+ player = state.duel.player
258
+ html = mana_html(player, state.duel.round_number)
259
+ assert html.count("mana filled") == player.energy
260
+ assert f"{player.energy}/" in html
261
+
262
+
263
+ # Verify pending bombs render as telegraphed tokens.
264
+ def test_pending_tokens_show_bombs() -> None:
265
+ state = battle_state()
266
+ duel = state.duel
267
+ from game import PendingEffect
268
+
269
+ duel.pending.append(PendingEffect("bomb", duel.player.name, duel.enemy.name, 9, delay=2))
270
+ html = pending_tokens_html(duel, duel.enemy.name)
271
+ assert "bomb in 3" in html
272
+ assert pending_tokens_html(duel, duel.player.name) == ""
273
+
274
+
275
+ # Verify the winner banner appears when the duel ends.
276
+ def test_winner_banner_on_victory() -> None:
277
+ state = battle_state()
278
+ state.duel.enemy.hp = 0
279
+ html = board_html(state)
280
+ assert "VICTORY" in html
281
+ assert "restart-btn" in html
282
+ assert "game-over" in html
283
+
284
+
285
+ # Verify the boss chooser is built once and falls back without a model.
286
+ def test_ui_boss_chooser_cached_fallback(monkeypatch) -> None:
287
+ import ui
288
+
289
+ monkeypatch.delenv("TABRAS_AI_BOSS", raising=False)
290
+ monkeypatch.setattr(ui, "_boss_chooser_cache", None)
291
+ chooser = ui.ui_boss_chooser()
292
+ assert ui.ui_boss_chooser() is chooser
293
+ state = battle_state(seed=4)
294
+ from play import choose_enemy_card
295
+
296
+ assert chooser(state.duel, state.duel.enemy) == choose_enemy_card(state.duel, state.duel.enemy)
297
+
298
+
299
+ # Verify the draft banner survives the final fade frame after pick nine.
300
+ def test_draft_banner_after_final_pick() -> None:
301
+ state = new_run("Ada", "anime", "ice", seed=1)
302
+ for _ in range(8):
303
+ state = choose_draft_card(state, 0)
304
+ final_steps = list(choose_draft_card_steps(state, 0))
305
+ fade = final_steps[0]
306
+ assert fade.pack_fading == 0
307
+ assert "Deck complete" in draft_screen_html(fade)
308
+ assert final_steps[-1].duel is not None
309
+
310
+
311
+ # Verify the log renders newest-first for bottom-anchored scroll.
312
+ def test_log_html_newest_first() -> None:
313
+ state = battle_state()
314
+ html = log_html(state)
315
+ assert html.startswith("<div class='log-scroll'>")
316
+ newest = state.log[-1]
317
+ assert html.index(newest[:20]) < html.index(state.log[0][:10])
318
+ assert log_html(None) == ""
ui.py ADDED
@@ -0,0 +1,655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Callable, Iterator
2
+ 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
9
+ from draft import backbone_deck, draft_anchor_indexes, draft_order
10
+ from game import MAX_ENERGY, DuelState, PlayerState, create_player, draw_cards, play_card_from_hand, start_round, winner
11
+ from generator import CardPackClient, generate_pack
12
+ from play import OPENING_HAND_SIZE, SYNERGY_COSTS, best_draft_index, choose_enemy_card, playable_indexes, shuffled_deck
13
+ from primitives import School
14
+
15
+ CARD_PANEL_COUNT = 3
16
+ HAND_PANEL_COUNT = 10
17
+ THEME = "dark fantasy"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RunState:
22
+ player_name: str
23
+ world: str
24
+ school: School
25
+ enemy_school: School
26
+ player_deck: tuple[Card, ...]
27
+ enemy_deck: tuple[Card, ...]
28
+ draft_step: int
29
+ draft_order: tuple[int, ...]
30
+ anchor_indexes: frozenset[int]
31
+ draft_anchors: tuple[Card, ...]
32
+ current_pack: tuple[Card, ...]
33
+ duel: DuelState | None
34
+ turn_order: tuple[str, ...]
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]
46
+
47
+
48
+ # Return the next school used by the enemy.
49
+ 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)
58
+ order = draft_order(len(SYNERGY_COSTS), draft_anchor_indexes(len(SYNERGY_COSTS), rng))
59
+ state = RunState(
60
+ player_name=player_name or "You",
61
+ world=world or THEME,
62
+ school=school,
63
+ enemy_school=enemy_school,
64
+ player_deck=player_deck,
65
+ enemy_deck=(),
66
+ draft_step=0,
67
+ draft_order=order,
68
+ anchor_indexes=frozenset(order[:2]),
69
+ draft_anchors=(),
70
+ current_pack=(),
71
+ duel=None,
72
+ turn_order=(),
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,
127
+ current_pack=(),
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:
144
+ anchors.append(selected)
145
+ return tuple(deck)
146
+
147
+
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 (
192
+ ("Stone Intake", (EffectPlan("block"), EffectPlan("scaling")), "The shield drinks the blow and answers later.", "stone shield lit with stored gold"),
193
+ ("Gravemoss Guard", (EffectPlan("block"), EffectPlan("weak")), "The ground takes their strength first.", "mossy shield under candlelight"),
194
+ ("Cairn Echo", (EffectPlan("scaling"),), "Stored force returns with interest.", "runes glowing in cracked stone"),
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):
201
+ yield state
202
+ return
203
+ card = state.duel.player.hand[index]
204
+ if card.cost > state.duel.player.energy:
205
+ yield add_log(state, f"{card.name} needs {card.cost} energy.")
206
+ return
207
+ yield from paced_action(state, resolve_card_steps, index)
208
+
209
+
210
+ # Play one player hand card by index.
211
+ def play_hand_card(state: RunState, index: int) -> RunState:
212
+ return last_state(play_hand_card_steps(state, index))
213
+
214
+
215
+ # Pass the turn, yielding paced states for the boss response.
216
+ def pass_turn_steps(state: RunState) -> Steps:
217
+ if state.duel is None or winner(state.duel):
218
+ yield state
219
+ return
220
+ yield from paced_action(state, resolve_pass_steps)
221
+
222
+
223
+ # Pass the current player turn.
224
+ def pass_turn(state: RunState) -> RunState:
225
+ return last_state(pass_turn_steps(state))
226
+
227
+
228
+ # Yield action steps stamped with per-step damage and round-turnover flashes.
229
+ def paced_action(state: RunState, steps_fn: Callable[..., Steps], *args: object) -> Steps:
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))
237
+ turned = step.duel.round_number if step.duel.round_number != round_seen else 0
238
+ player_hp, enemy_hp, round_seen = step.duel.player.hp, step.duel.enemy.hp, step.duel.round_number
239
+ yield replace(step, hp_flash=flash, round_flash=turned)
240
+
241
+
242
+ # Resolve one affordable hand card; the turn ends only when the player ends it.
243
+ def resolve_card_steps(state: RunState, index: int) -> Steps:
244
+ assert state.duel is not None
245
+ player = state.duel.player
246
+ played = play_card_from_hand(state.duel, player, index)
247
+ state = show_play(state, player.name, played)
248
+ state = add_log(state, f"{player.name} plays {played.name}: {played.rules_text()}")
249
+ if winner(state.duel):
250
+ yield add_log(state, f"Winner: {winner(state.duel)}")
251
+ return
252
+ yield state
253
+
254
+
255
+ # Resolve a pass by spending remaining energy, then advance.
256
+ def resolve_pass_steps(state: RunState) -> Steps:
257
+ assert state.duel is not None
258
+ state.duel.player.energy = 0
259
+ state = add_log(state, f"{state.duel.player.name} passes.")
260
+ yield state
261
+ yield from advance_after_player_steps(state)
262
+
263
+
264
+ # Yield combat steps after the player has ended their action.
265
+ def advance_after_player_steps(state: RunState) -> Steps:
266
+ assert state.duel is not None
267
+ if winner(state.duel):
268
+ yield state
269
+ return
270
+ position = state.turn_position + 1
271
+ if position < len(state.turn_order) and state.turn_order[position] == state.duel.enemy.name:
272
+ state = yield from boss_turn_steps(replace(state, turn_position=position))
273
+ if winner(state.duel):
274
+ yield add_log(state, f"Winner: {winner(state.duel)}")
275
+ return
276
+ yield from advance_to_player_steps(state)
277
+
278
+
279
+ # Yield round turnovers until the player can act.
280
+ def advance_to_player_steps(state: RunState) -> Steps:
281
+ assert state.duel is not None
282
+ if winner(state.duel):
283
+ yield state
284
+ return
285
+ while not winner(state.duel):
286
+ order = start_round(state.duel, state.rng)
287
+ header = (
288
+ f"Round {state.duel.round_number}: {' then '.join(order)} · "
289
+ f"{state.duel.player.name} {state.duel.player.hp} HP · Boss {state.duel.enemy.hp} HP"
290
+ )
291
+ state = replace(state, turn_order=order, turn_position=0, log=state.log + (header,))
292
+ yield state
293
+ if order[0] == state.duel.player.name:
294
+ return
295
+ state = yield from boss_turn_steps(state)
296
+ if len(order) > 1 and order[1] == state.duel.player.name and not winner(state.duel):
297
+ state = replace(state, turn_position=1)
298
+ yield state
299
+ return
300
+ yield add_log(state, f"Winner: {winner(state.duel)}")
301
+
302
+
303
+ _boss_chooser_cache = None
304
+
305
+
306
+ # Return the boss chooser, wiring the env-configured boss model once.
307
+ def ui_boss_chooser():
308
+ global _boss_chooser_cache
309
+ if _boss_chooser_cache is None:
310
+ _boss_chooser_cache = boss_chooser(boss_client_from_env(), choose_enemy_card)
311
+ return _boss_chooser_cache
312
+
313
+
314
+ # Yield boss plays one card at a time with thinking pauses; return the final state.
315
+ def boss_turn_steps(state: RunState) -> Steps:
316
+ assert state.duel is not None
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
324
+ state = add_log(state, "Boss passes.")
325
+ yield state
326
+ return state
327
+ card = play_card_from_hand(state.duel, state.duel.enemy, choice)
328
+ played_any = True
329
+ state = show_play(state, state.duel.enemy.name, card)
330
+ state = add_log(state, f"Boss plays {card.name}: {card.rules_text()}")
331
+ yield state
332
+ if winner(state.duel):
333
+ return state
334
+ if not played_any:
335
+ state = add_log(state, "Boss has no playable cards.")
336
+ state.duel.enemy.energy = 0
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),))
343
+
344
+
345
+ # Return the last state from an action generator.
346
+ def last_state(steps: Steps) -> RunState:
347
+ state = None
348
+ for state in steps:
349
+ pass
350
+ assert state is not None
351
+ return state
352
+
353
+
354
+ # Add one line to the turn log.
355
+ 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>"
363
+ return (
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:
381
+ cost_index = state.draft_order[state.draft_step]
382
+ kind = "Anchor pick" if cost_index in state.anchor_indexes else "Pick"
383
+ banner = (
384
+ f"<div class='draft-banner'><h2>{kind} {state.draft_step + 1} of {len(state.draft_order)}</h2>"
385
+ f"<p>{escape_html(state.player_name)} of {escape_html(state.world)} &mdash; {school_mark(state.school)}</p></div>"
386
+ )
387
+ cards = "".join(
388
+ draft_card_html(state, index, card) for index, card in enumerate(state.current_pack)
389
+ )
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:
396
+ return card_html(card, "draft-card", "", f"tabrasClick('draft-btn-{index}')")
397
+ chosen = index == state.pack_fading
398
+ return card_html(card, "draft-card picked" if chosen else "draft-card fading")
399
+
400
+
401
+ # Return a mini-chip strip of the deck drafted so far.
402
+ def deck_strip_html(state: RunState) -> str:
403
+ chips = "".join(
404
+ f"<span class='deck-chip{' anchor' if card in state.draft_anchors else ''}'>"
405
+ f"<b>{card.cost}</b> {escape_html(card.name)}</span>"
406
+ for card in state.player_deck
407
+ )
408
+ return f"<div class='deck-strip'><span class='deck-strip-label'>Deck {len(state.player_deck)}/15</span>{chips}</div>"
409
+
410
+
411
+ # Return HTML for the full Hearthstone-style battle board.
412
+ def board_html(state: RunState | None) -> str:
413
+ if state is None or state.duel is None:
414
+ return ""
415
+ duel = state.duel
416
+ over = " game-over" if winner(duel) else ""
417
+ quake = " quake" if state.hp_flash[0] >= 4 else ""
418
+ thinking = " thinking" if state.boss_thinking else ""
419
+ return (
420
+ f"<div class='board{over}{quake}{thinking}'>"
421
+ f"{enemy_zone_html(state)}"
422
+ f"{battlefield_html(state)}"
423
+ f"{player_zone_html(state)}"
424
+ f"{round_splash_html(state)}"
425
+ f"{winner_banner_html(state)}"
426
+ "</div>"
427
+ )
428
+
429
+
430
+ # Return the enemy side of the board.
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>"
438
+ f"{piles_html(enemy)}"
439
+ "</div>"
440
+ )
441
+
442
+
443
+ # Return the player side of the board.
444
+ def player_zone_html(state: RunState) -> str:
445
+ assert state.duel is not None
446
+ player = state.duel.player
447
+ return (
448
+ "<div class='zone player-zone'>"
449
+ "<div class='zone-center'>"
450
+ f"{hero_html(player, state.school, state.player_name, False, state.hp_flash[0])}"
451
+ f"{mana_html(player, state.duel.round_number)}"
452
+ f"{hand_fan_html(player)}"
453
+ "</div>"
454
+ f"{piles_html(player)}"
455
+ "</div>"
456
+ )
457
+
458
+
459
+ # Return the middle strip with pending effects, played cards, round banner, and end turn.
460
+ def battlefield_html(state: RunState) -> str:
461
+ assert state.duel is not None
462
+ duel = state.duel
463
+ over = winner(duel) is not None
464
+ pulse = " pulse" if not over and not playable_indexes(duel.player) else ""
465
+ waiting = "the boss responds" if state.boss_thinking else "your turn"
466
+ banner = "duel over" if over else waiting
467
+ end_turn = "" if over else f"<button class='end-turn{pulse}' onclick=\"tabrasClick('end-turn-btn')\">END TURN</button>"
468
+ return (
469
+ "<div class='battlefield'>"
470
+ f"<div class='pending-row'>{pending_tokens_html(duel, duel.enemy.name)}</div>"
471
+ f"{showcase_html(state)}"
472
+ f"<div class='round-banner'>Round {duel.round_number} &mdash; {banner}</div>"
473
+ f"{end_turn}"
474
+ f"<div class='pending-row'>{pending_tokens_html(duel, duel.player.name)}</div>"
475
+ "</div>"
476
+ )
477
+
478
+
479
+ # Return the cards played since the last player action, labeled, newest animated in.
480
+ def showcase_html(state: RunState) -> str:
481
+ assert state.duel is not None
482
+ enemy = state.duel.enemy.name
483
+ last = len(state.showcase) - 1
484
+ slots = []
485
+ for index, (owner, card) in enumerate(state.showcase):
486
+ boss = owner == enemy
487
+ fresh = " fresh" if index == last else ""
488
+ slots.append(
489
+ f"<div class='play-slot{fresh}'>"
490
+ f"<span class='play-label {'boss' if boss else 'you'}'>{'Boss played' if boss else 'You played'}</span>"
491
+ f"{card_html(card, 'played-card ' + ('enemy-play' if boss else 'player-play'))}"
492
+ "</div>"
493
+ )
494
+ return f"<div class='showcase'>{''.join(slots)}</div>"
495
+
496
+
497
+ # Return the darkened round-turnover splash announcing initiative.
498
+ def round_splash_html(state: RunState) -> str:
499
+ if not state.round_flash or state.duel is None:
500
+ return ""
501
+ first = state.turn_order[0] if state.turn_order else state.duel.player.name
502
+ you_first = first == state.duel.player.name
503
+ line = "YOU GO FIRST" if you_first else "BOSS GOES FIRST"
504
+ return (
505
+ "<div class='round-splash'>"
506
+ f"<div class='splash-round'>ROUND {state.round_flash}</div>"
507
+ f"<div class='splash-initiative {'you' if you_first else 'boss'}'>{line}</div>"
508
+ "</div>"
509
+ )
510
+
511
+
512
+ # Return a hero portrait with HP gem, defenses, status chips, and damage pop.
513
+ def hero_html(player: PlayerState, school: School, title: str, enemy: bool, damage: int = 0) -> str:
514
+ block = f"<div class='block-gem'>{player.block}</div>" if player.block else ""
515
+ ward = f"<div class='ward-gem'>{player.ward}</div>" if player.ward else ""
516
+ pop = f"<div class='dmg-pop'>-{damage}</div>" if damage else ""
517
+ hit = " hit" if damage else ""
518
+ return (
519
+ f"<div class='hero {'enemy' if enemy else 'you'}'>"
520
+ f"<div class='hero-frame{hit}'><div class='hero-face'>{school_mark(school)}</div>"
521
+ f"<div class='hp-gem'>{player.hp}</div>{block}{ward}{pop}</div>"
522
+ f"<div class='hero-name'>{escape_html(title)}</div>"
523
+ f"{status_chips_html(player)}"
524
+ "</div>"
525
+ )
526
+
527
+
528
+ # Return status chips for charge, weak, and vulnerable.
529
+ def status_chips_html(player: PlayerState) -> str:
530
+ chips = []
531
+ if player.shield_charge:
532
+ chips.append(f"<span class='chip charge'>Charge {player.shield_charge}</span>")
533
+ if player.weak:
534
+ chips.append(f"<span class='chip weak'>Weak {player.weak} ({player.weak_turns}t)</span>")
535
+ if player.vulnerable:
536
+ chips.append(f"<span class='chip vuln'>Vulnerable +{player.vulnerable} ({player.vulnerable_turns}t)</span>")
537
+ return f"<div class='chips'>{''.join(chips)}</div>"
538
+
539
+
540
+ # Return the mana crystal bar for the player.
541
+ def mana_html(player: PlayerState, round_number: int) -> str:
542
+ cap = min(MAX_ENERGY, round_number)
543
+ total = max(cap, player.energy)
544
+ gems = "".join(f"<span class='mana{' filled' if index < player.energy else ''}'></span>" for index in range(total))
545
+ return f"<div class='mana-bar'>{gems}<span class='mana-count'>{player.energy}/{cap}</span></div>"
546
+
547
+
548
+ # Return deck and discard piles with a fatigue warning when decked out.
549
+ def piles_html(player: PlayerState) -> str:
550
+ fatigue = f"<div class='fatigue-warn'>Fatigue {player.fatigue}</div>" if not player.deck else ""
551
+ return (
552
+ "<div class='piles'>"
553
+ f"<div class='pile deck'><span>{len(player.deck)}</span><label>Deck</label>{fatigue}</div>"
554
+ f"<div class='pile discard'><span>{len(player.discard)}</span><label>Discard</label></div>"
555
+ "</div>"
556
+ )
557
+
558
+
559
+ # Return the face-down enemy hand fan.
560
+ def enemy_hand_html(enemy: PlayerState) -> str:
561
+ backs = "".join("<div class='enemy-card-back'></div>" for _ in enemy.hand[:HAND_PANEL_COUNT])
562
+ return f"<div class='enemy-hand'>{backs}</div>"
563
+
564
+
565
+ # Return the fanned, clickable player hand.
566
+ def hand_fan_html(player: PlayerState) -> str:
567
+ cards = player.hand[:HAND_PANEL_COUNT]
568
+ middle = (len(cards) - 1) / 2
569
+ pieces = []
570
+ for index, card in enumerate(cards):
571
+ offset = index - middle
572
+ style = f"--rot:{offset * 3.5:.1f}deg;--ty:{abs(offset) * 8:.0f}px;z-index:{index + 1};"
573
+ playable = card.cost <= player.energy
574
+ classes = "hand-card" if playable else "hand-card unplayable"
575
+ onclick = f"tabrasPlay('hand-btn-{index}', this)" if playable else ""
576
+ pieces.append(card_html(card, classes, style, onclick))
577
+ return f"<div class='hand-fan'>{''.join(pieces)}</div>"
578
+
579
+
580
+ # Return telegraphed pending-effect tokens aimed at one player.
581
+ def pending_tokens_html(duel: DuelState, target: str) -> str:
582
+ tokens = []
583
+ for effect in duel.pending:
584
+ if effect.target != target:
585
+ continue
586
+ if effect.primitive_id == "bomb":
587
+ tokens.append(f"<div class='token bomb'><b>{effect.amount}</b><span>bomb in {effect.delay + 1}</span></div>")
588
+ else:
589
+ tokens.append(f"<div class='token burn'><b>{effect.amount}</b><span>burn &times;{max(effect.duration, 1)}</span></div>")
590
+ return "".join(tokens)
591
+
592
+
593
+ # Return the end-of-duel banner with a new-run control.
594
+ def winner_banner_html(state: RunState) -> str:
595
+ assert state.duel is not None
596
+ win = winner(state.duel)
597
+ if win is None:
598
+ return ""
599
+ text = "VICTORY" if win == state.duel.player.name else ("DRAW" if win == "draw" else "DEFEAT")
600
+ return (
601
+ f"<div class='winner-banner {text.lower()}'><h1>{text}</h1>"
602
+ "<button class='new-run' onclick=\"tabrasClick('restart-btn')\">New Run</button></div>"
603
+ )
604
+
605
+
606
+ # Return the battle log as styled entries, newest at the bottom.
607
+ def log_html(state: RunState | None) -> str:
608
+ if state is None:
609
+ return ""
610
+ entries = "".join(log_entry_html(line) for line in reversed(state.log[-24:]))
611
+ return f"<div class='log-scroll'>{entries}</div>"
612
+
613
+
614
+ # Return one styled log entry, breaking plays into owner, card, and effect.
615
+ def log_entry_html(line: str) -> str:
616
+ kind = log_kind(line)
617
+ if " plays " in line and ": " in line:
618
+ head, rules = line.split(": ", 1)
619
+ owner, card_name = head.split(" plays ", 1)
620
+ return (
621
+ f"<div class='log-line {kind}'><span class='log-owner'>{escape_html(owner)} played</span>"
622
+ f"<b>{escape_html(card_name)}</b><span class='log-rules'>{escape_html(rules)}</span></div>"
623
+ )
624
+ return f"<div class='log-line {kind}'>{escape_html(line)}</div>"
625
+
626
+
627
+ # Classify one log line for styling.
628
+ def log_kind(line: str) -> str:
629
+ if line.startswith("Round "):
630
+ return "log-round"
631
+ if line.startswith("Winner:"):
632
+ return "log-winner"
633
+ if " plays " in line:
634
+ return "log-play log-boss" if line.startswith("Boss") else "log-play log-you"
635
+ if "Drafted" in line:
636
+ return "log-draft"
637
+ if "passes" in line or "no playable" in line:
638
+ return "log-muted"
639
+ return "log-info"
640
+
641
+
642
+ # Return the school display mark.
643
+ def school_mark(school: str) -> str:
644
+ return {"fire": "Fire", "ice": "Ice", "earth": "Earth"}.get(school, school.title())
645
+
646
+
647
+ # Escape HTML for generated card text.
648
+ def escape_html(text: str) -> str:
649
+ return (
650
+ text.replace("&", "&amp;")
651
+ .replace("<", "&lt;")
652
+ .replace(">", "&gt;")
653
+ .replace('"', "&quot;")
654
+ .replace("'", "&#x27;")
655
+ )
uv.lock ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"