Spaces:
Running
Running
Commit ·
6bbf552
1
Parent(s): d8e5b40
Build Tabras card duel prototype
Browse files- .gitignore +4 -0
- README.md +36 -11
- agents.md +48 -0
- ai_runtime.py +61 -0
- app.py +656 -0
- boss.py +138 -0
- budget.py +146 -0
- clients.py +88 -0
- draft.py +82 -0
- game.py +235 -0
- generator.py +633 -0
- launch_ai.py +19 -0
- local_llm.py +277 -0
- play.py +388 -0
- primitives.py +124 -0
- pyproject.toml +10 -0
- tests/test_ai_runtime.py +39 -0
- tests/test_boss.py +187 -0
- tests/test_budget.py +95 -0
- tests/test_clients.py +100 -0
- tests/test_draft.py +108 -0
- tests/test_game.py +254 -0
- tests/test_generator.py +541 -0
- tests/test_local_llm.py +287 -0
- tests/test_play.py +314 -0
- tests/test_primitives.py +70 -0
- tests/test_ui.py +318 -0
- ui.py +655 -0
- 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:**
|
| 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
|
| 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** —
|
| 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,
|
| 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
|
| 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
|
| 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 |
-
|
| 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
|
| 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…</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)} — {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…</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} — {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 ×{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("&", "&")
|
| 651 |
+
.replace("<", "<")
|
| 652 |
+
.replace(">", ">")
|
| 653 |
+
.replace('"', """)
|
| 654 |
+
.replace("'", "'")
|
| 655 |
+
)
|
uv.lock
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version = 1
|
| 2 |
+
revision = 3
|
| 3 |
+
requires-python = ">=3.10"
|