ptg 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ptg/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """PTG — Python Trading Card Game engine.
2
+
3
+ Usage:
4
+ from ptg.api import Game, Player, CardDefinition, ...
5
+ """
6
+
7
+ # Keep __init__.py light to avoid circular imports during testing.
8
+ # For the single-import public API, use: from ptg.api import ...
@@ -0,0 +1,6 @@
1
+ from .resolver import register_card_triggers, unregister_card_triggers
2
+
3
+ __all__ = [
4
+ "register_card_triggers",
5
+ "unregister_card_triggers",
6
+ ]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from ..engine.event_bus import GameEvent, EventBus
4
+ from ..engine.spawn import process_spawns, process_leave_events
5
+ from ..core.types import CardID
6
+ from ..engine.types import CardDefinition
7
+ from ..core.enums import AbilityType
8
+ from ..engine.targeting import resolve_target
9
+
10
+ if TYPE_CHECKING:
11
+ from ..engine.game import GameState
12
+
13
+ # Resolves abilities that occur automatically when a condition is met in the game.
14
+ # They require no player action or mana expenditure at that time.
15
+
16
+ def register_card_triggers(bus: EventBus, card_id: CardID, card_def: CardDefinition) -> None:
17
+ """Suscribe card alterations of type "triggered" to the event bus based on their defined triggers.
18
+ This function iterates through the card's effects and their alterations.
19
+ """
20
+ for ability in (card_def.abilities or []):
21
+ if ability.ability_type != AbilityType.TRIGGERED:
22
+ continue
23
+ for alteration in ability.alterations:
24
+ trigger = alteration.start_trigger
25
+
26
+ def make_listener(trigger, alteration, card_id):
27
+ def listener(event, state):
28
+ if event.type != trigger:
29
+ return state
30
+ resolved = resolve_target(
31
+ state, alteration.target,
32
+ source_card_id=card_id,
33
+ )
34
+ if resolved is None:
35
+ return state
36
+ state = alteration.effect.apply(state, resolved, **alteration.params)
37
+ state = process_leave_events(state, bus)
38
+ return process_spawns(state, bus)
39
+ return listener
40
+
41
+ bus.subscribe(trigger, make_listener(trigger, alteration, card_id), card_id)
42
+
43
+ def unregister_card_triggers(bus: EventBus, card_id: CardID) -> None:
44
+ bus.unsubscribe_card(card_id)
@@ -0,0 +1,9 @@
1
+ from .registry import register_action, get_action, list_actions
2
+ from .base import Action
3
+
4
+ __all__ = [
5
+ "register_action",
6
+ "get_action",
7
+ "list_actions",
8
+ "Action",
9
+ ]
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from ..engine.event_bus import EventBus, GameEvent, EventType
4
+ from ..engine.spawn import process_spawns, process_leave_events
5
+ from ..engine.targeting import resolve_target
6
+ from ..core.types import PlayerID, CardID
7
+ from ..effects.spend_mana import SpendManaEffect
8
+ from ..core.enums import AbilityType
9
+ from .base import Action
10
+ from .registry import register_action
11
+
12
+ if TYPE_CHECKING:
13
+ from ..engine.game import GameState
14
+
15
+ @register_action("activate_ability")
16
+ class ActivateAbilityAction(Action):
17
+ def validate(self, state: GameState, player_id: PlayerID, card_id: CardID, ability_index: int) -> None:
18
+ """Validate if the ability can be activated."""
19
+ card = state.all_cards[card_id]
20
+ ability = card.card_definition.abilities[ability_index]
21
+
22
+ if ability.ability_type != AbilityType.ACTIVATED:
23
+ raise ValueError("The specified ability is not an activated ability.")
24
+
25
+ if card_id not in state.battlefield[player_id]:
26
+ raise ValueError("The card is not on the battlefield for this player.")
27
+
28
+ player = state.players[player_id]
29
+ if not player.mana_pool.can_afford(ability.mana_requirement):
30
+ raise ValueError("Not enough mana to activate the ability.")
31
+
32
+ def execute(self, state: GameState, bus: EventBus, player_id: PlayerID, card_id: CardID, ability_index: int) -> GameState:
33
+ """Execute the activation of the ability."""
34
+ card = state.all_cards[card_id]
35
+ ability = card.card_definition.abilities[ability_index]
36
+
37
+ # Pay the mana cost
38
+ state = SpendManaEffect().apply(state, player_id, ability.mana_requirement)
39
+
40
+ # Apply the ability's alterations
41
+ for alteration in ability.alterations:
42
+ resolved = resolve_target(
43
+ state, alteration.target,
44
+ source_card_id=card_id,
45
+ source_player_id=player_id,
46
+ )
47
+ if resolved is None:
48
+ continue
49
+ state = alteration.effect.apply(state, resolved, **alteration.params)
50
+ state = process_leave_events(state, bus)
51
+ state = process_spawns(state, bus)
52
+
53
+ # Emit an event for the ability activation
54
+ event = GameEvent(type=EventType.ON_ABILITY_ACTIVATED, source=card_id, target=None, context={})
55
+ state = bus.emit(event, state)
56
+
57
+ return state
ptg/actions/base.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+ from abc import ABC, abstractmethod
3
+ from typing import TYPE_CHECKING
4
+ from ..engine.event_bus import EventBus
5
+
6
+ if TYPE_CHECKING:
7
+ from ..engine.game import GameState
8
+
9
+ class Action(ABC):
10
+ @abstractmethod
11
+ def validate(self, state: GameState, **kwargs) -> None: ...
12
+
13
+ @abstractmethod
14
+ def execute(self, state: GameState, bus: EventBus, **kwargs) -> GameState: ...
15
+
16
+ def __call__(self, state: GameState, bus: EventBus, **kwargs) -> GameState:
17
+ self.validate(state, **kwargs)
18
+ return self.execute(state, bus, **kwargs)
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from ..engine.event_bus import EventBus, GameEvent, EventType
4
+ from ..core.types import CardID, PlayerID
5
+ from .base import Action
6
+ from .registry import register_action
7
+ from ..engine.types import CombatGroup
8
+ from dataclasses import replace
9
+
10
+ if TYPE_CHECKING:
11
+ from ..engine.game import GameState
12
+
13
+ @register_action("declare_attack")
14
+ class DeclareAttackAction(Action):
15
+ def validate(self, state: GameState, player_id: PlayerID, attacker_id: CardID) -> None:
16
+ """Validate that the attack can be declared."""
17
+ # Check if the attacker is on the battlefield and belongs to the player
18
+ if attacker_id not in state.battlefield.get(player_id, []):
19
+ raise ValueError(f"Card {attacker_id} is not on the battlefield for player {player_id}.")
20
+
21
+ # Check if the card is alive and has not attacked this turn
22
+ attacker_card = state.all_cards[attacker_id]
23
+ if not attacker_card.is_alive:
24
+ raise ValueError(f"Card {attacker_id} is not alive and cannot attack.")
25
+ if attacker_card.attacked_this_turn:
26
+ raise ValueError(f"Card {attacker_id} has already attacked this turn.")
27
+
28
+ # Card is not in pending combats
29
+ for combat in state.pending_combats:
30
+ if combat.attacker_id == attacker_id:
31
+ raise ValueError(f"Card {attacker_id} is already declared in a pending combat.")
32
+
33
+ def execute(self, state: GameState, bus: EventBus, player_id: PlayerID, attacker_id: CardID) -> GameState:
34
+ """Execute the attack declaration."""
35
+ # Create a new combat group with no blockers
36
+ new_combat = CombatGroup(
37
+ attacker_id=attacker_id,
38
+ blocker_ids=[],
39
+ attacker_player_id=player_id,
40
+ defender_player_id=next(pid for pid in state.players if pid != player_id) #Assuming a two-player game
41
+ )
42
+ # Add the new combat group to the pending combats
43
+ new_pending_combats = state.pending_combats + [new_combat]
44
+
45
+ # Mark the attacker as having attacked this turn
46
+ updated_attacker_card = state.all_cards[attacker_id]
47
+ updated_attacker_card = replace(updated_attacker_card, attacked_this_turn=True)
48
+
49
+ # Emit ON_ATTACK_DECLARED event
50
+ state = bus.emit(GameEvent(
51
+ type=EventType.ON_ATTACK_DECLARED,
52
+ source=attacker_id, target=None,
53
+ context={"attacker": updated_attacker_card}
54
+ ), state)
55
+
56
+ return replace(state, pending_combats=new_pending_combats, all_cards={**state.all_cards, attacker_id: updated_attacker_card})
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from ..engine.event_bus import EventBus, GameEvent, EventType
4
+ from ..core.types import CardID, PlayerID
5
+ from .base import Action
6
+ from .registry import register_action
7
+ from dataclasses import replace
8
+
9
+ if TYPE_CHECKING:
10
+ from ..engine.game import GameState
11
+
12
+ @register_action("declare_defense")
13
+ class DeclareDefenseAction(Action):
14
+ def validate(self, state: GameState, player_id: PlayerID, blocker_id: CardID, combat_index: int) -> None:
15
+ """Validate that the defense can be declared."""
16
+ # Check if the combat index is valid
17
+ if combat_index < 0 or combat_index >= len(state.pending_combats):
18
+ raise ValueError(f"Combat index {combat_index} is out of range.")
19
+
20
+ combat = state.pending_combats[combat_index]
21
+
22
+ # Check if the blocker is on the battlefield and belongs to the player
23
+ if blocker_id not in state.battlefield.get(player_id, []):
24
+ raise ValueError(f"Card {blocker_id} is not on the battlefield for player {player_id}.")
25
+
26
+ # Check if the combat_group's defender is the player declaring the defense
27
+ if combat.defender_player_id != player_id:
28
+ raise ValueError(f"Player {player_id} is not the defender in combat index {combat_index}.")
29
+
30
+ # The blocker is not already blocking in this combat
31
+ if blocker_id in combat.blocker_ids:
32
+ raise ValueError(f"Card {blocker_id} is already declared as a blocker in combat index {combat_index}.")
33
+
34
+ # Check if the blocker is alive
35
+ blocker_card = state.all_cards[blocker_id]
36
+ if not blocker_card.is_alive:
37
+ raise ValueError(f"Card {blocker_id} is not alive and cannot block.")
38
+
39
+ def execute(self, state: GameState, bus: EventBus, player_id: PlayerID, blocker_id: CardID, combat_index: int) -> GameState:
40
+ """Execute the defense declaration."""
41
+ # Adds blocker_id to the combat_group's blocker_ids
42
+ combat = state.pending_combats[combat_index]
43
+ updated_combat = replace(combat, blocker_ids=combat.blocker_ids + [blocker_id])
44
+ state = replace(state, pending_combats=state.pending_combats[:combat_index] + [updated_combat] + state.pending_combats[combat_index+1:])
45
+
46
+ state = bus.emit(GameEvent(
47
+ type=EventType.ON_DEFENSE_DECLARED,
48
+ source=blocker_id, target=combat.attacker_id,
49
+ context={"blocker": state.all_cards[blocker_id], "attacker": state.all_cards[combat.attacker_id]}
50
+ ), state)
51
+
52
+ return state
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+ from dataclasses import replace
3
+ from typing import TYPE_CHECKING
4
+ from ..engine.event_bus import EventBus, GameEvent, EventType
5
+ from ..engine.spawn import process_spawns, process_leave_events
6
+ from ..engine.targeting import resolve_target
7
+ from ..core.types import CardID, PlayerID, ManaRequirement, ManaPool
8
+ from ..core.enums import CardType
9
+ from ..effects.spend_mana import SpendManaEffect
10
+ from ..abilities.resolver import register_card_triggers
11
+ from .base import Action
12
+ from .registry import register_action
13
+
14
+ if TYPE_CHECKING:
15
+ from ..engine.game import GameState
16
+
17
+ @register_action("play_card")
18
+ class PlayCardAction(Action):
19
+ def validate(self, state: GameState, player_id: PlayerID, card_id: CardID) -> None:
20
+ # 1. The card is in the player's hand
21
+ if card_id not in state.hands.get(player_id, []):
22
+ raise ValueError(f"Card {card_id} is not in the hand of player {player_id}.")
23
+
24
+ card = state.all_cards[card_id]
25
+ card_def = card.card_definition
26
+
27
+ # 2. Can afford the mana cost of the card (if it's not a mana card)
28
+ if card_def.type != CardType.MANA:
29
+ mana_req = ManaRequirement(amounts=card_def.mana)
30
+ player = state.players[player_id]
31
+ if not player.mana_pool.can_afford(mana_req):
32
+ raise ValueError(f"Not enough mana to play {card_def.name}.")
33
+
34
+ def execute(self, state: GameState, bus: EventBus, player_id: PlayerID, card_id: CardID) -> GameState:
35
+ card = state.all_cards[card_id]
36
+ card_def = card.card_definition
37
+
38
+ # 1. Remove the card from the player's hand
39
+ hand = list(state.hands.get(player_id, []))
40
+ hand.remove(card_id)
41
+ state = replace(state, hands={**state.hands, player_id: hand})
42
+
43
+ # 2. Pay the mana cost if it's not a mana card
44
+ if card_def.type != CardType.MANA:
45
+ mana_req = ManaRequirement(amounts=card_def.mana)
46
+ state = SpendManaEffect().apply(state, player_id, mana_req)
47
+
48
+ # 3. Resolve by card type
49
+ if card_def.type == CardType.CREATURE:
50
+ return self._play_creature(state, bus, player_id, card_id)
51
+ elif card_def.type == CardType.SPELL:
52
+ return self._play_spell(state, bus, player_id, card_id)
53
+ elif card_def.type == CardType.MANA:
54
+ return self._play_mana(state, bus, player_id, card_id)
55
+ return state
56
+
57
+ def _play_creature(self, state: GameState, bus: EventBus, player_id: PlayerID, card_id: CardID)-> GameState:
58
+ card = state.all_cards[card_id]
59
+
60
+ # Add the card to the battlefield
61
+ bf = list(state.battlefield.get(player_id, []))
62
+ bf.append(card_id)
63
+ state = replace(state, battlefield={**state.battlefield, player_id: bf})
64
+
65
+ # Subscribe to card triggers
66
+ register_card_triggers(bus, card_id, card.card_definition)
67
+
68
+ # Send an event that the card has entered the battlefield
69
+ state = bus.emit(GameEvent(
70
+ type=EventType.ON_CARD_ENTER_BATTLEFIELD,
71
+ source=card_id, target=None,
72
+ context={}
73
+ ), state)
74
+
75
+ return state
76
+
77
+ def _play_spell(self, state: GameState, bus: EventBus, player_id: PlayerID, card_id: CardID)-> GameState:
78
+ card = state.all_cards[card_id]
79
+
80
+ # Apply the spell's effects immediately
81
+ for ability in (card.card_definition.abilities or []):
82
+ for alteration in ability.alterations:
83
+ resolved = resolve_target(
84
+ state, alteration.target,
85
+ source_card_id=card_id,
86
+ source_player_id=player_id,
87
+ )
88
+ if resolved is None:
89
+ continue
90
+ state = alteration.effect.apply(state, resolved, **alteration.params)
91
+ state = process_leave_events(state, bus)
92
+ state = process_spawns(state, bus)
93
+
94
+ # To the graveyard after resolution
95
+ gy = list(state.graveyards.get(player_id, []))
96
+ gy.append(card_id)
97
+ state = replace(state, graveyards={**state.graveyards, player_id: gy})
98
+ state = bus.emit(GameEvent(
99
+ type=EventType.ON_CARD_ENTER_GRAVEYARD,
100
+ source=card_id, target=None,
101
+ context={"reason": "spell_resolution"}
102
+ ), state)
103
+
104
+ return state
105
+
106
+ def _play_mana(self, state: GameState, bus: EventBus, player_id: PlayerID, card_id: CardID)-> GameState:
107
+ card = state.all_cards[card_id]
108
+ player = state.players[player_id]
109
+
110
+ # Add the mana to the player's mana pool
111
+ new_amounts = dict(player.mana_pool.amounts)
112
+ for mana_type, amount in card.card_definition.mana.items():
113
+ new_amounts[mana_type] = new_amounts.get(mana_type, 0) + amount
114
+
115
+ updated_player = replace(player, mana_pool=ManaPool(amounts=new_amounts))
116
+ state = replace(state, players={**state.players, player_id: updated_player})
117
+
118
+ # To the graveyard after resolution
119
+ gy = list(state.graveyards.get(player_id, []))
120
+ gy.append(card_id)
121
+ state = replace(state, graveyards={**state.graveyards, player_id: gy})
122
+ state = bus.emit(GameEvent(
123
+ type=EventType.ON_CARD_ENTER_GRAVEYARD,
124
+ source=card_id, target=None,
125
+ context={"reason": "mana_card_resolution"}
126
+ ), state)
127
+
128
+ # Emit an event that the player gained mana
129
+ state = bus.emit(GameEvent(
130
+ type=EventType.ON_PLAYER_MANA_GAINED,
131
+ source=card_id, target=player_id,
132
+ context={"amounts": card.card_definition.mana}
133
+ ), state)
134
+
135
+ return state
@@ -0,0 +1,22 @@
1
+ from .base import Action
2
+
3
+ ACTION_REGISTRY: dict[str, type] = {}
4
+
5
+ def register_action(name: str):
6
+ """Register an action in the global registry."""
7
+ def decorator(cls: Action):
8
+ if name in ACTION_REGISTRY:
9
+ raise ValueError(f"Action with name '{name}' is already registered.")
10
+ ACTION_REGISTRY[name] = cls
11
+ return cls
12
+ return decorator
13
+
14
+ def get_action(name: str) -> Action:
15
+ """Retrieve an action from the global registry by name."""
16
+ if name not in ACTION_REGISTRY:
17
+ raise ValueError(f"Action with name '{name}' is not registered.")
18
+ return ACTION_REGISTRY[name]
19
+
20
+ def list_actions() -> list[str]:
21
+ """List all registered action names."""
22
+ return list(ACTION_REGISTRY.keys())
ptg/api.py ADDED
@@ -0,0 +1,41 @@
1
+ """Public API for the PTG engine. Import everything from here."""
2
+
3
+ from .core.enums import CardType, ManaType, PhaseType, EventType, AbilityType, TargetSpec
4
+ from .core.types import ManaRequirement, ManaPool, ManaCost
5
+ from .engine.game import Game, GameState
6
+ from .engine.player import Player
7
+ from .engine.types import CardDefinition, CardInstance, CardAbility, AlterationData
8
+ from .actions.play_card import PlayCardAction
9
+ from .actions.declare_attack import DeclareAttackAction
10
+ from .actions.declare_defense import DeclareDefenseAction
11
+ from .actions.activate_ability import ActivateAbilityAction
12
+ from .io.yaml_loader import load_card, load_cards_from_dir
13
+ from .io.deck_builder import build_catalog, build_deck, load_deck
14
+
15
+ __all__ = [
16
+ "CardType",
17
+ "ManaType",
18
+ "PhaseType",
19
+ "EventType",
20
+ "AbilityType",
21
+ "TargetSpec",
22
+ "ManaRequirement",
23
+ "ManaPool",
24
+ "ManaCost",
25
+ "Game",
26
+ "GameState",
27
+ "Player",
28
+ "CardDefinition",
29
+ "CardInstance",
30
+ "CardAbility",
31
+ "AlterationData",
32
+ "PlayCardAction",
33
+ "DeclareAttackAction",
34
+ "DeclareDefenseAction",
35
+ "ActivateAbilityAction",
36
+ "load_card",
37
+ "load_cards_from_dir",
38
+ "build_catalog",
39
+ "build_deck",
40
+ "load_deck",
41
+ ]
ptg/core/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from .enums import CardType, ManaType, PhaseType, EventType, EffectType, AbilityType, TargetSpec
2
+ from .types import ManaCost, ManaRequirement, ManaPool, ActionData, CardID, PlayerID
3
+
4
+ __all__ = [
5
+ "CardType",
6
+ "ManaType",
7
+ "PhaseType",
8
+ "EventType",
9
+ "EffectType",
10
+ "AbilityType",
11
+ "TargetSpec",
12
+ "ManaCost",
13
+ "ManaRequirement",
14
+ "ManaPool",
15
+ "ActionData",
16
+ "CardID",
17
+ "PlayerID",
18
+ ]
ptg/core/enums.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+ from enum import Enum
3
+
4
+ class CardType(Enum):
5
+ SPELL = "spell"
6
+ CREATURE = "creature"
7
+ MANA = "mana"
8
+
9
+ class ManaType(Enum):
10
+ EARTH = "earth"
11
+ FIRE = "fire"
12
+ WATER = "water"
13
+ AIR = "air"
14
+ ANY = "any"
15
+
16
+ class PhaseType(Enum):
17
+ DRAW = "draw"
18
+ MAIN = "main"
19
+ ATTACK = "attack"
20
+ DEFENSE = "defense"
21
+ END = "end"
22
+
23
+ class EventType(Enum):
24
+ ON_CARD_DRAW = "on_card_draw"
25
+ ON_CARD_RECEIVE_DAMAGE = "on_card_receive_damage"
26
+ ON_CARD_DEATH = "on_card_death"
27
+ ON_CARD_HEALED = "on_card_healed"
28
+ ON_CARD_ATTACK = "on_card_attack"
29
+ ON_CARD_DEFENSE = "on_card_defense"
30
+
31
+ ON_CARD_ENTER_BATTLEFIELD = "on_card_enter_battlefield"
32
+ ON_CARD_ENTER_GRAVEYARD = "on_card_enter_graveyard"
33
+ ON_CARD_LEAVE_BATTLEFIELD = "on_card_leave_battlefield"
34
+ ON_CARD_LEAVE_GRAVEYARD = "on_card_leave_graveyard"
35
+
36
+ ON_PLAYER_RECEIVE_DAMAGE = "on_player_receive_damage"
37
+ ON_PLAYER_DEATH = "on_player_death"
38
+ ON_PLAYER_MANA_SPENT = "on_player_mana_spent"
39
+ ON_PLAYER_MANA_GAINED = "on_player_mana_gained"
40
+ ON_PLAYER_HEALED = "on_player_healed"
41
+
42
+ ON_TURN_START = "on_turn_start"
43
+ ON_ATTACK_DECLARED = "on_attack_declared"
44
+ ON_DEFENSE_DECLARED = "on_defense_declared"
45
+ ON_COMBAT_RESOLVED = "on_combat_resolved"
46
+ ON_TURN_END = "on_turn_end"
47
+ ON_ABILITY_ACTIVATED = "on_ability_activated"
48
+
49
+ class EffectType(Enum):
50
+ CARD = "card"
51
+ PLAYER = "player"
52
+
53
+ class AbilityType(Enum):
54
+ TRIGGERED = "triggered" # An ability that activates automatically when a specific condition is met.
55
+ ACTIVATED = "activated" # An ability that requires a player to take an action, often involving a cost, to activate it.
56
+
57
+ class TargetSpec(Enum):
58
+ SELF = "self" # the card that has the ability or effect
59
+ OWNER = "owner" # the player who controls the card
60
+ OPPONENT = "opponent" # the opposing player
61
+
62
+ class ZoneType(Enum):
63
+ HAND = "hand"
64
+ DECK = "deck"
65
+ BATTLEFIELD = "battlefield"
66
+ GRAVEYARD = "graveyard"
ptg/core/types.py ADDED
@@ -0,0 +1,61 @@
1
+ from .enums import ManaType
2
+ from typing import Any, NewType
3
+ from dataclasses import dataclass, field
4
+
5
+ @dataclass
6
+ class ManaCost:
7
+ """Represents a single mana cost for playing a card or activating an effect."""
8
+ type: ManaType
9
+ amount: int
10
+
11
+ @dataclass
12
+ class ManaRequirement:
13
+ """Defines the mana requirements for playing a card or activating an effect."""
14
+ amounts: dict[ManaType, int] = field(default_factory=dict)
15
+
16
+ @dataclass
17
+ class ManaPool:
18
+ """Represents a player's mana pool, which tracks the amount of each
19
+ type of mana available. Cannot contain "ANY" type mana.
20
+ """
21
+ amounts: dict[ManaType, int] = field(default_factory=dict)
22
+
23
+ def can_afford(self, requirement: ManaRequirement) -> bool:
24
+ any_needed = requirement.amounts.get(ManaType.ANY, 0)
25
+ remaining = dict(self.amounts)
26
+
27
+ for t, amt in requirement.amounts.items():
28
+ if t == ManaType.ANY:
29
+ continue
30
+ if remaining.get(t, 0) < amt:
31
+ return False
32
+ remaining[t] -= amt
33
+
34
+ return sum(remaining.values()) >= any_needed
35
+
36
+ def spend(self, requirement: ManaRequirement) -> 'ManaPool':
37
+ new_amounts = dict(self.amounts)
38
+ any_needed = requirement.amounts.get(ManaType.ANY, 0)
39
+
40
+ for t, amt in requirement.amounts.items():
41
+ if t == ManaType.ANY:
42
+ continue
43
+ new_amounts[t] = new_amounts.get(t, 0) - amt
44
+
45
+ for t in list(new_amounts.keys()):
46
+ if any_needed <= 0:
47
+ break
48
+ if new_amounts[t] > 0:
49
+ spend = min(new_amounts[t], any_needed)
50
+ new_amounts[t] -= spend
51
+ any_needed -= spend
52
+
53
+ return ManaPool(amounts=new_amounts)
54
+
55
+ @dataclass
56
+ class ActionData:
57
+ name: str
58
+ params: dict[str, Any]
59
+
60
+ CardID = NewType('CardID', str)
61
+ PlayerID = NewType('PlayerID', str)
@@ -0,0 +1,10 @@
1
+ from .base import Effect
2
+ from .registry import EFFECT_REGISTRY, register_effect, get_effect, list_effects
3
+
4
+ __all__ = [
5
+ "Effect",
6
+ "EFFECT_REGISTRY",
7
+ "register_effect",
8
+ "get_effect",
9
+ "list_effects",
10
+ ]