Skip to content

Commit da47254

Browse files
committed
feat: update Bot with types and new abc methods
1 parent c687417 commit da47254

File tree

1 file changed

+174
-47
lines changed

1 file changed

+174
-47
lines changed

src/balatrobot/base.py

Lines changed: 174 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,98 +5,217 @@
55
import string
66
from abc import ABC, abstractmethod
77
from datetime import datetime
8+
from typing import Any, TypedDict
89

910
from .enums import Actions, Decks, Stakes, State
1011

1112

12-
def cache_state(game_step, G):
13-
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
14-
if not os.path.exists(f"gamestate_cache/{game_step}/"):
15-
os.makedirs(f"gamestate_cache/{game_step}/")
16-
filename = f"gamestate_cache/{game_step}/{timestamp}.json"
17-
with open(filename, "w") as f:
18-
f.write(json.dumps(G, indent=4))
13+
class ActionSchema(TypedDict):
14+
"""Schema for action dictionary with name and arguments fields."""
15+
16+
action: Actions
17+
args: list[Any] | None
1918

2019

2120
class Bot(ABC):
21+
"""Abstract base class for Balatro bots.
22+
23+
This class provides the framework for creating bots that can play Balatro.
24+
Subclasses must implement all abstract methods to define the bot's behavior.
25+
26+
Attributes:
27+
G (dict[str, Any] | None): The current game state.
28+
deck (Decks): The deck type to use.
29+
stake (Stakes): The stake level to play at.
30+
seed (str): The random seed for the game.
31+
challenge (str | None): The challenge mode, if any.
32+
bot_port (int): The port for bot communication.
33+
addr (tuple[str, int]): The socket address for communication.
34+
running (bool): Whether the bot is currently running.
35+
balatro_instance (Any): The Balatro game instance.
36+
sock (socket.socket | None): The socket for communication.
37+
state (dict[str, Any]): The bot's internal state.
38+
"""
39+
2240
def __init__(
2341
self,
2442
deck: Decks,
2543
stake: Stakes = Stakes.WHITE,
2644
seed: str = "",
2745
challenge: str | None = None,
2846
bot_port: int = 12346,
29-
):
30-
self.G = None
31-
self.deck = deck
32-
self.stake = stake
33-
self.seed = seed
34-
self.challenge = challenge
47+
) -> None:
48+
"""Initialize a new Bot instance.
49+
50+
Args:
51+
deck (Decks): The deck type to use.
52+
stake (Stakes): The stake level to play at.
53+
seed (str): The random seed for the game.
54+
challenge (str | None): The challenge mode, if any.
55+
bot_port (int): The port for bot communication.
56+
"""
57+
self.G: dict[str, Any] | None = None
58+
self.deck: Decks = deck
59+
self.stake: Stakes = stake
60+
self.seed: str = seed
61+
self.challenge: str | None = challenge
3562

36-
self.bot_port = bot_port
63+
self.bot_port: int = bot_port
3764

38-
self.addr = ("127.0.0.1", self.bot_port)
39-
self.running = False
40-
self.balatro_instance = None
65+
self.addr: tuple[str, int] = ("127.0.0.1", self.bot_port)
66+
self.running: bool = False
67+
self.balatro_instance: Any = None
4168

42-
self.sock = None
69+
self.sock: socket.socket | None = None
4370

44-
self.state = {}
71+
self.state: dict[str, Any] = {}
4572

4673
@staticmethod
47-
def random_seed():
74+
def random_seed() -> str:
75+
"""Generate a random seed for the game.
76+
77+
Returns:
78+
str: A random 7-character seed string.
79+
"""
4880
return "".join(random.choices(string.digits + string.ascii_uppercase, k=7))
4981

5082
@abstractmethod
51-
def skip_or_select_blind(self, env: dict):
83+
def skip_or_select_blind(self, env: dict) -> ActionSchema:
84+
"""
85+
Decide whether to skip or select a blind.
86+
87+
Returns:
88+
ActionSchema with 'action' (Actions enum) and optional 'args' (any additional arguments)
89+
Example: {'action': Actions.SELECT_BLIND, 'args': [0]}
90+
"""
5291
pass
5392

5493
@abstractmethod
55-
def select_cards_from_hand(self, env: dict):
94+
def select_cards_from_hand(self, env: dict) -> ActionSchema:
95+
"""
96+
Select cards from hand to play or discard.
97+
98+
Returns:
99+
ActionSchema with 'action' (Actions enum) and optional 'args' (card indices)
100+
Example: {'action': Actions.PLAY_HAND, 'args': [1, 2, 3]}
101+
"""
56102
pass
57103

58104
@abstractmethod
59-
def select_shop_action(self, env: dict):
105+
def select_shop_action(self, env: dict) -> ActionSchema:
106+
"""
107+
Select an action in the shop.
108+
109+
Returns:
110+
ActionSchema with 'action' (Actions enum) and optional 'args' (shop item index)
111+
Example: {'action': Actions.BUY_CARD, 'args': [0]}
112+
"""
60113
pass
61114

62115
@abstractmethod
63-
def select_booster_action(self, env: dict):
116+
def select_booster_action(self, env: dict) -> ActionSchema:
117+
"""
118+
Select an action for booster packs.
119+
120+
Returns:
121+
ActionSchema with 'action' (Actions enum) and optional 'args' (booster card index)
122+
Example: {'action': Actions.SELECT_BOOSTER_CARD, 'args': [0]}
123+
"""
64124
pass
65125

66126
@abstractmethod
67-
def sell_jokers(self, env: dict):
127+
def sell_jokers(self, env: dict) -> ActionSchema:
128+
"""
129+
Sell jokers from the collection.
130+
131+
Returns:
132+
ActionSchema with 'action' (Actions enum) and optional 'args' (joker index)
133+
Example: {'action': Actions.SELL_JOKER, 'args': [0]}
134+
"""
68135
pass
69136

70137
@abstractmethod
71-
def rearrange_jokers(self, env: dict):
138+
def rearrange_jokers(self, env: dict) -> ActionSchema:
139+
"""
140+
Rearrange jokers in the collection.
141+
142+
Returns:
143+
ActionSchema with 'action' (Actions enum) and optional 'args' (new arrangement)
144+
Example: {'action': Actions.REARRANGE_JOKERS, 'args': [0, 1, 2]}
145+
"""
72146
pass
73147

74148
@abstractmethod
75-
def use_or_sell_consumables(self, env: dict):
149+
def use_or_sell_consumables(self, env: dict) -> ActionSchema:
150+
"""
151+
Use or sell consumable cards.
152+
153+
Returns:
154+
ActionSchema with 'action' (Actions enum) and optional 'args' (consumable index)
155+
Example: {'action': Actions.USE_CONSUMABLE, 'args': [0]}
156+
"""
76157
pass
77158

78159
@abstractmethod
79-
def rearrange_consumables(self, env: dict):
160+
def rearrange_consumables(self, env: dict) -> ActionSchema:
161+
"""
162+
Rearrange consumable cards.
163+
164+
Returns:
165+
ActionSchema with 'action' (Actions enum) and optional 'args' (new arrangement)
166+
Example: {'action': Actions.REARRANGE_CONSUMABLES, 'args': [0, 1, 2]}
167+
"""
80168
pass
81169

82170
@abstractmethod
83-
def rearrange_hand(self, env: dict):
171+
def rearrange_hand(self, env: dict) -> ActionSchema:
172+
"""
173+
Rearrange cards in hand.
174+
175+
Returns:
176+
ActionSchema with 'action' (Actions enum) and optional 'args' (new arrangement)
177+
Example: {'action': Actions.REARRANGE_HAND, 'args': [0, 1, 2, 3, 4]}
178+
"""
84179
pass
85180

86-
def _action_to_action_str(self, action):
181+
def _action_to_action_str(self, action: ActionSchema) -> str:
182+
"""
183+
Convert action to string format expected by the game.
184+
185+
Args:
186+
action: ActionSchema dict with 'action' and optional 'args'
187+
188+
Returns:
189+
Pipe-separated string representation of the action
190+
"""
87191
result = []
88192

89-
for x in action:
90-
if isinstance(x, Actions):
91-
result.append(x.name)
92-
elif type(x) is list:
93-
result.append(",".join([str(y) for y in x]))
193+
# Add the action name
194+
action_enum = action.get("action")
195+
if isinstance(action_enum, Actions):
196+
result.append(action_enum.name)
197+
else:
198+
result.append(str(action_enum))
199+
200+
# Add arguments if present
201+
args = action.get("args")
202+
if args:
203+
if isinstance(args, list):
204+
result.append(",".join([str(arg) for arg in args]))
94205
else:
95-
result.append(str(x))
206+
result.append(str(args))
96207

97208
return "|".join(result)
98209

99-
def chooseaction(self, env: dict):
210+
def chooseaction(self, env: dict[str, Any]) -> ActionSchema:
211+
"""Choose an action based on the current game state.
212+
213+
Args:
214+
env (dict[str, Any]): The current game environment state.
215+
216+
Returns:
217+
ActionSchema: The action to perform with 'action' and optional 'args'.
218+
"""
100219
print("Choosing action based on game state:", env["state"])
101220
if env["state"] == State.GAME_OVER:
102221
self.running = False
@@ -105,13 +224,10 @@ def chooseaction(self, env: dict):
105224
case "start_run":
106225
print("Starting run with deck:", self.deck)
107226
seed = self.seed or self.random_seed()
108-
return [
109-
Actions.START_RUN,
110-
self.stake.value,
111-
self.deck.value,
112-
seed,
113-
self.challenge,
114-
]
227+
return {
228+
"action": Actions.START_RUN,
229+
"args": [self.stake.value, self.deck.value, seed, self.challenge],
230+
}
115231
case "skip_or_select_blind":
116232
print("Choosing action: skip_or_select_blind")
117233
return self.skip_or_select_blind(env)
@@ -139,8 +255,15 @@ def chooseaction(self, env: dict):
139255
case "rearrange_hand":
140256
print("Choosing action: rearrange_hand")
141257
return self.rearrange_hand(env)
258+
case _:
259+
raise ValueError(f"Unhandled waitingFor state: {env['waitingFor']}")
142260

143-
def run_step(self):
261+
def run_step(self) -> None:
262+
"""Execute a single step of the bot's main loop.
263+
264+
This method handles socket communication, receives game state updates,
265+
and sends actions back to the game.
266+
"""
144267
if self.sock is None:
145268
self.state = {}
146269
self.G = None
@@ -161,7 +284,6 @@ def run_step(self):
161284
print(env["response"])
162285
else:
163286
if env["waitingForAction"]:
164-
cache_state(env["waitingFor"], env)
165287
action = self.chooseaction(env)
166288
if action:
167289
action_str = self._action_to_action_str(action)
@@ -176,6 +298,11 @@ def run_step(self):
176298
self.sock.settimeout(1)
177299
self.sock.connect(self.addr)
178300

179-
def run(self):
301+
def run(self) -> None:
302+
"""Run the bot's main game loop.
303+
304+
This method continues running until the bot is stopped,
305+
executing run_step() repeatedly.
306+
"""
180307
while self.running:
181308
self.run_step()

0 commit comments

Comments
 (0)