from random import Random from typing import Callable, Protocol from budget import Card from draft import backbone_deck, draft_deck_from_packs from generator import CardPackClient, CodexCardClient, deck_summary from game import DuelState, PlayerState, conditional_damage, create_player, draw_cards, named_player, opponent, play_card_from_hand, start_round, winner from primitives import School Prompt = Callable[[str], str] Printer = Callable[[str], None] Chooser = Callable[[DuelState, PlayerState], int | None] class BossClientLike(Protocol): # Return one raw boss decision payload. def choose_cards(self, payload: dict[str, object]) -> dict[str, object]: # pragma: no cover ... OPENING_HAND_SIZE = 3 MAX_ROUNDS = 20 SYNERGY_COSTS = (2, 2, 3, 3, 4, 4, 4, 5, 5) # Return a compact line for one card in hand. def card_line(index: int, card: Card) -> str: return f"{index}: {card.name} [{card.cost}] - {card.rules_text()}" # Return a compact line for one draft candidate. def pack_line(index: int, card: Card) -> str: return f"{index}: {card.name} [{card.cost}] - {card.rules_text()} | {card.flavor}" # Return the visible combat state for one player. def player_line(player: PlayerState) -> str: zones = f"hand {len(player.hand)}, deck {len(player.deck)}, discard {len(player.discard)}" return f"{player.name}: {player.hp} HP, {player.energy} energy, {player.block} block, {player.ward} ward, {player.shield_charge} charge ({zones})" # Return a focused prompt-state line before a player chooses. def choice_state_line(state: DuelState, actor: PlayerState) -> str: target = opponent(state, actor) return f"{actor.name} energy {actor.energy}. {target.name}: {target.hp} HP, {target.block} block, {target.ward} ward." # Return currently playable hand indexes for a player. def playable_indexes(player: PlayerState) -> tuple[int, ...]: return tuple(index for index, card in enumerate(player.hand) if card.cost <= player.energy) # Return a simple legal demo deck without model generation. def demo_deck(school: School, theme: str) -> tuple[Card, ...]: backbone = backbone_deck(school, theme) return (backbone * 3)[:15] # Print a player's hand. def print_hand(player: PlayerState, print_fn: Printer) -> None: for index, card in enumerate(player.hand): print_fn(card_line(index, card)) # Print one generated draft pack. def print_pack(pack: tuple[Card, ...], print_fn: Printer) -> None: for index, card in enumerate(pack): print_fn(pack_line(index, card)) # Prompt the human for one draft pack choice. def prompt_pack_choice(prompt: Prompt, print_fn: Printer) -> Callable[[tuple[Card, ...], tuple[Card, ...]], int]: # Choose one card from a generated pack. def choose(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int: del current_deck while True: print_pack(pack, print_fn) answer = prompt("Draft card number: ").strip() if answer.isdigit() and int(answer) < len(pack): return int(answer) print_fn("Choose one of the shown draft cards.") return choose # Draft a player deck from nine generated packs. def draft_player_deck( client: CardPackClient, school: School, theme: str, prompt: Prompt = input, print_fn: Printer = print, rng: Random | None = None, ) -> tuple[Card, ...]: pick_number = 0 choose_pack = prompt_pack_choice(prompt, print_fn) # Choose one pack card while printing draft progress. def choose(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int: nonlocal pick_number pick_number += 1 print_fn(f"Draft pick {pick_number}/9. Current deck: {len(current_deck)} cards.") return choose_pack(current_deck, pack) return draft_deck_from_packs(client, school, theme, SYNERGY_COSTS, choose, rng=rng) # Draft an enemy deck by taking the strongest card from each generated pack. def draft_enemy_deck( client: CardPackClient, school: School, theme: str, print_fn: Printer = print, rng: Random | None = None, ) -> tuple[Card, ...]: pick_number = 0 # Choose a deck-aware card from each generated pack. def choose(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int: nonlocal pick_number pick_number += 1 choice = best_draft_index(current_deck, pack) print_fn(f"Enemy generated pick {pick_number}/9: {pack[choice].name}") return choice return draft_deck_from_packs(client, school, theme, SYNERGY_COSTS, choose, rng=rng) # Return the strongest candidate index in a generated pack. def best_pack_index(pack: tuple[Card, ...]) -> int: return max(range(len(pack)), key=lambda index: (card_score(pack[index]), -index)) # Return the strongest draft pick for the current deck's school plan. def best_draft_index(current_deck: tuple[Card, ...], pack: tuple[Card, ...]) -> int: return max(range(len(pack)), key=lambda index: (draft_card_score(current_deck, pack[index]), -index)) # Return a deck-aware draft score for one card. def draft_card_score(current_deck: tuple[Card, ...], card: Card) -> int: counts = deck_summary(current_deck) return card_score(card) + school_need_bonus(card, counts) - repetition_penalty(card, counts) # Return class-specific bonus for cards that fill missing deck needs. def school_need_bonus(card: Card, counts: dict[str, int]) -> int: primitives = card_primitives(card) if card.school == "ice": return ice_need_bonus(primitives, counts) if card.school == "earth": return earth_need_bonus(primitives, counts) return fire_need_bonus(primitives, counts) # Return Fire's draft need bonus. def fire_need_bonus(primitives: tuple[str, ...], counts: dict[str, int]) -> int: delayed = counts.get("burn", 0) + counts.get("bomb", 0) pressure = counts.get("deal", 0) + counts.get("scaling", 0) if delayed >= pressure and ("deal" in primitives or "burn" in primitives or "scaling" in primitives): return 18 return 8 if "deal" in primitives and counts.get("deal", 0) < 5 else 0 # Return Ice's draft need bonus. def ice_need_bonus(primitives: tuple[str, ...], counts: dict[str, int]) -> int: if counts.get("vulnerable", 0) > 0 and counts.get("multi_hit", 0) == 0 and "multi_hit" in primitives: return 35 if counts.get("multi_hit", 0) > 0 and counts.get("initiative", 0) == 0 and "initiative" in primitives: return 35 if counts.get("multi_hit", 0) > counts.get("vulnerable", 0) + 1 and "vulnerable" in primitives: return 30 if counts.get("multi_hit", 0) > counts.get("initiative", 0) + 2 and "initiative" in primitives: return 30 return 0 # Return Earth's draft need bonus. def earth_need_bonus(primitives: tuple[str, ...], counts: dict[str, int]) -> int: if counts.get("scaling", 0) > 0 and counts.get("block", 0) <= counts.get("scaling", 0) and "block" in primitives: return 45 if counts.get("deal", 0) >= counts.get("block", 0) + 2 and ("block" in primitives or "draw" in primitives or "weak" in primitives): return 35 if counts.get("block", 0) > 0 and counts.get("scaling", 0) == 0 and "scaling" in primitives: return 35 protection = counts.get("block", 0) + counts.get("ward", 0) payoff = counts.get("scaling", 0) + counts.get("conditional", 0) if protection > payoff and ("scaling" in primitives or "weak" in primitives or "draw" in primitives): return 20 return 0 # Return a penalty for repeating already represented primitives. def repetition_penalty(card: Card, counts: dict[str, int]) -> int: return sum(counts.get(primitive, 0) * repeat_weight(primitive) for primitive in card_primitives(card)) # Return how strongly repeated primitives are penalized. def repeat_weight(primitive_id: str) -> int: if primitive_id in {"multi_hit", "bomb", "ward", "block"}: return 5 return 3 # Return primitive ids on one card. def card_primitives(card: Card) -> tuple[str, ...]: return tuple(effect.primitive_id for effect in card.effects) # Prompt the human for one card choice. def prompt_human_choice(prompt: Prompt, print_fn: Printer) -> Chooser: # Choose one playable card from prompted input. def choose(state: DuelState, actor: PlayerState) -> int | None: while True: print_fn(choice_state_line(state, actor)) print_hand(actor, print_fn) answer = prompt("Choose card number, or pass: ").strip().lower() if answer in {"", "p", "pass"}: return None if answer.isdigit() and int(answer) in playable_indexes(actor): return int(answer) print_fn("That card is not playable.") return choose # Choose the best playable card for the enemy heuristic. def choose_enemy_card(state: DuelState, actor: PlayerState) -> int | None: playable = playable_indexes(actor) if not playable: return None target = opponent(state, actor) lethal = tuple(index for index in playable if immediate_hp_damage(actor, target, actor.hand[index]) >= target.hp) if lethal: return max(lethal, key=lambda index: (immediate_hp_damage(actor, target, actor.hand[index]), -index)) return max(playable, key=lambda index: (combat_card_score(state, actor, actor.hand[index]), -index)) # Choose the best playable card for the assistant. def choose_assistant_card(state: DuelState, actor: PlayerState) -> int | None: return choose_enemy_card(state, actor) # Return a static draft score for one card. def card_score(card: Card) -> int: return sum(effect_score(effect.primitive_id, effect.amount, effect.hits) for effect in card.effects) - card.cost # Return a combat-aware score for one card. def combat_card_score(state: DuelState, actor: PlayerState, card: Card) -> int: target = opponent(state, actor) defense_bonus = 4 if actor.hp <= target.hp else 0 return immediate_hp_damage(actor, target, card) * 3 + card_score(card) + defense_bonus * defensive_amount(card) # Return the defensive amount on a card. def defensive_amount(card: Card) -> int: return sum(effect.amount for effect in card.effects if effect.primitive_id in {"block", "ward", "weak"}) # Return approximate immediate HP damage from a card. def immediate_hp_damage(actor: PlayerState, target: PlayerState, card: Card) -> int: return sum(effect_hp_damage(actor, target, card, index) for index, _ in enumerate(card.effects)) # Return approximate immediate HP damage from one effect. def effect_hp_damage(actor: PlayerState, target: PlayerState, card: Card, effect_index: int) -> int: effect = card.effects[effect_index] if effect.primitive_id == "deal": return hp_damage_after_defense(actor, target, effect.amount) if effect.primitive_id == "multi_hit": return sum(hp_damage_after_defense(actor, target, effect.amount) for _ in range(effect.hits)) if effect.primitive_id == "conditional": return hp_damage_after_defense(actor, target, conditional_damage(target, effect.amount)) if effect.primitive_id == "scaling": if effect.condition == "shield_charge": return hp_damage_after_defense(actor, target, effect.amount + actor.shield_charge) return hp_damage_after_defense(actor, target, effect.amount + actor.cards_played_this_turn + 1) return 0 # Return HP damage after current weak, vulnerable, ward, and block. def hp_damage_after_defense(actor: PlayerState, target: PlayerState, amount: int) -> int: damage = max(0, amount - actor.weak) if damage == 0 or target.ward > 0: return 0 damage += target.vulnerable return max(0, damage - target.block) # Return a primitive's rough tactical score. def effect_score(primitive_id: str, amount: int, hits: int) -> int: if primitive_id in {"deal", "conditional", "scaling"}: return amount * 2 if primitive_id == "multi_hit": return amount * hits * 2 if primitive_id in {"block", "ward", "weak"}: return amount if primitive_id in {"burn", "bomb", "vulnerable"}: return amount * 2 if primitive_id in {"draw", "energy", "initiative"}: return amount + 2 return 0 # Play cards for one actor until they pass or cannot act. def play_turn(state: DuelState, actor: PlayerState, choose: Chooser, print_fn: Printer) -> None: while playable_indexes(actor): choice = choose(state, actor) if choice is None: expire_energy(actor) print_fn(f"{actor.name} passes.") return card = play_card_from_hand(state, actor, choice) print_fn(f"{actor.name} plays {card.name}: {card.rules_text()}") if winner(state): expire_energy(actor) return expire_energy(actor) print_fn(f"{actor.name} has no playable cards.") # Expire unused turn energy. def expire_energy(player: PlayerState) -> None: player.energy = 0 # Print both sides of the duel state. def print_state(state: DuelState, print_fn: Printer) -> None: print_fn(player_line(state.player)) print_fn(player_line(state.enemy)) # Run a tiny terminal duel between a human and the assistant policy. def run_text_duel( player_deck: tuple[Card, ...] | None = None, enemy_deck: tuple[Card, ...] | None = None, prompt: Prompt = input, print_fn: Printer = print, rng: Random | None = None, max_rounds: int = MAX_ROUNDS, boss_client: BossClientLike | None = None, ) -> str: from boss import boss_chooser randomizer = rng or Random() state = DuelState( create_player("You", shuffled_deck(player_deck or demo_deck("fire", "dark fantasy"), randomizer)), create_player("Enemy", shuffled_deck(enemy_deck or demo_deck("earth", "dark fantasy"), randomizer)), ) draw_cards(state.player, OPENING_HAND_SIZE) draw_cards(state.enemy, OPENING_HAND_SIZE) human_choice = prompt_human_choice(prompt, print_fn) enemy_choice = boss_chooser(boss_client, choose_enemy_card) print_state(state, print_fn) while not winner(state) and state.round_number < max_rounds: order = start_round(state, randomizer) print_fn(f"Round {state.round_number}: {' then '.join(order)}") for name in order: if winner(state): break actor = named_player(state, name) chooser = human_choice if actor is state.player else enemy_choice play_turn(state, actor, chooser, print_fn) print_state(state, print_fn) result = winner(state) or "no winner" print_fn(f"Winner: {result}") return result # Return a shuffled copy of a deck. def shuffled_deck(deck: tuple[Card, ...], rng: Random) -> tuple[Card, ...]: cards = list(deck) rng.shuffle(cards) return tuple(cards) # Start an interactive terminal duel. def main() -> None: # pragma: no cover from clients import boss_client_from_env, card_client_from_env rng = Random() client = card_client_from_env() or CodexCardClient(cwd=".") player_deck = draft_player_deck(client, "fire", "dark fantasy", rng=rng) enemy_deck = draft_enemy_deck(client, "earth", "dark fantasy", rng=rng) run_text_duel(player_deck=player_deck, enemy_deck=enemy_deck, rng=rng, boss_client=boss_client_from_env()) if __name__ == "__main__": # pragma: no cover main()