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 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
+ ]
@@ -0,0 +1,4 @@
1
+ # kaboom/cards/__init__.py
2
+ from .card import Card, Rank, Suit
3
+
4
+ __all__ = ["Card", "Rank", "Suit"]
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
@@ -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
@@ -0,0 +1,8 @@
1
+ # kaboom/game/phases.py
2
+ from enum import Enum
3
+
4
+ class GamePhase(str, Enum):
5
+ TURN_DRAW = "turn_draw"
6
+ TURN_RESOLVE = "turn_resolve"
7
+ REACTION = "reaction"
8
+ GAME_OVER = "game_over"
@@ -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.")
@@ -0,0 +1,4 @@
1
+ # kaboom/players/__init__.py
2
+ from .player import Player
3
+
4
+ __all__ = ["Player"]
@@ -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,2 @@
1
+ # kaboom/version.py
2
+ __version__ = "0.1.0"
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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