Spaces:
Running
Running
| from dataclasses import dataclass, field | |
| from random import Random | |
| from budget import Card | |
| from primitives import Effect | |
| STARTING_HP = 20 | |
| MAX_ENERGY = 5 | |
| HALF_HP = STARTING_HP // 2 | |
| class PlayerState: | |
| name: str | |
| deck: list[Card] | |
| hand: list[Card] = field(default_factory=list) | |
| discard: list[Card] = field(default_factory=list) | |
| hp: int = STARTING_HP | |
| energy: int = 0 | |
| block: int = 0 | |
| ward: int = 0 | |
| shield_charge: int = 0 | |
| fatigue: int = 1 | |
| vulnerable: int = 0 | |
| vulnerable_turns: int = 0 | |
| weak: int = 0 | |
| weak_turns: int = 0 | |
| cards_played_this_turn: int = 0 | |
| class PendingEffect: | |
| primitive_id: str | |
| owner: str | |
| target: str | |
| amount: int | |
| delay: int = 0 | |
| duration: int = 0 | |
| class DuelState: | |
| player: PlayerState | |
| enemy: PlayerState | |
| pending: list[PendingEffect] = field(default_factory=list) | |
| round_number: int = 0 | |
| forced_second: str | None = None | |
| # Create a player state from an ordered deck. | |
| def create_player(name: str, deck: list[Card] | tuple[Card, ...]) -> PlayerState: | |
| return PlayerState(name=name, deck=list(deck)) | |
| # Return the opposing player state. | |
| def opponent(state: DuelState, actor: PlayerState) -> PlayerState: | |
| return state.enemy if actor is state.player else state.player | |
| # Draw cards, applying escalating fatigue on deck-out. | |
| def draw_cards(player: PlayerState, count: int) -> None: | |
| for _ in range(count): | |
| if player.deck: | |
| player.hand.append(player.deck.pop(0)) | |
| else: | |
| player.hp -= player.fatigue | |
| player.fatigue += 1 | |
| # Start a paired round with energy refill and one draw each. | |
| def start_round(state: DuelState, rng: Random | None = None) -> tuple[str, str]: | |
| state.round_number += 1 | |
| for player in (state.player, state.enemy): | |
| begin_turn(player, state.round_number) | |
| draw_cards(player, 1) | |
| advance_pending(state) | |
| return round_order(state, rng or Random()) | |
| # Prepare one player for the current round. | |
| def begin_turn(player: PlayerState, round_number: int) -> None: | |
| player.energy = min(MAX_ENERGY, round_number) | |
| player.block = 0 | |
| player.cards_played_this_turn = 0 | |
| tick_statuses(player) | |
| # Tick one-turn status durations. | |
| def tick_statuses(player: PlayerState) -> None: | |
| if player.vulnerable_turns > 0: | |
| player.vulnerable_turns -= 1 | |
| if player.vulnerable_turns == 0: | |
| player.vulnerable = 0 | |
| if player.weak_turns > 0: | |
| player.weak_turns -= 1 | |
| if player.weak_turns == 0: | |
| player.weak = 0 | |
| # Choose action order for the current paired round. | |
| def round_order(state: DuelState, rng: Random) -> tuple[str, str]: | |
| if state.forced_second == state.player.name: | |
| state.forced_second = None | |
| return (state.enemy.name, state.player.name) | |
| if state.forced_second == state.enemy.name: | |
| state.forced_second = None | |
| return (state.player.name, state.enemy.name) | |
| names = (state.player.name, state.enemy.name) | |
| return names if rng.randint(0, 1) == 0 else (names[1], names[0]) | |
| # Play a card from hand by index. | |
| def play_card_from_hand(state: DuelState, actor: PlayerState, hand_index: int) -> Card: | |
| card = actor.hand.pop(hand_index) | |
| play_card(state, actor, card) | |
| actor.discard.append(card) | |
| return card | |
| # Resolve a card if the actor can pay its energy cost. | |
| def play_card(state: DuelState, actor: PlayerState, card: Card) -> None: | |
| if card.cost > actor.energy: | |
| raise ValueError("not enough energy") | |
| actor.energy -= card.cost | |
| actor.cards_played_this_turn += 1 | |
| target = opponent(state, actor) | |
| for effect in card.effects: | |
| apply_effect(state, actor, target, effect) | |
| # Apply one deterministic effect. | |
| def apply_effect(state: DuelState, actor: PlayerState, target: PlayerState, effect: Effect) -> None: | |
| match effect.primitive_id: | |
| case "deal": | |
| deal_damage(actor, target, effect.amount) | |
| case "burn": | |
| state.pending.append(PendingEffect("burn", actor.name, target.name, effect.amount, delay=1, duration=effect.duration)) | |
| case "bomb": | |
| state.pending.append(PendingEffect("bomb", actor.name, target.name, effect.amount, delay=effect.delay)) | |
| case "block": | |
| actor.block += effect.amount | |
| case "ward": | |
| actor.ward += effect.amount | |
| case "weak": | |
| target.weak += effect.amount | |
| target.weak_turns = max(target.weak_turns, effect.duration) | |
| case "draw": | |
| draw_cards(actor, effect.amount) | |
| case "energy": | |
| actor.energy += effect.amount | |
| case "initiative": | |
| state.forced_second = target.name | |
| case "multi_hit": | |
| for _ in range(effect.hits): | |
| deal_damage(actor, target, effect.amount) | |
| case "vulnerable": | |
| target.vulnerable += effect.amount | |
| target.vulnerable_turns = max(target.vulnerable_turns, effect.duration) | |
| case "conditional": | |
| damage = conditional_damage(target, effect.amount) | |
| if damage > 0: | |
| deal_damage(actor, target, damage) | |
| case "scaling": | |
| deal_damage(actor, target, scaling_damage(actor, effect)) | |
| # Deal damage through weak, vulnerable, ward, and block. | |
| def deal_damage(actor: PlayerState, target: PlayerState, amount: int) -> int: | |
| damage = max(0, amount - actor.weak) | |
| if damage == 0: | |
| return 0 | |
| damage += target.vulnerable | |
| if target.ward > 0: | |
| if damage >= target.ward: | |
| target.ward = 0 | |
| return 0 | |
| blocked = min(target.block, damage) | |
| target.shield_charge += blocked // 2 | |
| target.block -= blocked | |
| target.hp -= damage - blocked | |
| return damage - blocked | |
| # Return scaling damage and spend shield charge when used. | |
| def scaling_damage(actor: PlayerState, effect: Effect) -> int: | |
| if effect.condition == "shield_charge": | |
| damage = effect.amount + actor.shield_charge | |
| actor.shield_charge = 0 | |
| return damage | |
| return effect.amount + actor.cards_played_this_turn | |
| # Return missing-HP-scaled conditional damage. | |
| def conditional_damage(target: PlayerState, amount: int) -> int: | |
| missing_hp = max(0, STARTING_HP - target.hp) | |
| return amount * missing_hp // STARTING_HP | |
| # Advance all deferred effects by one schedule tick. | |
| def advance_pending(state: DuelState) -> None: | |
| remaining: list[PendingEffect] = [] | |
| for effect in state.pending: | |
| if effect.delay > 0: | |
| effect.delay -= 1 | |
| remaining.append(effect) | |
| elif effect.primitive_id == "burn": | |
| deal_damage(named_player(state, effect.owner), named_player(state, effect.target), effect.amount) | |
| effect.duration -= 1 | |
| if effect.duration > 0: | |
| remaining.append(effect) | |
| elif effect.primitive_id == "bomb": | |
| deal_damage(named_player(state, effect.owner), named_player(state, effect.target), effect.amount) | |
| state.pending = remaining | |
| # Return a player by name. | |
| def named_player(state: DuelState, name: str) -> PlayerState: | |
| if state.player.name == name: | |
| return state.player | |
| if state.enemy.name == name: | |
| return state.enemy | |
| raise KeyError(name) | |
| # Return whether either side has won. | |
| def winner(state: DuelState) -> str | None: | |
| player_dead = state.player.hp <= 0 | |
| enemy_dead = state.enemy.hp <= 0 | |
| if player_dead and enemy_dead: | |
| return "draw" | |
| if enemy_dead: | |
| return state.player.name | |
| if player_dead: | |
| return state.enemy.name | |
| return None | |