Spaces:
Running
Running
Codex
Trim prefetch to 1 pack, log model-pack failures, JSON-constrained cards, rotate fallback names
b483ca7 | from typing import Any | |
| import subprocess | |
| import pytest | |
| from budget import Card, CardSpec, EffectPlan, cost_card | |
| from generator import ( | |
| CodexCardClient, | |
| LlamaCppCardClient, | |
| MiniCPMCardClient, | |
| balance_json_closers, | |
| card_context, | |
| codex_pack_prompt, | |
| deck_summary, | |
| draft_direction, | |
| draft_need, | |
| extract_json_object, | |
| generate_card, | |
| generate_pack, | |
| generate_llamacpp_card, | |
| generic_card_name, | |
| generator_payload, | |
| llamacpp_card_prompt, | |
| llama_candidate_need, | |
| llamacpp_retry_prompt, | |
| normalize_card_response, | |
| pack_payload, | |
| parse_llamacpp_card_text, | |
| parse_card_payload, | |
| parse_effect_plan, | |
| parse_pack_payload, | |
| repair_llamacpp_card, | |
| primitive_guidance, | |
| school_flavor_mismatch, | |
| school_imagery_rule, | |
| school_identity, | |
| theme_name_leak, | |
| distinct_name, | |
| ) | |
| from primitives import Effect | |
| class FakeClient: | |
| # Initialize the fake client with a fixed raw response. | |
| def __init__(self, raw: dict[str, Any]) -> None: | |
| self.raw = raw | |
| self.payload: dict[str, Any] | None = None | |
| # Capture the payload and return a fixed model response. | |
| def create_card(self, payload: dict[str, Any]) -> dict[str, Any]: | |
| self.payload = payload | |
| return self.raw | |
| class FakePackClient: | |
| # Initialize the fake pack client with a fixed raw response. | |
| def __init__(self, raw: dict[str, Any]) -> None: | |
| self.raw = raw | |
| self.payload: dict[str, Any] | None = None | |
| # Capture the payload and return a fixed model pack response. | |
| def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]: | |
| self.payload = payload | |
| return self.raw | |
| class FakeChat: | |
| # Initialize the fake chat client with fixed text. | |
| def __init__(self, text: str | list[str]) -> None: | |
| self.responses = [text] if isinstance(text, str) else text | |
| self.calls: list[tuple[str, str]] = [] | |
| # Capture prompts and return fixed text. | |
| def complete(self, system: str, user: str) -> str: | |
| self.calls.append((system, user)) | |
| return self.responses[min(len(self.calls) - 1, len(self.responses) - 1)] | |
| # Build one card for generator context tests. | |
| def context_card() -> Card: | |
| return cost_card(CardSpec("Spark", 1, "fire", "wuxia", (EffectPlan("deal"),))) | |
| # Build one direct card for summary tests. | |
| def direct_card(name: str, school: str, effects: tuple[Effect, ...]) -> Card: | |
| return Card(name, 1, school, "wuxia", effects) # type: ignore[arg-type] | |
| # Verify generator payload includes deck context and tool schema. | |
| def test_generator_payload_is_deck_aware() -> None: | |
| payload = generator_payload("fire", "wuxia", [context_card()], [context_card()]) | |
| assert payload["allowed_primitives"] == ("deal", "burn", "bomb", "scaling", "draw", "energy", "block", "conditional") | |
| assert payload["current_deck"][0]["rules_text"] == "Deal 2 damage." | |
| assert payload["current_deck_summary"] == {"deal": 1} | |
| assert payload["draft_anchors"][0]["name"] == "Spark" | |
| assert payload["draft_anchor_summary"] == {"deal": 1} | |
| assert "fast pressure" in payload["draft_direction"] | |
| assert "races with direct pressure" in payload["school_identity"] | |
| assert "burn for damage over turns" in payload["primitive_guidance"] | |
| assert "Fire" in payload["draft_need"] | |
| assert payload["tool_schema"]["name"] == "create_card" | |
| # Verify school identity text captures class mechanics. | |
| def test_school_identity() -> None: | |
| assert "shield charge" in school_identity("earth") | |
| assert "plain deal is secondary" in school_identity("earth") | |
| # Verify primitive guidance maps school ideas to primitive ids. | |
| def test_primitive_guidance() -> None: | |
| assert "scaling to spend shield charge" in primitive_guidance("earth") | |
| # Verify draft direction is inferred from anchor primitives. | |
| def test_draft_direction() -> None: | |
| anchors = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))] | |
| assert "multi_hit" in draft_direction("ice", anchors) | |
| assert "No anchor cards" in draft_direction("fire", []) | |
| # Verify llama.cpp candidate need points at missing scaling. | |
| def test_llama_candidate_need_prefers_missing_scaling() -> None: | |
| payload = pack_payload("earth", "wuxia", [direct_card("Block", "earth", (Effect("block", amount=1),))], 2, 3) | |
| assert "primitive_id scaling" in llama_candidate_need(payload, {}) | |
| # Verify llama.cpp candidate need points at missing block. | |
| def test_llama_candidate_need_prefers_missing_block() -> None: | |
| deck = [direct_card("Scale", "earth", (Effect("scaling", amount=1, condition="shield_charge"),))] | |
| payload = pack_payload("earth", "wuxia", deck, 2, 3) | |
| assert "primitive_id block" in llama_candidate_need(payload, {}) | |
| # Verify llama.cpp candidate need points at missing multi-hit. | |
| def test_llama_candidate_need_prefers_missing_multi_hit() -> None: | |
| deck = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))] | |
| payload = pack_payload("ice", "wuxia", deck, 2, 3) | |
| assert "primitive_id multi_hit" in llama_candidate_need(payload, {}) | |
| # Verify llama.cpp candidate need points at missing initiative. | |
| def test_llama_candidate_need_prefers_missing_initiative() -> None: | |
| deck = [ | |
| direct_card("Vuln A", "ice", (Effect("vulnerable", amount=1),)), | |
| direct_card("Vuln B", "ice", (Effect("vulnerable", amount=1),)), | |
| direct_card("Hit", "ice", (Effect("multi_hit", amount=1),)), | |
| ] | |
| payload = pack_payload("ice", "wuxia", deck, 2, 3) | |
| assert "primitive_id initiative" in llama_candidate_need(payload, {}) | |
| # Verify llama.cpp candidate need avoids repeated deal when covered. | |
| def test_llama_candidate_need_avoids_repeated_deal() -> None: | |
| deck = [ | |
| direct_card("Block", "earth", (Effect("block", amount=1),)), | |
| direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)), | |
| direct_card("Deal A", "earth", (Effect("deal", amount=1),)), | |
| direct_card("Deal B", "earth", (Effect("deal", amount=1),)), | |
| direct_card("Deal C", "earth", (Effect("deal", amount=1),)), | |
| direct_card("Deal D", "earth", (Effect("deal", amount=1),)), | |
| ] | |
| payload = pack_payload("earth", "wuxia", deck, 2, 3) | |
| assert "primitive_id block" in llama_candidate_need(payload, {"deal": 1, "scaling": 1}) | |
| # Verify Fire pack generation explores non-deal primitives after one damage card. | |
| def test_llama_candidate_need_diversifies_fire_pack() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 2, 3) | |
| assert "primitive_id burn" in llama_candidate_need(payload, {"deal": 1}) | |
| assert "primitive_id bomb" in llama_candidate_need(payload, {"deal": 1, "burn": 1}) | |
| assert "primitive_id draw" in llama_candidate_need(payload, {"deal": 1, "burn": 1, "bomb": 1}) | |
| # Verify llama.cpp retry prompt names exact primitive requirements. | |
| def test_llamacpp_retry_prompt_mentions_scaling() -> None: | |
| payload = pack_payload("earth", "wuxia", [], 2, 3) | |
| prompt = llamacpp_retry_prompt(payload, "explore primitive_id scaling") | |
| assert "primitive_id exactly \"block\"" in prompt | |
| assert "primitive_id exactly \"scaling\"" in prompt | |
| assert "primitive_id exactly \"multi_hit\"" in prompt | |
| assert "primitive_id exactly \"initiative\"" in prompt | |
| # Verify deck summary counts all costed card effects. | |
| def test_deck_summary_counts_effects() -> None: | |
| deck = [ | |
| direct_card("Wall", "earth", (Effect("block", amount=3), Effect("ward", amount=1))), | |
| direct_card("Payoff", "earth", (Effect("scaling", amount=2),)), | |
| ] | |
| assert deck_summary(deck) == {"block": 1, "scaling": 1, "ward": 1} | |
| # Verify Earth draft need shifts toward payoff after protection. | |
| def test_earth_draft_need_prefers_payoff_when_protected() -> None: | |
| deck = [ | |
| direct_card("Ward A", "earth", (Effect("ward", amount=1),)), | |
| direct_card("Ward B", "earth", (Effect("ward", amount=1),)), | |
| direct_card("Block", "earth", (Effect("block", amount=1),)), | |
| direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)), | |
| ] | |
| assert "payoff" in draft_need("earth", deck) | |
| # Verify Earth asks for scaling when block exists without payoff. | |
| def test_earth_draft_need_prefers_scaling_after_block() -> None: | |
| deck = [direct_card("Block", "earth", (Effect("block", amount=1),))] | |
| assert "scaling" in draft_need("earth", deck) | |
| # Verify Earth asks for block when payoff exists without enough charge. | |
| def test_earth_draft_need_prefers_block_after_scaling() -> None: | |
| deck = [direct_card("Scale", "earth", (Effect("scaling", amount=1, condition="shield_charge"),))] | |
| assert "add block" in draft_need("earth", deck) | |
| # Verify Earth stops asking for plain damage once deal is covered. | |
| def test_earth_draft_need_moves_away_from_deal() -> None: | |
| deck = [ | |
| direct_card("Block", "earth", (Effect("block", amount=1),)), | |
| direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)), | |
| direct_card("Deal A", "earth", (Effect("deal", amount=1),)), | |
| direct_card("Deal B", "earth", (Effect("deal", amount=1),)), | |
| direct_card("Deal C", "earth", (Effect("deal", amount=1),)), | |
| direct_card("Deal D", "earth", (Effect("deal", amount=1),)), | |
| ] | |
| assert "add block" in draft_need("earth", deck) | |
| # Verify Ice asks for multi-hit after vulnerability setup. | |
| def test_ice_draft_need_prefers_multi_hit_after_vulnerable() -> None: | |
| deck = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))] | |
| assert "multi_hit" in draft_need("ice", deck) | |
| # Verify Ice asks for initiative after setup and payoff exist. | |
| def test_ice_draft_need_prefers_initiative_after_setup_payoff() -> None: | |
| deck = [ | |
| direct_card("Vuln A", "ice", (Effect("vulnerable", amount=1),)), | |
| direct_card("Vuln B", "ice", (Effect("vulnerable", amount=1),)), | |
| direct_card("Hit", "ice", (Effect("multi_hit", amount=1),)), | |
| ] | |
| assert "initiative" in draft_need("ice", deck) | |
| # Verify pack payload includes cost and requested pack size. | |
| def test_pack_payload_includes_draft_context() -> None: | |
| payload = pack_payload("fire", "wuxia", [context_card()], cost=3, pack_size=3) | |
| assert payload["cost"] == 3 | |
| assert payload["pack_size"] == 3 | |
| assert payload["pack_schema"]["required"] == ["cards"] | |
| # Verify card batch payload includes a cost schedule. | |
| def test_cards_payload_includes_cost_schedule() -> None: | |
| from generator import cards_payload | |
| payload = cards_payload("fire", "wuxia", [context_card()], (2, 3)) | |
| assert payload["costs"] == (2, 3) | |
| assert payload["pack_size"] == 2 | |
| assert "immediate Deal" in payload["school_bias"] | |
| # Verify card context exposes compact fixed rules text. | |
| def test_card_context_is_compact() -> None: | |
| assert card_context(context_card()) == {"name": "Spark", "cost": 1, "rules_text": "Deal 2 damage."} | |
| # Verify model-authored magnitudes are ignored by engine costing. | |
| def test_generate_card_ignores_model_numbers() -> None: | |
| client = FakeClient( | |
| { | |
| "name": "Inferno Lie", | |
| "flavor": "The model says 999.", | |
| "art_prompt": "flame", | |
| "effects": [{"primitive_id": "deal", "weight": 1, "amount": 999}], | |
| } | |
| ) | |
| card = generate_card(client, "fire", "wuxia", [context_card()], cost=2) | |
| assert client.payload is not None | |
| assert client.payload["current_deck"][0]["name"] == "Spark" | |
| assert card.effects[0].amount == 4 | |
| assert "999" not in card.rules_text() | |
| # Verify pack generation costs every returned candidate. | |
| def test_generate_pack_returns_costed_candidates() -> None: | |
| client = FakePackClient( | |
| { | |
| "cards": [ | |
| {"name": "A", "effects": [{"primitive_id": "deal"}]}, | |
| {"name": "B", "effects": [{"primitive_id": "burn"}]}, | |
| {"name": "C", "effects": [{"primitive_id": "bomb"}]}, | |
| ] | |
| } | |
| ) | |
| pack = generate_pack(client, "fire", "wuxia", [context_card()], cost=2) | |
| assert client.payload is not None | |
| assert client.payload["current_deck"][0]["name"] == "Spark" | |
| assert [card.name for card in pack] == ["A", "B", "C"] | |
| assert pack[0].rules_text() == "Deal 4 damage." | |
| # Verify multi-card generation costs cards against their own costs. | |
| def test_generate_cards_returns_costed_batch() -> None: | |
| from generator import generate_cards | |
| client = FakePackClient( | |
| { | |
| "cards": [ | |
| {"name": "A", "effects": [{"primitive_id": "deal"}]}, | |
| {"name": "B", "effects": [{"primitive_id": "burn"}]}, | |
| ] | |
| } | |
| ) | |
| cards = generate_cards(client, "fire", "wuxia", [context_card()], (2, 3)) | |
| assert [card.rules_text() for card in cards] == ["Deal 4 damage.", "Burn 3 damage for 2 turns."] | |
| # Verify multi-card generation rejects wrong-size output. | |
| def test_generate_cards_rejects_wrong_size() -> None: | |
| from generator import generate_cards | |
| client = FakePackClient({"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}) | |
| with pytest.raises(ValueError, match="wrong size"): | |
| generate_cards(client, "fire", "wuxia", [], (2, 3)) | |
| # Verify pack generation rejects wrong-size model output. | |
| def test_generate_pack_rejects_wrong_size() -> None: | |
| client = FakePackClient({"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}) | |
| with pytest.raises(ValueError, match="wrong size"): | |
| generate_pack(client, "fire", "wuxia", [], cost=2) | |
| # Verify pack generation trims extra model output. | |
| def test_generate_pack_trims_extra_cards() -> None: | |
| client = FakePackClient( | |
| { | |
| "cards": [ | |
| {"name": "A", "effects": [{"primitive_id": "deal"}]}, | |
| {"name": "B", "effects": [{"primitive_id": "burn"}]}, | |
| {"name": "C", "effects": [{"primitive_id": "bomb"}]}, | |
| {"name": "D", "effects": [{"primitive_id": "draw"}]}, | |
| ] | |
| } | |
| ) | |
| pack = generate_pack(client, "fire", "wuxia", [], cost=2) | |
| assert [card.name for card in pack] == ["A", "B", "C"] | |
| # Verify invalid school output is rejected after parsing. | |
| def test_parse_card_payload_still_goes_through_budget_validation() -> None: | |
| raw = {"name": "Off School", "effects": [{"primitive_id": "bomb"}]} | |
| spec = parse_card_payload(raw, "ice", "wuxia", 1) | |
| with pytest.raises(ValueError, match="not allowed"): | |
| cost_card(spec) | |
| # Verify pack parsing uses engine costing. | |
| def test_parse_pack_payload_costs_cards() -> None: | |
| pack = parse_pack_payload({"cards": [{"name": "A", "effects": [{"primitive_id": "deal", "amount": 999}]}]}, "fire", "wuxia", 1) | |
| assert pack[0].rules_text() == "Deal 2 damage." | |
| # Verify invalid model weights normalize to the smallest legal weight. | |
| def test_parse_effect_plan_clamps_weight() -> None: | |
| assert parse_effect_plan({"primitive_id": "deal", "weight": 0}).weight == 1 | |
| # Verify Codex prompt includes constraints and deck context. | |
| def test_codex_pack_prompt_mentions_engine_owned_numbers() -> None: | |
| prompt = codex_pack_prompt(pack_payload("fire", "wuxia", [context_card()], 2, 3)) | |
| assert "strict JSON only" in prompt | |
| assert "engine owns all final numbers" in prompt | |
| assert "meaningfully different" in prompt | |
| assert "Class identity" in prompt | |
| assert "Primitive guidance" in prompt | |
| assert "Draft anchor cards" in prompt | |
| assert "Draft direction" in prompt | |
| assert "Deck primitive counts" in prompt | |
| assert "Draft need" in prompt | |
| assert "Return exactly 3 cards" in prompt | |
| assert "Spark" in prompt | |
| # Verify Codex prompt supports batch cost schedules. | |
| def test_codex_pack_prompt_mentions_cost_schedule() -> None: | |
| from generator import cards_payload | |
| prompt = codex_pack_prompt(cards_payload("fire", "wuxia", [], (2, 3))) | |
| assert "Card costs by index: [2, 3]" in prompt | |
| # Verify JSON extraction tolerates surrounding model text. | |
| def test_extract_json_object() -> None: | |
| assert extract_json_object("text {\"cards\": []} more") == "{\"cards\": []}" | |
| with pytest.raises(ValueError, match="JSON object"): | |
| extract_json_object("no json") | |
| # Verify JSON extraction ignores later objects in chatty model output. | |
| def test_extract_json_object_uses_first_balanced_object() -> None: | |
| text = 'first {"name": "A", "effects": []} then {"name": "B"}' | |
| assert extract_json_object(text) == '{"name": "A", "effects": []}' | |
| # Verify JSON extraction repairs missing closing delimiters. | |
| def test_extract_json_object_repairs_missing_closer() -> None: | |
| assert extract_json_object('{"cards": [{"name": "A"}]') == '{"cards": [{"name": "A"}]}' | |
| # Verify JSON closer balancing ignores braces inside strings. | |
| def test_balance_json_closers_ignores_strings() -> None: | |
| assert balance_json_closers('{"text": "}"}') == '{"text": "}"}' | |
| # Verify Codex client reads the final-message file. | |
| def test_codex_client_invokes_subprocess(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: | |
| calls: list[dict[str, Any]] = [] | |
| # Fake subprocess.run by writing the requested output file. | |
| def fake_run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]: | |
| output_path = command[command.index("--output-last-message") + 1] | |
| calls.append({"command": command, "kwargs": kwargs}) | |
| with open(output_path, "w") as output: | |
| output.write('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}') | |
| return subprocess.CompletedProcess(command, 0, "", "") | |
| monkeypatch.setattr("generator.subprocess.run", fake_run) | |
| client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path)) | |
| raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 1)) | |
| assert raw["cards"][0]["name"] == "A" | |
| assert "--output-last-message" in calls[0]["command"] | |
| assert calls[0]["kwargs"]["cwd"] == str(tmp_path) | |
| # Verify single-card Codex calls reuse the pack path. | |
| def test_codex_client_create_card_uses_pack(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: | |
| # Fake subprocess.run by writing one valid card. | |
| def fake_run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]: | |
| output_path = command[command.index("--output-last-message") + 1] | |
| with open(output_path, "w") as output: | |
| output.write('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}') | |
| return subprocess.CompletedProcess(command, 0, "", "") | |
| monkeypatch.setattr("generator.subprocess.run", fake_run) | |
| client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path)) | |
| raw = client.create_card(generator_payload("fire", "wuxia", [])) | |
| assert raw["name"] == "A" | |
| # Verify MiniCPM card client uses local chat completions. | |
| def test_minicpm_card_client() -> None: | |
| chat = FakeChat('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}') | |
| client = MiniCPMCardClient(chat) # type: ignore[arg-type] | |
| raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 1)) | |
| assert raw["cards"][0]["name"] == "A" | |
| assert "strict JSON" in chat.calls[0][0] | |
| assert "Allowed primitives" in chat.calls[0][1] | |
| # Verify MiniCPM single-card calls reuse the pack path. | |
| def test_minicpm_card_client_create_card() -> None: | |
| chat = FakeChat('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}') | |
| client = MiniCPMCardClient(chat) # type: ignore[arg-type] | |
| assert client.create_card(generator_payload("fire", "wuxia", []))["name"] == "A" | |
| # Verify llama.cpp card prompt asks for one compact card. | |
| def test_llamacpp_card_prompt() -> None: | |
| payload = pack_payload("fire", "wuxia", [context_card()], 2, 3) | |
| prompt = llamacpp_card_prompt(payload, [{"name": "A", "effects": [{"primitive_id": "burn"}]}]) | |
| assert "Create exactly one Tabras card" in prompt | |
| assert "Fit the school identity" in prompt | |
| assert "Primitive guidance" in prompt | |
| assert "Current draft need" in prompt | |
| assert "This candidate should" in prompt | |
| assert "Already in this pack" in prompt | |
| assert "race plan" not in prompt | |
| assert "\"burn\": 1" in prompt | |
| assert "Spark" in prompt | |
| # Verify llama.cpp card client builds packs from concurrent single-card calls, | |
| # one model call per card, with duplicate names de-duplicated after the fact. | |
| def test_llamacpp_card_client() -> None: | |
| chat = FakeChat('{"name": "A", "effects": [{"primitive_id": "deal"}]}') | |
| client = LlamaCppCardClient(chat) # type: ignore[arg-type] | |
| raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 3)) | |
| assert [card["name"] for card in raw["cards"]] == ["A", "Cinder Warrant", "Ashen Oath"] | |
| assert len(chat.calls) == 3 | |
| # Verify llama.cpp card generation retries missed candidate needs. | |
| def test_generate_llamacpp_card_retries_missed_need() -> None: | |
| payload = pack_payload("earth", "wuxia", [direct_card("Block", "earth", (Effect("block", amount=1),))], 2, 3) | |
| chat = FakeChat( | |
| [ | |
| '{"name": "Plain", "effects": [{"primitive_id": "deal"}]}', | |
| '{"earth_tabras": {"name": "Granite Vow", "effects": [{"primitive_id": "scaling"}]}}', | |
| ] | |
| ) | |
| card = generate_llamacpp_card(chat, payload, []) | |
| assert card["name"] == "Granite Vow" | |
| assert card["effects"] == [{"primitive_id": "scaling", "weight": 1}] | |
| assert len(chat.calls) == 2 | |
| # Verify generic name detection flags mechanic words and empty names. | |
| def test_generic_card_name() -> None: | |
| assert generic_card_name("") | |
| assert generic_card_name("Fire Deal") | |
| assert generic_card_name("Ice Deal 2") | |
| assert generic_card_name("Vulnerable Anime School") | |
| assert generic_card_name("Fast Burner") | |
| assert generic_card_name("Flaming Fire") | |
| assert generic_card_name("Pressureflare") | |
| assert generic_card_name("Pressure Clock 2") | |
| assert not generic_card_name("Vow of Cinders") | |
| # Verify theme labels are rejected from in-world card names. | |
| def test_theme_name_leak() -> None: | |
| assert theme_name_leak("Vulnerable Anime School", "anime school") | |
| assert not theme_name_leak("Glass Edict", "anime school") | |
| # Verify card prompts carry the naming rules. | |
| def test_prompts_ban_mechanic_names() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 2, 3) | |
| assert "Never use these words in the name" in llamacpp_card_prompt(payload, []) | |
| assert "Never use these words in the name" in llamacpp_retry_prompt(payload, "fit the draft need") | |
| assert "glacier" in school_imagery_rule("fire") | |
| assert "glacier" in llamacpp_card_prompt(payload, []) | |
| # Verify llama.cpp card generation retries hyper-generic names. | |
| def test_generate_llamacpp_card_retries_generic_name() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 2, 3) | |
| chat = FakeChat( | |
| [ | |
| '{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}', | |
| '{"name": "Vow of Cinders", "effects": [{"primitive_id": "deal"}]}', | |
| ] | |
| ) | |
| card = generate_llamacpp_card(chat, payload, []) | |
| assert card["name"] == "Vow of Cinders" | |
| assert len(chat.calls) == 2 | |
| # Verify failed Fire retries repair to a non-deal primitive when pack needs variety. | |
| def test_generate_llamacpp_card_repairs_to_needed_fire_primitive() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 2, 3) | |
| chat = FakeChat('{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}') | |
| card = generate_llamacpp_card(chat, payload, [{"name": "A", "effects": [{"primitive_id": "deal"}]}]) | |
| assert card["effects"] == [{"primitive_id": "burn", "weight": 1}] | |
| assert card["name"] == "Cinder Warrant" | |
| # Verify screenshot-style generic names retry into authored names. | |
| def test_generate_llamacpp_card_retries_theme_and_mechanic_names() -> None: | |
| payload = pack_payload("ice", "anime school", [], 3, 3) | |
| chat = FakeChat( | |
| [ | |
| '{"name": "Vulnerable Anime School", "effects": [{"primitive_id": "multi_hit"}]}', | |
| '{"name": "Glass Edict", "effects": [{"primitive_id": "multi_hit"}]}', | |
| ] | |
| ) | |
| card = generate_llamacpp_card(chat, payload, []) | |
| assert card["name"] == "Glass Edict" | |
| assert len(chat.calls) == 2 | |
| # Verify off-school imagery is rejected and retried. | |
| def test_generate_llamacpp_card_retries_off_school_imagery() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 2, 3) | |
| chat = FakeChat( | |
| [ | |
| '{"name": "Howling Glacier", "art_prompt": "frozen snow spell", "effects": [{"primitive_id": "deal"}]}', | |
| '{"name": "Vow of Cinders", "art_prompt": "cinder oath spell", "effects": [{"primitive_id": "deal"}]}', | |
| ] | |
| ) | |
| card = generate_llamacpp_card(chat, payload, []) | |
| assert card["name"] == "Vow of Cinders" | |
| assert len(chat.calls) == 2 | |
| # Verify school imagery validation catches Fire cards with Ice names. | |
| def test_school_flavor_mismatch() -> None: | |
| assert school_flavor_mismatch("fire", {"name": "Howling Glacier"}) | |
| assert not school_flavor_mismatch("fire", {"name": "Vow of Cinders"}) | |
| # Verify llama.cpp repair fills required schema fields. | |
| def test_repair_llamacpp_card_fills_missing_fields() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 1, 3) | |
| raw = {"school": "fire", "effects": [{"primitive_id": "deal", "weight": 0}, {"primitive_id": "burn"}]} | |
| card = repair_llamacpp_card(raw, payload, []) | |
| assert card["name"] == "Cinder Warrant" | |
| assert card["flavor"] == "" | |
| assert card["art_prompt"] == "wuxia fire card art" | |
| assert card["effects"] == [{"primitive_id": "deal", "weight": 1}, {"primitive_id": "burn", "weight": 1}] | |
| # Verify repair strips off-school imagery before costing. | |
| def test_repair_llamacpp_card_replaces_off_school_name() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 1, 3) | |
| raw = {"name": "Howling Glacier", "art_prompt": "frozen snow spell", "effects": [{"primitive_id": "deal"}]} | |
| card = repair_llamacpp_card(raw, payload, []) | |
| assert card["name"] == "Cinder Warrant" | |
| assert "frozen" not in card["art_prompt"] | |
| # Verify llama.cpp repair replaces a duplicate name with a fresh pool name (no number). | |
| def test_repair_llamacpp_card_uniquifies_duplicate_name() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 1, 3) | |
| raw = {"name": "Firepress", "effects": [{"primitive_id": "deal"}]} | |
| card = repair_llamacpp_card(raw, payload, [{"name": "Firepress"}]) | |
| assert card["name"] == "Cinder Warrant" | |
| assert not card["name"][-1].isdigit() | |
| # Verify a collision draws a fresh pool name instead of a numeric suffix. | |
| def test_distinct_name() -> None: | |
| assert distinct_name("A", set(), "fire") == "A" | |
| assert distinct_name("A", {"A"}, "fire") == "Cinder Warrant" | |
| assert distinct_name("A", {"A", "Cinder Warrant"}, "ice") == "Glass Edict" | |
| # Verify llama.cpp repair drops invalid primitives. | |
| def test_repair_llamacpp_card_drops_invalid_primitives() -> None: | |
| payload = pack_payload("ice", "wuxia", [], 1, 3) | |
| card = repair_llamacpp_card({"effects": [{"primitive_id": "bomb"}]}, payload, []) | |
| assert card["effects"] == [{"primitive_id": "deal", "weight": 1}] | |
| # Verify one-card normalization accepts pack-shaped responses. | |
| def test_normalize_card_response() -> None: | |
| assert normalize_card_response({"cards": [{"name": "A"}]}) == {"name": "A"} | |
| # Verify one-card normalization unwraps named wrapper objects. | |
| def test_normalize_card_response_unwraps_wrapper() -> None: | |
| raw = {"earth_tabras": {"name": "Earth Tabras", "effects": [{"primitive_id": "scaling"}]}} | |
| assert normalize_card_response(raw) == {"name": "Earth Tabras", "effects": [{"primitive_id": "scaling"}]} | |
| # Verify one-card normalization unwraps MiniCPM shape wrappers. | |
| def test_normalize_card_response_unwraps_shape() -> None: | |
| raw = {"shape": {"name": "Shield of Scale", "effects": [{"primitive_id": "scaling"}]}, "school": "earth"} | |
| assert normalize_card_response(raw) == {"name": "Shield of Scale", "effects": [{"primitive_id": "scaling"}]} | |
| # Verify malformed llama.cpp card JSON becomes a legal fallback card. | |
| def test_parse_llamacpp_card_text_falls_back() -> None: | |
| payload = pack_payload("earth", "wuxia", [], 1, 3) | |
| card = parse_llamacpp_card_text('{"name" "Broken"}', payload, []) | |
| assert card["name"] == "Stone Covenant" | |
| assert card["effects"] == [{"primitive_id": "deal", "weight": 1}] | |
| # Verify Codex client reports subprocess failures. | |
| def test_codex_client_reports_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: | |
| # Fake subprocess.run with a failed Codex result. | |
| def fake_run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]: | |
| return subprocess.CompletedProcess(command, 1, "", "blocked") | |
| monkeypatch.setattr("generator.subprocess.run", fake_run) | |
| client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path)) | |
| with pytest.raises(RuntimeError, match="blocked"): | |
| client.create_pack(pack_payload("fire", "wuxia", [], 1, 1)) | |
| # Verify quick mode accepts the first card without a corrective retry. | |
| def test_generate_llamacpp_card_quick_skips_retry() -> None: | |
| payload = pack_payload("fire", "wuxia", [], 2, 3) | |
| payload["quick"] = True | |
| chat = FakeChat('{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}') | |
| card = generate_llamacpp_card(chat, payload, []) | |
| assert card["name"] == "Cinder Warrant" | |
| assert len(chat.calls) == 1 | |
| # Verify generate_pack threads the quick flag into the client payload. | |
| def test_generate_pack_threads_quick_flag() -> None: | |
| card = {"name": "A", "flavor": "", "art_prompt": "", "effects": [{"primitive_id": "deal"}]} | |
| client = FakePackClient({"cards": [card, dict(card, name="B"), dict(card, name="C")]}) | |
| generate_pack(client, "fire", "wuxia", [], 1, quick=True) | |
| assert client.payload["quick"] is True | |