from random import Random from budget import Card from game import DuelState, create_player from play import ( best_pack_index, best_draft_index, card_line, card_primitives, choose_assistant_card, choose_enemy_card, choice_state_line, combat_card_score, demo_deck, draft_enemy_deck, draft_card_score, draft_player_deck, ice_need_bonus, earth_need_bonus, effect_score, immediate_hp_damage, pack_line, playable_indexes, player_line, play_turn, prompt_pack_choice, prompt_human_choice, run_text_duel, shuffled_deck, ) from primitives import Effect # Build a play-loop test card. def make_card(name: str, cost: int, effect: Effect) -> Card: return Card(name, cost, "fire", "wuxia", (effect,)) # Build a play-loop test duel. def make_duel() -> DuelState: return DuelState(create_player("You", []), create_player("Enemy", [])) class PlayPackClient: # Initialize call tracking for play draft generation. def __init__(self) -> None: self.calls = 0 # Return one legal fire pack. def create_pack(self, payload: dict[str, object]) -> dict[str, object]: self.calls += 1 if "costs" in payload: costs = payload["costs"] # type: ignore[assignment] return { "cards": [ {"name": f"Spark {self.calls}-{index}", "flavor": "A", "effects": [{"primitive_id": "deal"}]} for index, _ in enumerate(costs, 1) ] } return { "cards": [ {"name": f"Spark {self.calls}A", "flavor": "A", "effects": [{"primitive_id": "deal"}]}, {"name": f"Spark {self.calls}B", "flavor": "B", "effects": [{"primitive_id": "burn"}]}, {"name": f"Spark {self.calls}C", "flavor": "C", "effects": [{"primitive_id": "scaling"}]}, ] } # Verify card and player display lines stay compact. def test_display_lines() -> None: card = make_card("Spark", 1, Effect("deal", amount=2)) player = create_player("You", []) player.energy = 1 player.block = 2 player.ward = 3 assert card_line(0, card) == "0: Spark [1] - Deal 2 damage." assert player_line(player) == "You: 20 HP, 1 energy, 2 block, 3 ward, 0 charge (hand 0, deck 0, discard 0)" # Verify choice state shows the active energy and target defenses. def test_choice_state_line() -> None: state = make_duel() state.player.energy = 3 state.enemy.hp = 12 state.enemy.block = 2 state.enemy.ward = 1 assert choice_state_line(state, state.player) == "You energy 3. Enemy: 12 HP, 2 block, 1 ward." # Verify draft pack lines include flavor. def test_pack_line() -> None: card = Card("Spark", 1, "fire", "wuxia", (Effect("deal", amount=2),), flavor="Hot.") assert pack_line(0, card) == "0: Spark [1] - Deal 2 damage. | Hot." # Verify playable indexes filter by current energy. def test_playable_indexes() -> None: player = create_player("You", []) player.energy = 2 player.hand = [ make_card("One", 1, Effect("deal", amount=2)), make_card("Three", 3, Effect("deal", amount=6)), ] assert playable_indexes(player) == (0,) # Verify the enemy chooses lethal before other playable cards. def test_choose_enemy_card_prefers_lethal() -> None: state = make_duel() state.enemy.energy = 3 state.player.hp = 2 state.enemy.hand = [ make_card("Cheap", 1, Effect("deal", amount=2)), make_card("Big", 3, Effect("block", amount=10)), ] assert choose_enemy_card(state, state.enemy) == 0 assert choose_assistant_card(state, state.enemy) == 0 # Verify the enemy passes without playable cards. def test_choose_enemy_card_passes_without_playable_cards() -> None: state = make_duel() state.enemy.energy = 0 state.enemy.hand = [make_card("Big", 3, Effect("deal", amount=4))] assert choose_assistant_card(state, state.enemy) is None # Verify the enemy values defense when behind. def test_choose_enemy_card_values_defense_when_behind() -> None: state = make_duel() state.enemy.hp = 5 state.player.hp = 12 state.enemy.energy = 2 state.enemy.hand = [ make_card("Small hit", 1, Effect("deal", amount=2)), make_card("Guard", 2, Effect("block", amount=6)), ] assert choose_enemy_card(state, state.enemy) == 1 # Verify human input accepts pass and retries invalid choices. def test_prompt_human_choice() -> None: outputs: list[str] = [] answers = iter(["9", "0"]) state = make_duel() state.player.energy = 1 state.player.hand = [make_card("Spark", 1, Effect("deal", amount=2))] chooser = prompt_human_choice(lambda _: next(answers), outputs.append) assert chooser(state, state.player) == 0 assert "That card is not playable." in outputs assert "You energy 1. Enemy: 20 HP, 0 block, 0 ward." in outputs pass_chooser = prompt_human_choice(lambda _: "pass", outputs.append) assert pass_chooser(state, state.player) is None # Verify draft prompt accepts one shown card and retries invalid input. def test_prompt_pack_choice() -> None: outputs: list[str] = [] answers = iter(["5", "1"]) pack = ( make_card("A", 1, Effect("deal", amount=2)), make_card("B", 1, Effect("deal", amount=2)), make_card("C", 1, Effect("deal", amount=2)), ) chooser = prompt_pack_choice(lambda _: next(answers), outputs.append) assert chooser((), pack) == 1 assert "Choose one of the shown draft cards." in outputs # Verify player drafting produces a 15-card deck from nine packs. def test_draft_player_deck() -> None: outputs: list[str] = [] client = PlayPackClient() deck = draft_player_deck(client, "fire", "dark fantasy", lambda _: "2", outputs.append) assert len(deck) == 15 assert client.calls == 9 assert deck[-1].name == "Spark 9C" assert "Draft pick 9/9. Current deck: 14 cards." in outputs # Verify enemy drafting chooses the highest-scoring generated card. def test_draft_enemy_deck() -> None: outputs: list[str] = [] client = PlayPackClient() deck = draft_enemy_deck(client, "fire", "dark fantasy", outputs.append, Random(0)) assert len(deck) == 15 assert client.calls == 9 assert any("Enemy generated pick 9/9" in line for line in outputs) # Verify pack selection uses static card scoring. def test_best_pack_index() -> None: pack = ( make_card("Block", 1, Effect("block", amount=3)), make_card("Hit", 1, Effect("deal", amount=4)), make_card("Draw", 1, Effect("draw", amount=1)), ) assert best_pack_index(pack) == 1 # Verify deck-aware draft selection can choose synergy over raw damage. def test_best_draft_index_uses_school_needs() -> None: current_deck = (Card("Setup", 1, "ice", "wuxia", (Effect("vulnerable", amount=1),)),) pack = ( Card("Damage", 2, "ice", "wuxia", (Effect("deal", amount=8),)), Card("Payoff", 2, "ice", "wuxia", (Effect("multi_hit", amount=1, hits=2),)), ) assert best_draft_index(current_deck, pack) == 1 assert draft_card_score(current_deck, pack[1]) > draft_card_score(current_deck, pack[0]) # Verify Ice bonus rewards missing payoff. def test_ice_need_bonus_rewards_missing_payoff() -> None: assert ice_need_bonus(("multi_hit",), {"vulnerable": 1}) > 0 assert card_primitives(make_card("Hit", 1, Effect("deal", amount=1))) == ("deal",) # Verify Earth bonus rewards block after scaling payoff. def test_earth_need_bonus_rewards_block_engine() -> None: assert earth_need_bonus(("block",), {"scaling": 2, "block": 1}) > earth_need_bonus(("deal",), {"scaling": 2, "block": 1}) # Verify tactical score helpers cover primitive categories. def test_score_helpers() -> None: state = make_duel() state.enemy.hp = 5 state.player.hp = 10 card = make_card("Guard", 1, Effect("block", amount=3)) assert combat_card_score(state, state.enemy, card) > combat_card_score(state, state.player, card) assert effect_score("multi_hit", 2, 3) == 12 assert effect_score("burn", 2, 1) == 4 assert effect_score("initiative", 0, 1) == 2 assert effect_score("unknown", 0, 1) == 0 # Verify immediate damage estimates cover tactical effect shapes. def test_immediate_hp_damage_shapes() -> None: state = make_duel() state.enemy.block = 1 assert immediate_hp_damage(state.player, state.enemy, make_card("Hits", 1, Effect("multi_hit", amount=2, hits=2))) == 2 state.enemy.block = 0 state.enemy.hp = 10 assert immediate_hp_damage(state.player, state.enemy, make_card("Finish", 1, Effect("conditional", amount=3))) == 1 state.player.cards_played_this_turn = 1 assert immediate_hp_damage(state.player, state.enemy, make_card("Grow", 1, Effect("scaling", amount=2))) == 4 state.player.shield_charge = 5 assert immediate_hp_damage(state.player, state.enemy, make_card("Burst", 1, Effect("scaling", amount=2, condition="shield_charge"))) == 7 state.enemy.ward = 1 assert immediate_hp_damage(state.player, state.enemy, make_card("Stopped", 1, Effect("deal", amount=9))) == 0 state.enemy.ward = 0 state.player.weak = 9 assert immediate_hp_damage(state.player, state.enemy, make_card("Weak", 1, Effect("deal", amount=3))) == 0 # Verify play_turn resolves selected cards until pass. def test_play_turn_resolves_cards() -> None: outputs: list[str] = [] state = make_duel() state.player.energy = 2 state.player.hand = [ make_card("Spark", 1, Effect("deal", amount=2)), make_card("Guard", 1, Effect("block", amount=3)), ] choices = iter([0, None]) play_turn(state, state.player, lambda _, __: next(choices), outputs.append) assert state.enemy.hp == 18 assert state.player.hand[0].name == "Guard" assert state.player.discard[0].name == "Spark" assert outputs[-1] == "You passes." # Verify the demo deck has the locked deck size. def test_demo_deck_has_fifteen_cards() -> None: deck = demo_deck("fire", "dark fantasy") assert len(deck) == 15 assert deck[0].rules_text() == "Deal 2 damage." # Verify deck shuffling is deterministic from the run RNG. def test_shuffled_deck_uses_rng() -> None: deck = tuple(make_card(str(index), 1, Effect("deal", amount=1)) for index in range(5)) shuffled = shuffled_deck(deck, Random(1)) assert [card.name for card in shuffled] != [card.name for card in deck] assert [card.name for card in shuffled] == [card.name for card in shuffled_deck(deck, Random(1))] # Verify a short scripted duel can produce a winner. def test_run_text_duel_returns_winner() -> None: outputs: list[str] = [] player_deck = tuple(make_card("Strike", 1, Effect("deal", amount=25)) for _ in range(4)) enemy_deck = ( make_card("Wait", 5, Effect("block", amount=1)), make_card("Wait", 5, Effect("block", amount=1)), make_card("Wait", 5, Effect("block", amount=1)), make_card("Wait", 5, Effect("block", amount=1)), ) result = run_text_duel(player_deck, enemy_deck, lambda _: "0", outputs.append, Random(0), max_rounds=1) assert result == "You" assert outputs[-1] == "Winner: You" # Verify the round stops immediately when the first actor wins. def test_run_text_duel_stops_after_first_actor_win() -> None: outputs: list[str] = [] player_deck = tuple(make_card("Strike", 1, Effect("deal", amount=25)) for _ in range(4)) enemy_deck = ( make_card("Wait", 1, Effect("deal", amount=1)), make_card("Wait", 1, Effect("deal", amount=1)), make_card("Wait", 1, Effect("deal", amount=1)), make_card("Wait", 1, Effect("deal", amount=1)), ) result = run_text_duel(player_deck, enemy_deck, lambda _: "0", outputs.append, Random(3), max_rounds=1) assert result == "You" assert not any(line.startswith("Enemy plays") for line in outputs)