kaboom-engine 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.
- kaboom/__init__.py +32 -0
- kaboom/cards/__init__.py +4 -0
- kaboom/cards/card.py +45 -0
- kaboom/exceptions.py +16 -0
- kaboom/game/__init__.py +10 -0
- kaboom/game/actions.py +46 -0
- kaboom/game/game_state.py +134 -0
- kaboom/game/phases.py +8 -0
- kaboom/game/reaction.py +169 -0
- kaboom/game/results.py +11 -0
- kaboom/game/turn.py +204 -0
- kaboom/game/validators.py +14 -0
- kaboom/players/__init__.py +4 -0
- kaboom/players/player.py +49 -0
- kaboom/powers/__init__.py +8 -0
- kaboom/powers/base.py +14 -0
- kaboom/powers/blind_swap.py +26 -0
- kaboom/powers/registry.py +12 -0
- kaboom/powers/see_and_swap.py +33 -0
- kaboom/powers/see_other.py +23 -0
- kaboom/powers/see_self.py +19 -0
- kaboom/version.py +2 -0
- kaboom_engine-0.1.0.dist-info/METADATA +191 -0
- kaboom_engine-0.1.0.dist-info/RECORD +27 -0
- kaboom_engine-0.1.0.dist-info/WHEEL +5 -0
- kaboom_engine-0.1.0.dist-info/licenses/LICENSE +11 -0
- kaboom_engine-0.1.0.dist-info/top_level.txt +1 -0
kaboom/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from .cards.card import Card, Suit, Rank
|
|
2
|
+
from .players.player import Player
|
|
3
|
+
from .game.game_state import GameState
|
|
4
|
+
from .game.turn import apply_action, close_reaction, get_valid_actions, is_game_over
|
|
5
|
+
from .game.actions import Draw, Discard, Replace, UsePower, CallKaboom
|
|
6
|
+
from .game.results import ActionResult
|
|
7
|
+
from .game.phases import GamePhase
|
|
8
|
+
from .game.reaction import react_discard_own_cards, react_discard_other_cards, ReactionResult
|
|
9
|
+
from .version import __version__
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Card",
|
|
13
|
+
"Suit",
|
|
14
|
+
"Rank",
|
|
15
|
+
"Player",
|
|
16
|
+
"GameState",
|
|
17
|
+
"apply_action",
|
|
18
|
+
"Draw",
|
|
19
|
+
"Discard",
|
|
20
|
+
"Replace",
|
|
21
|
+
"UsePower",
|
|
22
|
+
"CallKaboom",
|
|
23
|
+
"react_discard_own_cards",
|
|
24
|
+
"react_discard_other_cards",
|
|
25
|
+
"ReactionResult",
|
|
26
|
+
"GamePhase",
|
|
27
|
+
"ActionResult",
|
|
28
|
+
"close_reaction",
|
|
29
|
+
"get_valid_actions",
|
|
30
|
+
"is_game_over",
|
|
31
|
+
"__version__"
|
|
32
|
+
]
|
kaboom/cards/__init__.py
ADDED
kaboom/cards/card.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# kaboom/cards/card.py
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Suit(str, Enum):
|
|
7
|
+
SPADES = "♠"
|
|
8
|
+
HEARTS = "♥"
|
|
9
|
+
DIAMONDS = "♦"
|
|
10
|
+
CLUBS = "♣"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Rank(str, Enum):
|
|
14
|
+
A = "A"
|
|
15
|
+
TWO = "2"
|
|
16
|
+
THREE = "3"
|
|
17
|
+
FOUR = "4"
|
|
18
|
+
FIVE = "5"
|
|
19
|
+
SIX = "6"
|
|
20
|
+
SEVEN = "7"
|
|
21
|
+
EIGHT = "8"
|
|
22
|
+
NINE = "9"
|
|
23
|
+
TEN = "10"
|
|
24
|
+
J = "J"
|
|
25
|
+
Q = "Q"
|
|
26
|
+
K = "K"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Card:
|
|
31
|
+
rank: Rank
|
|
32
|
+
suit: Suit
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
return f"{self.rank.value}{self.suit.value}"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def score_value(self) -> int:
|
|
39
|
+
if self.rank == Rank.K and self.suit in {Suit.HEARTS, Suit.DIAMONDS}:
|
|
40
|
+
return 0
|
|
41
|
+
if self.rank == Rank.A:
|
|
42
|
+
return 1
|
|
43
|
+
if self.rank in {Rank.J, Rank.Q, Rank.K}:
|
|
44
|
+
return 10
|
|
45
|
+
return int(self.rank.value)
|
kaboom/exceptions.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# kaboom/exceptions.py
|
|
2
|
+
class KaboomError(Exception):
|
|
3
|
+
"""Base exception for Kaboom engine."""
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
class InvalidActionError(KaboomError):
|
|
7
|
+
"""Raised when a player attempts an invalid action."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class InvalidReactionError(KaboomError):
|
|
11
|
+
"""Raised when a reaction attempt is invalid."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class InvariantViolationError(KaboomError):
|
|
15
|
+
"""Raised when game state invariants are violated."""
|
|
16
|
+
pass
|
kaboom/game/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# kaboom/game/__init__.py
|
|
2
|
+
from .game_state import GameState
|
|
3
|
+
from .reaction import react_discard_own_cards, react_discard_other_cards, ReactionResult
|
|
4
|
+
from .actions import Draw, Discard, Replace, UsePower, CallKaboom
|
|
5
|
+
from .turn import apply_action, close_reaction
|
|
6
|
+
from .results import ActionResult
|
|
7
|
+
from .phases import GamePhase
|
|
8
|
+
|
|
9
|
+
__all__ = ["GameState", "react_discard_own_cards", "react_discard_other_cards", "ActionResult", "GamePhase",
|
|
10
|
+
"Draw", "Discard", "Replace", "UsePower", "CallKaboom", "apply_action", "close_reaction", "ReactionResult"]
|
kaboom/game/actions.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# kaboom/game/actions.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, Protocol
|
|
6
|
+
|
|
7
|
+
from kaboom.cards.card import Card
|
|
8
|
+
|
|
9
|
+
class Action(Protocol):
|
|
10
|
+
"""
|
|
11
|
+
Marker protocol for all turn actions.
|
|
12
|
+
"""
|
|
13
|
+
actor_id: int
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Draw(Action):
|
|
17
|
+
actor_id: int
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Discard(Action):
|
|
21
|
+
actor_id: int
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Replace(Action):
|
|
25
|
+
actor_id: int
|
|
26
|
+
target_index: int # index in player's hand
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class UsePower(Action):
|
|
30
|
+
actor_id: int
|
|
31
|
+
power_name: str
|
|
32
|
+
source_card: Card
|
|
33
|
+
|
|
34
|
+
# Power-specific payload (indices, player ids, etc.)
|
|
35
|
+
target_player_id: Optional[int] = None
|
|
36
|
+
target_card_index: Optional[int] = None
|
|
37
|
+
second_target_player_id: Optional[int] = None
|
|
38
|
+
second_target_card_index: Optional[int] = None
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class CallKaboom(Action):
|
|
42
|
+
actor_id: int
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class CloseReaction(Action):
|
|
46
|
+
actor_id: int
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# kaboom/game/game_state.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from kaboom.cards.card import Card
|
|
9
|
+
from kaboom.players.player import Player
|
|
10
|
+
from kaboom.exceptions import InvalidActionError
|
|
11
|
+
from kaboom.game.phases import GamePhase
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class GameState:
|
|
15
|
+
"""
|
|
16
|
+
Complete mutable state of a Kaboom game.
|
|
17
|
+
"""
|
|
18
|
+
players: List[Player]
|
|
19
|
+
deck: List[Card]
|
|
20
|
+
discard_pile: List[Card] = field(default_factory=list)
|
|
21
|
+
phase: GamePhase = GamePhase.TURN_DRAW
|
|
22
|
+
|
|
23
|
+
current_player_index: int = 0
|
|
24
|
+
round_number: int = 1
|
|
25
|
+
|
|
26
|
+
# Drawn card awaiting action
|
|
27
|
+
drawn_card: Optional[Card] = None
|
|
28
|
+
|
|
29
|
+
# Reaction state
|
|
30
|
+
reaction_rank: Optional[str] = None
|
|
31
|
+
reaction_initiator: Optional[int] = None
|
|
32
|
+
reaction_open: bool = False
|
|
33
|
+
|
|
34
|
+
# Kaboom
|
|
35
|
+
kaboom_called_by: Optional[int] = None
|
|
36
|
+
instant_winner: Optional[int] = None
|
|
37
|
+
|
|
38
|
+
def active_players(self) -> List[Player]:
|
|
39
|
+
return [p for p in self.players if p.active]
|
|
40
|
+
|
|
41
|
+
def current_player(self) -> Player:
|
|
42
|
+
return self.players[self.current_player_index]
|
|
43
|
+
|
|
44
|
+
# def advance_turn(self) -> None:
|
|
45
|
+
# """
|
|
46
|
+
# Move to next active player.
|
|
47
|
+
# """
|
|
48
|
+
# if self.kaboom_called_by is not None:
|
|
49
|
+
# return
|
|
50
|
+
|
|
51
|
+
# n = len(self.players)
|
|
52
|
+
# for _ in range(n):
|
|
53
|
+
# self.current_player_index = (self.current_player_index + 1) % n
|
|
54
|
+
# if self.players[self.current_player_index].active:
|
|
55
|
+
# break
|
|
56
|
+
|
|
57
|
+
# if self.current_player_index == 0:
|
|
58
|
+
# self.round_number += 1
|
|
59
|
+
|
|
60
|
+
def advance_turn(self) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Move to next player.
|
|
63
|
+
|
|
64
|
+
If Kaboom has been called, remaining players finish the round.
|
|
65
|
+
When the turn returns to the Kaboom caller, the game ends.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
n = len(self.players)
|
|
69
|
+
|
|
70
|
+
while True:
|
|
71
|
+
self.current_player_index = (self.current_player_index + 1) % n
|
|
72
|
+
|
|
73
|
+
player = self.players[self.current_player_index]
|
|
74
|
+
|
|
75
|
+
# If Kaboom was called and we reached the caller again → end game
|
|
76
|
+
if (
|
|
77
|
+
self.kaboom_called_by is not None
|
|
78
|
+
and player.id == self.kaboom_called_by
|
|
79
|
+
):
|
|
80
|
+
self.phase = GamePhase.GAME_OVER
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Skip inactive players (kaboom caller is inactive)
|
|
84
|
+
if player.active:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
if self.current_player_index == 0:
|
|
88
|
+
self.round_number += 1
|
|
89
|
+
|
|
90
|
+
def top_discard(self) -> Optional[Card]:
|
|
91
|
+
return self.discard_pile[-1] if self.discard_pile else None
|
|
92
|
+
|
|
93
|
+
def resolve_player(self, player_id: int) -> Player:
|
|
94
|
+
for p in self.players:
|
|
95
|
+
if p.id == player_id:
|
|
96
|
+
return p
|
|
97
|
+
raise InvalidActionError("Unknown player_id")
|
|
98
|
+
|
|
99
|
+
def ensure_deck(self) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Ensure deck has cards.
|
|
102
|
+
If empty, reshuffle discard pile (except top card).
|
|
103
|
+
"""
|
|
104
|
+
if self.deck:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if len(self.discard_pile) <= 1:
|
|
108
|
+
raise InvalidActionError("No cards left to reshuffle.")
|
|
109
|
+
|
|
110
|
+
top = self.discard_pile.pop()
|
|
111
|
+
self.deck = self.discard_pile
|
|
112
|
+
random.shuffle(self.deck)
|
|
113
|
+
|
|
114
|
+
self.discard_pile = [top]
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def new_game(cls, players: list[Player], deck: list[Card]) -> GameState:
|
|
118
|
+
"""
|
|
119
|
+
Create a new game state with initial settings.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
return cls(
|
|
123
|
+
players=players,
|
|
124
|
+
deck=deck,
|
|
125
|
+
discard_pile=[],
|
|
126
|
+
current_player_index=0,
|
|
127
|
+
round_number=1,
|
|
128
|
+
drawn_card=None,
|
|
129
|
+
reaction_rank=None,
|
|
130
|
+
reaction_initiator=None,
|
|
131
|
+
reaction_open=False,
|
|
132
|
+
kaboom_called_by=None,
|
|
133
|
+
instant_winner=None,
|
|
134
|
+
)
|
kaboom/game/phases.py
ADDED
kaboom/game/reaction.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# kaboom/game/reaction.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
from kaboom.exceptions import InvalidActionError
|
|
8
|
+
from kaboom.game.game_state import GameState
|
|
9
|
+
from kaboom.game.phases import GamePhase
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class ReactionResult:
|
|
13
|
+
success: bool
|
|
14
|
+
penalty_applied: bool = False
|
|
15
|
+
instant_win_player: Optional[int] = None
|
|
16
|
+
|
|
17
|
+
def _close_reaction(state: GameState) -> None:
|
|
18
|
+
state.reaction_open = False
|
|
19
|
+
state.reaction_rank = None
|
|
20
|
+
state.reaction_initiator = None
|
|
21
|
+
state.phase = GamePhase.TURN_DRAW
|
|
22
|
+
|
|
23
|
+
def close_reaction(state: GameState) -> None:
|
|
24
|
+
if not state.reaction_open:
|
|
25
|
+
raise InvalidActionError("No reaction to close")
|
|
26
|
+
|
|
27
|
+
_close_reaction(state)
|
|
28
|
+
|
|
29
|
+
def _apply_penalty(state: GameState, actor_id: int) -> ReactionResult:
|
|
30
|
+
if not state.deck:
|
|
31
|
+
raise InvalidActionError("Deck empty during penalty.")
|
|
32
|
+
|
|
33
|
+
penalty_card = state.deck.pop()
|
|
34
|
+
state.resolve_player(actor_id).hand.append(penalty_card)
|
|
35
|
+
|
|
36
|
+
_close_reaction(state)
|
|
37
|
+
return ReactionResult(success=False, penalty_applied=True)
|
|
38
|
+
|
|
39
|
+
def _check_instant_win(state: GameState, actor_id: int) -> ReactionResult:
|
|
40
|
+
if not state.resolve_player(actor_id).hand:
|
|
41
|
+
state.instant_winner = actor_id
|
|
42
|
+
state.phase = GamePhase.GAME_OVER
|
|
43
|
+
return ReactionResult(success=True, instant_win_player=actor_id)
|
|
44
|
+
|
|
45
|
+
return ReactionResult(success=True)
|
|
46
|
+
|
|
47
|
+
def react_discard_own_card(
|
|
48
|
+
state: GameState,
|
|
49
|
+
actor_id: int,
|
|
50
|
+
card_index: int,
|
|
51
|
+
) -> ReactionResult:
|
|
52
|
+
if not state.reaction_open:
|
|
53
|
+
raise InvalidActionError("No active reaction window.")
|
|
54
|
+
|
|
55
|
+
player = state.resolve_player(actor_id)
|
|
56
|
+
|
|
57
|
+
if card_index < 0 or card_index >= len(player.hand):
|
|
58
|
+
raise InvalidActionError("Invalid card index.")
|
|
59
|
+
|
|
60
|
+
card = player.hand[card_index]
|
|
61
|
+
|
|
62
|
+
if card.rank.value != state.reaction_rank:
|
|
63
|
+
return _apply_penalty(state, actor_id)
|
|
64
|
+
|
|
65
|
+
# Valid match
|
|
66
|
+
discarded = player.hand.pop(card_index)
|
|
67
|
+
state.discard_pile.append(discarded)
|
|
68
|
+
|
|
69
|
+
_close_reaction(state)
|
|
70
|
+
return _check_instant_win(state, actor_id)
|
|
71
|
+
|
|
72
|
+
def react_discard_other_card(
|
|
73
|
+
state: GameState,
|
|
74
|
+
actor_id: int,
|
|
75
|
+
target_player_id: int,
|
|
76
|
+
target_card_index: int,
|
|
77
|
+
give_card_index: int,
|
|
78
|
+
) -> ReactionResult:
|
|
79
|
+
if not state.reaction_open:
|
|
80
|
+
raise InvalidActionError("No active reaction window.")
|
|
81
|
+
|
|
82
|
+
actor = state.resolve_player(actor_id)
|
|
83
|
+
target = state.resolve_player(target_player_id)
|
|
84
|
+
|
|
85
|
+
if not actor.hand:
|
|
86
|
+
raise InvalidActionError("Actor must have a card to give.")
|
|
87
|
+
|
|
88
|
+
target_card = target.hand[target_card_index]
|
|
89
|
+
|
|
90
|
+
if target_card.rank.value != state.reaction_rank:
|
|
91
|
+
return _apply_penalty(state, actor_id)
|
|
92
|
+
|
|
93
|
+
# Discard target's card
|
|
94
|
+
discarded = target.hand.pop(target_card_index)
|
|
95
|
+
state.discard_pile.append(discarded)
|
|
96
|
+
|
|
97
|
+
# Give actor's card (hidden)
|
|
98
|
+
given = actor.hand.pop(give_card_index)
|
|
99
|
+
target.hand.append(given)
|
|
100
|
+
|
|
101
|
+
_close_reaction(state)
|
|
102
|
+
return ReactionResult(success=True)
|
|
103
|
+
|
|
104
|
+
def react_discard_own_cards(
|
|
105
|
+
state: GameState,
|
|
106
|
+
actor_id: int,
|
|
107
|
+
card_indices: List[int],
|
|
108
|
+
) -> ReactionResult:
|
|
109
|
+
if not state.reaction_open:
|
|
110
|
+
raise InvalidActionError("No active reaction window.")
|
|
111
|
+
|
|
112
|
+
if not card_indices:
|
|
113
|
+
raise InvalidActionError("Must discard at least one card.")
|
|
114
|
+
|
|
115
|
+
player = state.resolve_player(actor_id)
|
|
116
|
+
|
|
117
|
+
# Validate indices
|
|
118
|
+
if any(i < 0 or i >= len(player.hand) for i in card_indices):
|
|
119
|
+
raise InvalidActionError("Invalid card index.")
|
|
120
|
+
|
|
121
|
+
# Validate all ranks BEFORE mutating
|
|
122
|
+
for i in card_indices:
|
|
123
|
+
if player.hand[i].rank.value != state.reaction_rank:
|
|
124
|
+
return _apply_penalty(state, actor_id)
|
|
125
|
+
|
|
126
|
+
# Discard highest indices first (stable pop)
|
|
127
|
+
for i in sorted(card_indices, reverse=True):
|
|
128
|
+
discarded = player.hand.pop(i)
|
|
129
|
+
state.discard_pile.append(discarded)
|
|
130
|
+
|
|
131
|
+
_close_reaction(state)
|
|
132
|
+
return _check_instant_win(state, actor_id)
|
|
133
|
+
|
|
134
|
+
def react_discard_other_cards(
|
|
135
|
+
state: GameState,
|
|
136
|
+
actor_id: int,
|
|
137
|
+
target_player_id: int,
|
|
138
|
+
target_card_indices: List[int],
|
|
139
|
+
give_card_indices: List[int],
|
|
140
|
+
) -> ReactionResult:
|
|
141
|
+
if not state.reaction_open:
|
|
142
|
+
raise InvalidActionError("No active reaction window.")
|
|
143
|
+
|
|
144
|
+
if len(target_card_indices) != len(give_card_indices):
|
|
145
|
+
raise InvalidActionError("Must give one card per stolen card.")
|
|
146
|
+
|
|
147
|
+
actor = state.resolve_player(actor_id)
|
|
148
|
+
target = state.resolve_player(target_player_id)
|
|
149
|
+
|
|
150
|
+
if len(actor.hand) < len(give_card_indices):
|
|
151
|
+
raise InvalidActionError("Not enough cards to give.")
|
|
152
|
+
|
|
153
|
+
# Validate ranks first
|
|
154
|
+
for i in target_card_indices:
|
|
155
|
+
if target.hand[i].rank.value != state.reaction_rank:
|
|
156
|
+
return _apply_penalty(state, actor_id)
|
|
157
|
+
|
|
158
|
+
# Discard stolen cards
|
|
159
|
+
for i in sorted(target_card_indices, reverse=True):
|
|
160
|
+
discarded = target.hand.pop(i)
|
|
161
|
+
state.discard_pile.append(discarded)
|
|
162
|
+
|
|
163
|
+
# Give cards (hidden)
|
|
164
|
+
for i in sorted(give_card_indices, reverse=True):
|
|
165
|
+
given = actor.hand.pop(i)
|
|
166
|
+
target.hand.append(given)
|
|
167
|
+
|
|
168
|
+
_close_reaction(state)
|
|
169
|
+
return ReactionResult(success=True)
|
kaboom/game/results.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# kaboom/game/results.py
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
@dataclass(slots=True)
|
|
6
|
+
class ActionResult:
|
|
7
|
+
action: str
|
|
8
|
+
actor_id: int
|
|
9
|
+
reaction_opened: bool = False
|
|
10
|
+
reaction_closed: bool = False
|
|
11
|
+
instant_winner: Optional[int] = None
|
kaboom/game/turn.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# kaboom/game/turn.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from kaboom.exceptions import InvalidActionError
|
|
5
|
+
from kaboom.game.actions import (
|
|
6
|
+
Action,
|
|
7
|
+
Draw,
|
|
8
|
+
Discard,
|
|
9
|
+
Replace,
|
|
10
|
+
UsePower,
|
|
11
|
+
CallKaboom,
|
|
12
|
+
CloseReaction,
|
|
13
|
+
)
|
|
14
|
+
from kaboom.game.game_state import GameState
|
|
15
|
+
from kaboom.game.validators import validate_index, validate_turn
|
|
16
|
+
from kaboom.powers.registry import POWER_REGISTRY
|
|
17
|
+
from kaboom.game.reaction import close_reaction
|
|
18
|
+
from kaboom.game.results import ActionResult
|
|
19
|
+
from kaboom.game.phases import GamePhase
|
|
20
|
+
|
|
21
|
+
def _validate_turn_owner(state: GameState, actor_id: int) -> None:
|
|
22
|
+
current = state.current_player()
|
|
23
|
+
if current.id != actor_id:
|
|
24
|
+
raise InvalidActionError("Not this player's turn.")
|
|
25
|
+
if not current.active:
|
|
26
|
+
raise InvalidActionError("Inactive player cannot act.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _draw(state: GameState, action: Draw) -> None:
|
|
30
|
+
|
|
31
|
+
if state.drawn_card is not None:
|
|
32
|
+
raise InvalidActionError("Already holding a drawn card.")
|
|
33
|
+
|
|
34
|
+
state.ensure_deck()
|
|
35
|
+
|
|
36
|
+
if not state.deck:
|
|
37
|
+
raise InvalidActionError("Deck is empty.")
|
|
38
|
+
|
|
39
|
+
state.drawn_card = state.deck.pop()
|
|
40
|
+
state.phase = GamePhase.TURN_RESOLVE
|
|
41
|
+
|
|
42
|
+
def _replace(state: GameState, action: Replace) -> None:
|
|
43
|
+
if state.drawn_card is None:
|
|
44
|
+
raise InvalidActionError("No drawn card to replace with.")
|
|
45
|
+
|
|
46
|
+
player = state.current_player()
|
|
47
|
+
|
|
48
|
+
validate_index(len(player.hand), action.target_index, "target_index")
|
|
49
|
+
|
|
50
|
+
replaced = player.hand[action.target_index]
|
|
51
|
+
state.discard_pile.append(replaced)
|
|
52
|
+
|
|
53
|
+
player.hand[action.target_index] = state.drawn_card
|
|
54
|
+
state.drawn_card = None
|
|
55
|
+
|
|
56
|
+
# Open reaction window
|
|
57
|
+
state.reaction_rank = replaced.rank.value
|
|
58
|
+
state.reaction_initiator = player.id
|
|
59
|
+
state.reaction_open = True
|
|
60
|
+
state.phase = GamePhase.REACTION
|
|
61
|
+
|
|
62
|
+
state.advance_turn()
|
|
63
|
+
|
|
64
|
+
def _discard(state: GameState, action: Discard) -> None:
|
|
65
|
+
if state.drawn_card is None:
|
|
66
|
+
raise InvalidActionError("No drawn card to discard.")
|
|
67
|
+
|
|
68
|
+
discarded = state.drawn_card
|
|
69
|
+
state.discard_pile.append(discarded)
|
|
70
|
+
state.drawn_card = None
|
|
71
|
+
|
|
72
|
+
state.reaction_rank = discarded.rank.value
|
|
73
|
+
state.reaction_initiator = action.actor_id
|
|
74
|
+
state.reaction_open = True
|
|
75
|
+
state.phase = GamePhase.REACTION
|
|
76
|
+
|
|
77
|
+
state.advance_turn()
|
|
78
|
+
|
|
79
|
+
def _use_power(state: GameState, action: UsePower) -> None:
|
|
80
|
+
card = action.source_card
|
|
81
|
+
|
|
82
|
+
if state.drawn_card is None:
|
|
83
|
+
raise InvalidActionError("No drawn card to use power with.")
|
|
84
|
+
|
|
85
|
+
if state.drawn_card != card:
|
|
86
|
+
raise InvalidActionError("Power must use drawn card.")
|
|
87
|
+
|
|
88
|
+
for power in POWER_REGISTRY:
|
|
89
|
+
if power.can_apply(state, action.actor_id, card):
|
|
90
|
+
power.apply(state, action)
|
|
91
|
+
break
|
|
92
|
+
else:
|
|
93
|
+
raise InvalidActionError("No valid power for this card.")
|
|
94
|
+
|
|
95
|
+
state.discard_pile.append(card)
|
|
96
|
+
state.drawn_card = None
|
|
97
|
+
state.advance_turn()
|
|
98
|
+
|
|
99
|
+
def _call_kaboom(state: GameState, action: CallKaboom) -> None:
|
|
100
|
+
if state.round_number <= 1:
|
|
101
|
+
raise InvalidActionError("Kaboom cannot be called in round 1.")
|
|
102
|
+
|
|
103
|
+
player = state.current_player()
|
|
104
|
+
|
|
105
|
+
state.kaboom_called_by = player.id
|
|
106
|
+
player.active = False
|
|
107
|
+
player.revealed = True
|
|
108
|
+
|
|
109
|
+
def is_game_over(state: GameState) -> bool:
|
|
110
|
+
return state.phase == GamePhase.GAME_OVER
|
|
111
|
+
|
|
112
|
+
def get_valid_actions(state: GameState):
|
|
113
|
+
"""
|
|
114
|
+
Return all valid actions for the current player.
|
|
115
|
+
Useful for AI agents and simulations.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
if state.phase == GamePhase.GAME_OVER:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
player = state.current_player()
|
|
122
|
+
actions = []
|
|
123
|
+
|
|
124
|
+
if state.phase == GamePhase.REACTION:
|
|
125
|
+
actions.append(CloseReaction(actor_id=player.id))
|
|
126
|
+
return actions
|
|
127
|
+
|
|
128
|
+
if state.phase == GamePhase.TURN_DRAW:
|
|
129
|
+
actions.append(Draw(actor_id=player.id))
|
|
130
|
+
|
|
131
|
+
if state.round_number > 1:
|
|
132
|
+
actions.append(CallKaboom(actor_id=player.id))
|
|
133
|
+
|
|
134
|
+
return actions
|
|
135
|
+
|
|
136
|
+
if state.phase == GamePhase.TURN_RESOLVE:
|
|
137
|
+
actions.append(Discard(actor_id=player.id))
|
|
138
|
+
|
|
139
|
+
for i in range(len(player.hand)):
|
|
140
|
+
actions.append(Replace(actor_id=player.id, target_index=i))
|
|
141
|
+
|
|
142
|
+
return actions
|
|
143
|
+
|
|
144
|
+
return actions
|
|
145
|
+
|
|
146
|
+
def apply_action(state: GameState, action: Action) -> list[ActionResult]:
|
|
147
|
+
"""
|
|
148
|
+
Apply a validated action to the game state.
|
|
149
|
+
"""
|
|
150
|
+
if state.phase == GamePhase.GAME_OVER:
|
|
151
|
+
raise InvalidActionError("Game is already over.")
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------
|
|
154
|
+
# Reaction resolution path (bypass turn rules)
|
|
155
|
+
# ------------------------------------------------
|
|
156
|
+
if isinstance(action, CloseReaction):
|
|
157
|
+
close_reaction(state)
|
|
158
|
+
return [ActionResult("close_reaction", action.actor_id, reaction_closed=True, instant_winner=state.instant_winner)]
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------
|
|
161
|
+
# Normal turn validation
|
|
162
|
+
# ------------------------------------------------
|
|
163
|
+
if state.reaction_open:
|
|
164
|
+
raise InvalidActionError("Reaction window open.")
|
|
165
|
+
_validate_turn_owner(state, action.actor_id)
|
|
166
|
+
validate_turn(state, action.actor_id)
|
|
167
|
+
|
|
168
|
+
phase = state.phase
|
|
169
|
+
|
|
170
|
+
if phase == GamePhase.REACTION and not isinstance(action, CloseReaction):
|
|
171
|
+
raise InvalidActionError("Must resolve reaction first.")
|
|
172
|
+
|
|
173
|
+
if phase == GamePhase.TURN_DRAW and not (isinstance(action, Draw) or isinstance(action, CallKaboom)):
|
|
174
|
+
raise InvalidActionError("Must draw first.")
|
|
175
|
+
|
|
176
|
+
if phase == GamePhase.TURN_RESOLVE and isinstance(action, Draw):
|
|
177
|
+
raise InvalidActionError("Already drawn this turn.")
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------
|
|
180
|
+
# Turn actions
|
|
181
|
+
# ------------------------------------------------
|
|
182
|
+
|
|
183
|
+
if isinstance(action, Draw):
|
|
184
|
+
_draw(state, action)
|
|
185
|
+
return [ActionResult("draw", action.actor_id)]
|
|
186
|
+
|
|
187
|
+
elif isinstance(action, Replace):
|
|
188
|
+
_replace(state, action)
|
|
189
|
+
return [ActionResult("replace", action.actor_id, reaction_opened=True)]
|
|
190
|
+
|
|
191
|
+
elif isinstance(action, Discard):
|
|
192
|
+
_discard(state, action)
|
|
193
|
+
return [ActionResult("discard", action.actor_id, reaction_opened=True)]
|
|
194
|
+
|
|
195
|
+
elif isinstance(action, UsePower):
|
|
196
|
+
_use_power(state, action)
|
|
197
|
+
return [ActionResult("use_power", action.actor_id)]
|
|
198
|
+
|
|
199
|
+
elif isinstance(action, CallKaboom):
|
|
200
|
+
_call_kaboom(state, action)
|
|
201
|
+
return [ActionResult("call_kaboom", action.actor_id)]
|
|
202
|
+
|
|
203
|
+
else:
|
|
204
|
+
raise InvalidActionError(f"Unknown action type: {type(action)}")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# kaboom/game/validators.py
|
|
2
|
+
from kaboom.exceptions import InvalidActionError
|
|
3
|
+
from kaboom.game.game_state import GameState
|
|
4
|
+
|
|
5
|
+
def validate_turn(state: GameState, actor_id: int):
|
|
6
|
+
if state.current_player().id != actor_id:
|
|
7
|
+
raise InvalidActionError(f"Not this player's turn.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_index(length: int, index: int, name: str):
|
|
11
|
+
if index is None:
|
|
12
|
+
raise InvalidActionError(f"{name} required.")
|
|
13
|
+
if index < 0 or index >= length:
|
|
14
|
+
raise InvalidActionError(f"{name} out of range.")
|
kaboom/players/player.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# kaboom/players/player.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import List, Dict, Tuple
|
|
6
|
+
|
|
7
|
+
from kaboom.cards.card import Card
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class Player:
|
|
12
|
+
"""
|
|
13
|
+
Represents a single player and their private state.
|
|
14
|
+
"""
|
|
15
|
+
id: int
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
hand: List[Card] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
# Tracks which cards THIS player has seen
|
|
21
|
+
# (player_id, card_index) → Card
|
|
22
|
+
memory: Dict[Tuple[int, int], Card] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
active: bool = True # False after Kaboom call
|
|
25
|
+
revealed: bool = False # True only at final reveal
|
|
26
|
+
|
|
27
|
+
def card_count(self) -> int:
|
|
28
|
+
return len(self.hand)
|
|
29
|
+
|
|
30
|
+
def total_score(self) -> int:
|
|
31
|
+
return sum(card.score_value for card in self.hand)
|
|
32
|
+
|
|
33
|
+
def remove_card(self, index: int) -> Card:
|
|
34
|
+
return self.hand.pop(index)
|
|
35
|
+
|
|
36
|
+
def add_card(self, card: Card) -> None:
|
|
37
|
+
self.hand.append(card)
|
|
38
|
+
|
|
39
|
+
def remember(self, player_id: int, card_index: int, card: Card) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Store knowledge gained via a power.
|
|
42
|
+
"""
|
|
43
|
+
self.memory[(player_id, card_index)] = card
|
|
44
|
+
|
|
45
|
+
def forget_all(self) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Used after blind swaps or Kaboom.
|
|
48
|
+
"""
|
|
49
|
+
self.memory.clear()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# kaboom/powers/__init__.py
|
|
2
|
+
from .see_self import SeeSelfPower
|
|
3
|
+
from .see_other import SeeOtherPower
|
|
4
|
+
from .blind_swap import BlindSwapPower
|
|
5
|
+
from .see_and_swap import SeeAndSwapPower
|
|
6
|
+
from .registry import POWER_REGISTRY
|
|
7
|
+
|
|
8
|
+
__all__ = ["SeeSelfPower", "SeeOtherPower", "BlindSwapPower", "SeeAndSwapPower", "POWER_REGISTRY"]
|
kaboom/powers/base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# kaboom/powers/base.py
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from kaboom.game.game_state import GameState
|
|
4
|
+
from kaboom.game.actions import UsePower
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Power(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def can_apply(self, state: GameState, actor_id: int, card) -> bool:
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def apply(self, state: GameState, action: UsePower,) -> None:
|
|
14
|
+
...
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# kaboom/powers/blind_swap.py
|
|
2
|
+
from kaboom.game.actions import UsePower
|
|
3
|
+
from kaboom.powers.base import Power
|
|
4
|
+
from kaboom.cards.card import Rank
|
|
5
|
+
from kaboom.game.game_state import GameState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BlindSwapPower(Power):
|
|
9
|
+
VALID_RANKS = {Rank.J, Rank.Q}
|
|
10
|
+
|
|
11
|
+
def can_apply(self, state, actor_id, card):
|
|
12
|
+
return card.rank in self.VALID_RANKS
|
|
13
|
+
|
|
14
|
+
def apply(self, state: GameState, action: UsePower) -> None:
|
|
15
|
+
actor = state.resolve_player(action.actor_id)
|
|
16
|
+
target = state.resolve_player(action.target_player_id)
|
|
17
|
+
|
|
18
|
+
actor.hand[action.target_card_index], target.hand[
|
|
19
|
+
action.second_target_card_index
|
|
20
|
+
] = (
|
|
21
|
+
target.hand[action.second_target_card_index],
|
|
22
|
+
actor.hand[action.target_card_index],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
actor.forget_all()
|
|
26
|
+
target.forget_all()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# kaboom/powers/registry.py
|
|
2
|
+
from kaboom.powers.see_self import SeeSelfPower
|
|
3
|
+
from kaboom.powers.see_other import SeeOtherPower
|
|
4
|
+
from kaboom.powers.blind_swap import BlindSwapPower
|
|
5
|
+
from kaboom.powers.see_and_swap import SeeAndSwapPower
|
|
6
|
+
|
|
7
|
+
POWER_REGISTRY = [
|
|
8
|
+
SeeSelfPower(),
|
|
9
|
+
SeeOtherPower(),
|
|
10
|
+
BlindSwapPower(),
|
|
11
|
+
SeeAndSwapPower(),
|
|
12
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# kaboom/powers/see_and_swap.py
|
|
2
|
+
from kaboom.powers.base import Power
|
|
3
|
+
from kaboom.cards.card import Rank, Suit
|
|
4
|
+
from kaboom.game.game_state import GameState
|
|
5
|
+
from kaboom.game.actions import UsePower
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SeeAndSwapPower(Power):
|
|
9
|
+
def can_apply(self, state, actor_id, card):
|
|
10
|
+
return card.rank == Rank.K and card.suit in {
|
|
11
|
+
Suit.SPADES,
|
|
12
|
+
Suit.CLUBS,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def apply(self, state: GameState, action: UsePower) -> None:
|
|
16
|
+
p1 = state.resolve_player(action.target_player_id)
|
|
17
|
+
p2 = state.resolve_player(action.second_target_player_id)
|
|
18
|
+
|
|
19
|
+
c1 = p1.hand[action.target_card_index]
|
|
20
|
+
c2 = p2.hand[action.second_target_card_index]
|
|
21
|
+
|
|
22
|
+
state.resolve_player(action.actor_id).remember(
|
|
23
|
+
action.target_player_id, action.target_card_index, c1
|
|
24
|
+
)
|
|
25
|
+
state.resolve_player(action.actor_id).remember(
|
|
26
|
+
action.second_target_player_id,
|
|
27
|
+
action.second_target_card_index,
|
|
28
|
+
c2,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
p1.hand[action.target_card_index], p2.hand[
|
|
32
|
+
action.second_target_card_index
|
|
33
|
+
] = c2, c1
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# kaboom/powers/see_other.py
|
|
2
|
+
from kaboom.game.actions import UsePower
|
|
3
|
+
from kaboom.powers.base import Power
|
|
4
|
+
from kaboom.cards.card import Rank
|
|
5
|
+
from kaboom.game.game_state import GameState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SeeOtherPower(Power):
|
|
9
|
+
VALID_RANKS = {Rank.NINE, Rank.TEN}
|
|
10
|
+
|
|
11
|
+
def can_apply(self, state, actor_id, card):
|
|
12
|
+
return card.rank in self.VALID_RANKS
|
|
13
|
+
|
|
14
|
+
def apply(self, state: GameState, action: UsePower) -> None:
|
|
15
|
+
target = state.resolve_player(action.target_player_id)
|
|
16
|
+
card = target.hand[action.target_card_index]
|
|
17
|
+
|
|
18
|
+
state.resolve_player(action.actor_id).remember(
|
|
19
|
+
action.target_player_id,
|
|
20
|
+
action.target_card_index,
|
|
21
|
+
card,
|
|
22
|
+
)
|
|
23
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# kaboom/powers/see_other.py
|
|
2
|
+
from kaboom.game.actions import UsePower
|
|
3
|
+
from kaboom.powers.base import Power
|
|
4
|
+
from kaboom.cards.card import Rank
|
|
5
|
+
from kaboom.game.game_state import GameState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SeeSelfPower(Power):
|
|
9
|
+
VALID_RANKS = {Rank.SEVEN, Rank.EIGHT}
|
|
10
|
+
|
|
11
|
+
def can_apply(self, state, actor_id, card):
|
|
12
|
+
return card.rank in self.VALID_RANKS
|
|
13
|
+
|
|
14
|
+
def apply(self, state: GameState, action: UsePower) -> None:
|
|
15
|
+
idx = action.target_card_index
|
|
16
|
+
player = state.resolve_player(action.actor_id)
|
|
17
|
+
card = player.hand[idx]
|
|
18
|
+
player.remember(action.actor_id, idx, card)
|
|
19
|
+
|
kaboom/version.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kaboom-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core game engine for the Kaboom card game.
|
|
5
|
+
Author: Arnav Ajay
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# Kaboom Engine
|
|
12
|
+
|
|
13
|
+
A deterministic Python engine for the **Kaboom card game**.
|
|
14
|
+
|
|
15
|
+
The project implements the full game logic including turns, reactions, powers, and endgame rules, designed to be used as a reusable **simulation engine, AI environment, or UI backend**.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Features
|
|
20
|
+
|
|
21
|
+
* Complete Kaboom game rules
|
|
22
|
+
* Deterministic turn engine
|
|
23
|
+
* Reaction resolution system
|
|
24
|
+
* Card power mechanics
|
|
25
|
+
* Kaboom endgame logic
|
|
26
|
+
* Deck reshuffle handling
|
|
27
|
+
* Action-based engine architecture
|
|
28
|
+
* Event-based results
|
|
29
|
+
* Fully tested core logic
|
|
30
|
+
|
|
31
|
+
The engine is designed so it can be used for:
|
|
32
|
+
|
|
33
|
+
* CLI or graphical game clients
|
|
34
|
+
* AI agents
|
|
35
|
+
* game simulations
|
|
36
|
+
* multiplayer servers
|
|
37
|
+
* reinforcement learning environments
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
# Installation
|
|
42
|
+
|
|
43
|
+
Clone the repository:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/Arnav-Ajay/kaboom-core.git
|
|
47
|
+
cd kaboom-core
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Install locally:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
# Basic Usage
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from kaboom import GameState, apply_action
|
|
62
|
+
from kaboom.game.actions import Draw, Discard
|
|
63
|
+
|
|
64
|
+
state = GameState.new_game()
|
|
65
|
+
|
|
66
|
+
apply_action(state, Draw(actor_id=0))
|
|
67
|
+
apply_action(state, Discard(actor_id=0))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
# Running Simulations
|
|
73
|
+
|
|
74
|
+
The engine supports automated play.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import random
|
|
78
|
+
from kaboom import GameState, apply_action
|
|
79
|
+
from kaboom.game.turn import get_valid_actions
|
|
80
|
+
|
|
81
|
+
state = GameState.new_game()
|
|
82
|
+
|
|
83
|
+
while True:
|
|
84
|
+
actions = get_valid_actions(state)
|
|
85
|
+
|
|
86
|
+
if not actions:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
action = random.choice(actions)
|
|
90
|
+
apply_action(state, action)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This allows thousands of games to be simulated for testing or AI training.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
# Architecture
|
|
98
|
+
|
|
99
|
+
The engine follows a modular architecture:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
GameState
|
|
103
|
+
|
|
|
104
|
+
Action (Draw / Discard / Replace / UsePower / CallKaboom)
|
|
105
|
+
|
|
|
106
|
+
apply_action()
|
|
107
|
+
|
|
|
108
|
+
ActionResult
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Key components:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
kaboom/cards → card definitions
|
|
115
|
+
kaboom/players → player state
|
|
116
|
+
kaboom/powers → power system
|
|
117
|
+
kaboom/game → turn engine, reactions, validators
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
This separation keeps game logic independent from any UI layer.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
# Project Structure
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
kaboom/
|
|
128
|
+
cards/
|
|
129
|
+
players/
|
|
130
|
+
powers/
|
|
131
|
+
game/
|
|
132
|
+
actions.py
|
|
133
|
+
game_state.py
|
|
134
|
+
phases.py
|
|
135
|
+
reaction.py
|
|
136
|
+
results.py
|
|
137
|
+
turn.py
|
|
138
|
+
validators.py
|
|
139
|
+
|
|
140
|
+
tests/
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
# Testing
|
|
146
|
+
|
|
147
|
+
The engine includes a full pytest test suite.
|
|
148
|
+
|
|
149
|
+
Run tests:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pytest
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Current coverage includes:
|
|
156
|
+
|
|
157
|
+
* card scoring
|
|
158
|
+
* deck creation
|
|
159
|
+
* player management
|
|
160
|
+
* power mechanics
|
|
161
|
+
* turn actions
|
|
162
|
+
* reaction logic
|
|
163
|
+
* Kaboom endgame rules
|
|
164
|
+
* full game initialization
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
# Future Improvements
|
|
169
|
+
|
|
170
|
+
Planned enhancements:
|
|
171
|
+
|
|
172
|
+
* CLI interface
|
|
173
|
+
* graphical UI
|
|
174
|
+
* AI player agents
|
|
175
|
+
* multiplayer networking
|
|
176
|
+
* replay and event logging
|
|
177
|
+
* Gym-compatible environment for reinforcement learning
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
# License
|
|
182
|
+
|
|
183
|
+
This project is licensed under the MIT License.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
# Author
|
|
188
|
+
|
|
189
|
+
[Arnav Ajay](https://github.com/Arnav-Ajay)
|
|
190
|
+
|
|
191
|
+
---
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
kaboom/__init__.py,sha256=MIJ3vgrE4LzPxrlj6giUTFVPTuAP7lJWZ4DdZMT9i3E,889
|
|
2
|
+
kaboom/exceptions.py,sha256=htzyYT36tTqQghgs82h1bO5dK5Apo8jwqBraQqHPHf0,444
|
|
3
|
+
kaboom/version.py,sha256=fkhLiQPNUlIo5wbs_2WPJs6Ih9c1uarXshsjoVG5pKk,42
|
|
4
|
+
kaboom/cards/__init__.py,sha256=Xkm2z21flg6VJxf6ltFGJNs4hWJamwpnhPqNQNBBNfs,100
|
|
5
|
+
kaboom/cards/card.py,sha256=Nk0MfQqbe8G83wkvD-BkrcF4FAKCiQbCH31MLdsxKS4,912
|
|
6
|
+
kaboom/game/__init__.py,sha256=lLYvOZKf8d5C2-KzD1dRyGhG1dp5ialnAViIt4FRrQo,563
|
|
7
|
+
kaboom/game/actions.py,sha256=nez151girLgpb-0fQ2xGfkUS9-3KR3qnrwAyrO_I2Vw,1126
|
|
8
|
+
kaboom/game/game_state.py,sha256=D4iVTbRuTSBk_597A9SExAjwPFbeeQqHdYv4o7OovJc,3975
|
|
9
|
+
kaboom/game/phases.py,sha256=YUdPPDX1a6nF0PtMhtlm0uAUqQlUZ2ZSjv-jeNMkuiQ,197
|
|
10
|
+
kaboom/game/reaction.py,sha256=rJ5cUGoBdNPnPL1vyvfq9Oqzcw5oNbiQpz8HWBGmjeM,5303
|
|
11
|
+
kaboom/game/results.py,sha256=mtTaO9You5ARMf5CEG3XwMocePk6KHwre2DEjrODXLk,283
|
|
12
|
+
kaboom/game/turn.py,sha256=Yy1tbRlPbX0QOQyCeyLuNxEtD07cBnCXILyNNWqUE7s,6636
|
|
13
|
+
kaboom/game/validators.py,sha256=ZS_gV9CW9L_UIY_DMMrkCthMON820kR2fV61z-FDXVU,523
|
|
14
|
+
kaboom/players/__init__.py,sha256=yVJ7ejKPLxEJ1Q8FjWGoI7zYYN8HnMOjPO9nsXT6eis,80
|
|
15
|
+
kaboom/players/player.py,sha256=GulGPvjDB69TDqprwGFYUW0RRmyhp-RP5t77mvCfPqo,1328
|
|
16
|
+
kaboom/powers/__init__.py,sha256=pm-_grQP0xmO9YwcXug_bj6smYDd8EFqehXHzfFR2hc,324
|
|
17
|
+
kaboom/powers/base.py,sha256=qdIn7iiYvz2G54cUYHP5a0ci7EB4QafNeUmMyOmBXns,383
|
|
18
|
+
kaboom/powers/blind_swap.py,sha256=PgQOGWnAsDrKYLzW8UoOB3dvf2x1Wu3dcE7v51E0BtA,849
|
|
19
|
+
kaboom/powers/registry.py,sha256=wxxrdFahAJIZM8VPrru_Dcq9hA_XhHhj4camKIQrt3A,353
|
|
20
|
+
kaboom/powers/see_and_swap.py,sha256=Mk4s7RrZisZAioV9Uz7qMoD3KXZEDPTEH0ss8qL37Pw,1135
|
|
21
|
+
kaboom/powers/see_other.py,sha256=a2rPOxsWDA2dpWx-TQIiRy8u-7Zm-axl-LJvdY_XVBA,718
|
|
22
|
+
kaboom/powers/see_self.py,sha256=2n06x9P7ISvkZEDvd3XsodJC11KpzFmkHdtikZYZfOQ,617
|
|
23
|
+
kaboom_engine-0.1.0.dist-info/licenses/LICENSE,sha256=cBLtln6E2ziRnpA0_xU0CXiHWh34dNKoQvJufm0b94M,1076
|
|
24
|
+
kaboom_engine-0.1.0.dist-info/METADATA,sha256=nYLhoIOHsGdx_Pzx3WBqMi0AaZhhq5PXR18PstcWtLI,3271
|
|
25
|
+
kaboom_engine-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
26
|
+
kaboom_engine-0.1.0.dist-info/top_level.txt,sha256=HWvUo4xUyc9VDNbhCXca57trgtt3nltDnDBGb0A35Zc,7
|
|
27
|
+
kaboom_engine-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arnav Ajay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
10
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
11
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kaboom
|