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 @dataclass 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 @dataclass class PendingEffect: primitive_id: str owner: str target: str amount: int delay: int = 0 duration: int = 0 @dataclass 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