Spaces:
Sleeping
Sleeping
| 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) | |