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 +8 -0
- ptg/abilities/__init__.py +6 -0
- ptg/abilities/resolver.py +44 -0
- ptg/actions/__init__.py +9 -0
- ptg/actions/activate_ability.py +57 -0
- ptg/actions/base.py +18 -0
- ptg/actions/declare_attack.py +56 -0
- ptg/actions/declare_defense.py +52 -0
- ptg/actions/play_card.py +135 -0
- ptg/actions/registry.py +22 -0
- ptg/api.py +41 -0
- ptg/core/__init__.py +18 -0
- ptg/core/enums.py +66 -0
- ptg/core/types.py +61 -0
- ptg/effects/__init__.py +10 -0
- ptg/effects/base.py +61 -0
- ptg/effects/buff_all.py +32 -0
- ptg/effects/buff_card.py +26 -0
- ptg/effects/damage_card.py +24 -0
- ptg/effects/damage_player.py +25 -0
- ptg/effects/destroy_card.py +23 -0
- ptg/effects/draw.py +34 -0
- ptg/effects/heal_player.py +25 -0
- ptg/effects/mill.py +37 -0
- ptg/effects/recruit.py +54 -0
- ptg/effects/registry.py +22 -0
- ptg/effects/return_to_hand.py +33 -0
- ptg/effects/spawn_creature.py +48 -0
- ptg/effects/spend_mana.py +24 -0
- ptg/engine/__init__.py +18 -0
- ptg/engine/combat.py +93 -0
- ptg/engine/event_bus.py +37 -0
- ptg/engine/game.py +128 -0
- ptg/engine/player.py +89 -0
- ptg/engine/spawn.py +71 -0
- ptg/engine/targeting.py +42 -0
- ptg/engine/types.py +99 -0
- ptg/io/__init__.py +10 -0
- ptg/io/deck_builder.py +38 -0
- ptg/io/yaml_loader.py +200 -0
- ptg-0.1.0.dist-info/METADATA +813 -0
- ptg-0.1.0.dist-info/RECORD +45 -0
- ptg-0.1.0.dist-info/WHEEL +5 -0
- ptg-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
- ptg-0.1.0.dist-info/top_level.txt +1 -0
ptg/__init__.py
ADDED
|
@@ -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)
|
ptg/actions/__init__.py
ADDED
|
@@ -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
|
ptg/actions/play_card.py
ADDED
|
@@ -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
|
ptg/actions/registry.py
ADDED
|
@@ -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)
|