from typing import Any from boss import ( HeuristicBossClient, NemotronBossClient, affordable_indexes, boss_chooser, boss_indexes, boss_payload, boss_prompt, boss_system_prompt, hand_payload, parse_card_indexes, player_payload, safe_boss_indexes, ) from budget import Card from game import DuelState, create_player from play import run_text_duel from primitives import Effect class FakeBossClient: # Initialize the fake boss 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 boss response. def choose_cards(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) -> None: self.text = 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.text class BrokenBossClient: # Raise like a malformed model response. def choose_cards(self, payload: dict[str, Any]) -> dict[str, Any]: del payload raise ValueError("bad model output") # Build one boss test card. def make_card(name: str, cost: int, effect: Effect) -> Card: return Card(name, cost, "fire", "wuxia", (effect,)) # Build one boss test duel. def make_duel() -> DuelState: return DuelState(create_player("You", []), create_player("Enemy", [])) # Verify boss payload exposes visible state. def test_boss_payload() -> None: state = make_duel() state.enemy.energy = 3 state.enemy.shield_charge = 2 state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))] payload = boss_payload(state, state.enemy) assert payload["actor"]["shield_charge"] == 2 assert payload["opponent"]["name"] == "You" assert payload["hand"][0]["rules_text"] == "Deal 2 damage." # Verify heuristic boss client returns an empty model choice. def test_heuristic_boss_client() -> None: assert HeuristicBossClient().choose_cards({}) == {"card_indexes": []} # Verify boss prompts ask for strict JSON and playable indexes. def test_boss_prompts() -> None: payload = {"playable_indexes": (0,), "actor": {}, "opponent": {}, "hand": []} assert "strict JSON" in boss_system_prompt() assert "playable_indexes" in boss_prompt(payload) # Verify Nemotron boss client uses local chat completions. def test_nemotron_boss_client() -> None: chat = FakeChat('{"card_indexes": [0], "reason": "lethal"}') client = NemotronBossClient(chat) # type: ignore[arg-type] assert client.choose_cards({"playable_indexes": [0]})["card_indexes"] == [0] assert "strict JSON" in chat.calls[0][0] assert "card_indexes" in chat.calls[0][1] # Verify player payload includes zone counts. def test_player_payload() -> None: player = create_player("Enemy", [make_card("Deck", 1, Effect("deal", amount=1))]) player.hand = [make_card("Hand", 1, Effect("deal", amount=1))] player.discard = [make_card("Discard", 1, Effect("deal", amount=1))] assert player_payload(player)["deck_count"] == 1 assert player_payload(player)["hand_count"] == 1 assert player_payload(player)["discard_count"] == 1 # Verify hand payload indexes playable cards. def test_hand_payload() -> None: player = create_player("Enemy", []) player.hand = [make_card("Strike", 1, Effect("deal", amount=2))] assert hand_payload(player) == [{"index": 0, "name": "Strike", "cost": 1, "rules_text": "Deal 2 damage."}] # Verify model indexes parse defensively. def test_parse_card_indexes() -> None: assert parse_card_indexes({"card_indexes": [2, "x", 0]}) == (2, 0) assert parse_card_indexes({"card_indexes": "bad"}) == () assert parse_card_indexes({}) == () # Verify affordability validation preserves sequence. def test_affordable_indexes() -> None: actor = create_player("Enemy", []) actor.energy = 3 actor.hand = [ make_card("One", 1, Effect("deal", amount=1)), make_card("Three", 3, Effect("deal", amount=1)), make_card("Two", 2, Effect("deal", amount=1)), ] assert affordable_indexes(actor, (2, 0, 1, 2, 9)) == (2, 0) # Verify boss indexes go through payload and validation. def test_boss_indexes() -> None: state = make_duel() state.enemy.energy = 1 state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))] client = FakeBossClient({"card_indexes": [0]}) assert boss_indexes(client, state, state.enemy) == (0,) assert client.payload is not None assert client.payload["actor"]["name"] == "Enemy" # Verify boss index safety converts model failures to no choice. def test_safe_boss_indexes_handles_model_failure() -> None: state = make_duel() assert safe_boss_indexes(BrokenBossClient(), state, state.enemy) == () # Verify boss chooser falls back when model output is invalid. def test_boss_chooser_falls_back() -> None: state = make_duel() state.enemy.energy = 1 state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))] chooser = boss_chooser(FakeBossClient({"card_indexes": [9]})) assert chooser(state, state.enemy) == 0 # Verify boss chooser falls back when the model fails. def test_boss_chooser_falls_back_on_model_failure() -> None: state = make_duel() state.enemy.energy = 1 state.enemy.hand = [make_card("Strike", 1, Effect("deal", amount=2))] chooser = boss_chooser(BrokenBossClient()) assert chooser(state, state.enemy) == 0 # Verify boss chooser uses a valid model card first. def test_boss_chooser_uses_model_card() -> None: state = make_duel() state.enemy.energy = 2 state.enemy.hand = [ make_card("A", 1, Effect("deal", amount=1)), make_card("B", 2, Effect("block", amount=5)), ] chooser = boss_chooser(FakeBossClient({"card_indexes": [0]})) assert chooser(state, state.enemy) == 0 # Verify run_text_duel can use a boss client for enemy turns. def test_run_text_duel_uses_boss_client() -> None: outputs: list[str] = [] player_deck = tuple(make_card("Wait", 5, Effect("block", amount=1)) for _ in range(4)) enemy_deck = tuple(make_card("Strike", 1, Effect("deal", amount=25)) for _ in range(4)) result = run_text_duel(player_deck, enemy_deck, lambda _: "pass", outputs.append, max_rounds=1, boss_client=FakeBossClient({"card_indexes": [0]})) assert result == "Enemy" assert any("Enemy plays Strike" in line for line in outputs)