tabras / game.py
vvennelakanti's picture
Build Tabras card duel prototype
6bbf552
Raw
History Blame Contribute Delete
7.64 kB
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