Spaces:
Running
Running
| 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() | |