chipzen-bot 0.2.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.
chipzen/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Chipzen Poker Bot SDK -- build, test, and deploy poker bots for the Chipzen platform."""
2
+
3
+ from chipzen.bot import ChipzenBot
4
+ from chipzen.models import Action, Card, GameState, Player, RoundStart, TurnResult
5
+
6
+ # Convenience alias matching the public API: `from chipzen import Bot`
7
+ Bot = ChipzenBot
8
+
9
+ __all__ = [
10
+ "Bot",
11
+ "ChipzenBot",
12
+ "Action",
13
+ "Card",
14
+ "GameState",
15
+ "Player",
16
+ "RoundStart",
17
+ "TurnResult",
18
+ ]
19
+
20
+ __version__ = "0.2.0"
chipzen/__main__.py ADDED
@@ -0,0 +1,55 @@
1
+ """CLI entry point for the chipzen-sdk package.
2
+
3
+ Usage:
4
+ chipzen-sdk init my_bot
5
+ chipzen-sdk validate ./my_bot/
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ COMMANDS = {
13
+ "init": "Scaffold a new bot project with starter files",
14
+ "validate": "Check if a bot will pass the platform upload and build process",
15
+ }
16
+
17
+
18
+ def _print_help() -> None:
19
+ """Print top-level help with all available commands."""
20
+ print("Chipzen Poker Bot SDK")
21
+ print()
22
+ print("Usage: chipzen-sdk <command> [options]")
23
+ print()
24
+ print("Commands:")
25
+ for cmd, desc in COMMANDS.items():
26
+ print(f" {cmd:<12} {desc}")
27
+ print()
28
+ print("Run 'chipzen-sdk <command> --help' for details on a specific command.")
29
+
30
+
31
+ def main() -> None:
32
+ if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h"):
33
+ _print_help()
34
+ sys.exit(0 if len(sys.argv) >= 2 else 1)
35
+
36
+ command = sys.argv[1]
37
+ remaining = sys.argv[2:]
38
+
39
+ if command == "validate":
40
+ from chipzen.validate import validate_cli
41
+
42
+ validate_cli(remaining)
43
+ elif command == "init":
44
+ from chipzen.scaffold import init_cli
45
+
46
+ init_cli(remaining)
47
+ else:
48
+ print(f"Unknown command: {command}")
49
+ print()
50
+ _print_help()
51
+ sys.exit(1)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
chipzen/bot.py ADDED
@@ -0,0 +1,148 @@
1
+ """Abstract base class for Chipzen poker bots.
2
+
3
+ Subclass ``ChipzenBot`` and implement ``decide()`` to create your bot.
4
+
5
+ The public developer-facing API is stable across protocol revisions.
6
+ ``decide()`` still receives a :class:`GameState` regardless of whether the
7
+ SDK is talking to the old flat wire protocol or the new two-layer protocol.
8
+ Only the wire format and the optional lifecycle hooks change.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+
15
+ from chipzen.models import Action, Card, GameState
16
+
17
+
18
+ class ChipzenBot(ABC):
19
+ """Base class for all Chipzen poker bots.
20
+
21
+ At minimum you must implement :meth:`decide`. The other lifecycle hooks
22
+ are optional but useful for tracking state across hands.
23
+
24
+ Example::
25
+
26
+ from chipzen import Bot, GameState, Action
27
+
28
+ class MyBot(Bot):
29
+ def decide(self, state: GameState) -> Action:
30
+ if "check" in state.valid_actions:
31
+ return Action.check()
32
+ return Action.call()
33
+ """
34
+
35
+ @abstractmethod
36
+ def decide(self, state: GameState) -> Action:
37
+ """Return your action given the current game state.
38
+
39
+ This is called every time the server asks your bot to act.
40
+ You must respond quickly -- the server enforces a 5000ms timeout
41
+ by default (announced in the ``match_start.turn_timeout_ms`` field).
42
+
43
+ Args:
44
+ state: The current game state including your hole cards,
45
+ the board, pot size, and valid actions.
46
+
47
+ Returns:
48
+ The action to take.
49
+ """
50
+ ...
51
+
52
+ # ------------------------------------------------------------------
53
+ # Match-level hooks
54
+ # ------------------------------------------------------------------
55
+
56
+ def on_match_start(self, match_info: dict) -> None:
57
+ """Called once when the match begins.
58
+
59
+ Override this to initialize any match-level state. ``match_info`` is
60
+ the full ``match_start`` message including seat assignments and the
61
+ nested ``game_config`` (blinds, starting stack, hand count).
62
+
63
+ Args:
64
+ match_info: The full ``match_start`` message.
65
+ """
66
+
67
+ def on_match_end(self, results: dict) -> None:
68
+ """Called once when the match ends.
69
+
70
+ Args:
71
+ results: The full ``match_end`` message with final standings.
72
+ """
73
+
74
+ # ------------------------------------------------------------------
75
+ # Round (hand) hooks
76
+ # ------------------------------------------------------------------
77
+
78
+ def on_round_start(self, message: dict) -> None:
79
+ """Called at the start of each round (hand) with the raw message.
80
+
81
+ Override this if you need access to the full Layer 1 envelope
82
+ (``round_id``, ``round_number``) or the nested Layer 2 ``state``
83
+ payload (``deck_commitment``, per-seat ``stacks``).
84
+
85
+ The default implementation delegates to :meth:`on_hand_start` for
86
+ backward compatibility.
87
+ """
88
+ state = message.get("state", {}) or {}
89
+ hand_number = int(state.get("hand_number", 0))
90
+ hole_strs = state.get("your_hole_cards", [])
91
+ hole_cards = [Card.from_str(c) for c in hole_strs]
92
+ self.on_hand_start(hand_number, hole_cards)
93
+
94
+ def on_hand_start(self, hand_number: int, hole_cards: list[Card]) -> None:
95
+ """Called at the start of each hand.
96
+
97
+ Override this to do per-hand setup, reset trackers, etc.
98
+
99
+ Args:
100
+ hand_number: Which hand number in the match.
101
+ hole_cards: Your two private cards.
102
+ """
103
+
104
+ def on_round_result(self, message: dict) -> None:
105
+ """Called after each round (hand) with the raw message.
106
+
107
+ Override this if you need the full Layer 1 envelope plus the nested
108
+ Layer 2 ``result`` payload (``winner_seats``, ``pot``, ``payouts``,
109
+ ``showdown``, ``action_history``, ``deck_reveal``).
110
+
111
+ The default implementation delegates to :meth:`on_hand_result` with
112
+ the flattened result object for backward compatibility.
113
+ """
114
+ result = dict(message.get("result", {}) or {})
115
+ # Hoist the Layer 1 round_id so legacy bots that inspect it still work.
116
+ if "round_id" in message and "round_id" not in result:
117
+ result["round_id"] = message["round_id"]
118
+ self.on_hand_result(result)
119
+
120
+ def on_hand_result(self, result: dict) -> None:
121
+ """Called with hand results after each hand completes.
122
+
123
+ Override this to track opponent tendencies, win rates, etc.
124
+
125
+ Args:
126
+ result: The hand result payload. In the two-layer protocol this
127
+ is the ``round_result.result`` object; in the legacy flat
128
+ protocol (and in the local test harness) it is the full
129
+ ``hand_result`` message.
130
+ """
131
+
132
+ # ------------------------------------------------------------------
133
+ # Optional hooks for extra protocol signals
134
+ # ------------------------------------------------------------------
135
+
136
+ def on_phase_change(self, message: dict) -> None:
137
+ """Called when a new phase begins within a hand (flop/turn/river).
138
+
139
+ Default implementation is a no-op. Override to refresh internal
140
+ board tracking or trigger planning steps.
141
+ """
142
+
143
+ def on_turn_result(self, message: dict) -> None:
144
+ """Called after any participant's action is broadcast.
145
+
146
+ Default implementation is a no-op. Override to track opponent
147
+ action frequencies, timing patterns, etc.
148
+ """
chipzen/client.py ADDED
@@ -0,0 +1,440 @@
1
+ """WebSocket client for connecting a :class:`ChipzenBot` to a Chipzen server.
2
+
3
+ Implements the Chipzen two-layer protocol:
4
+
5
+ - Layer 1 (Transport): ``docs/protocol/TRANSPORT-PROTOCOL.md``
6
+ - Layer 2 (Poker): ``docs/protocol/POKER-GAME-STATE-PROTOCOL.md``
7
+
8
+ Handles the connection lifecycle: ``authenticate``, ``hello`` handshake,
9
+ ``match_start``, per-round ``round_start`` / ``turn_request`` /
10
+ ``turn_result`` / ``phase_change`` / ``round_result`` dispatch, heartbeat
11
+ ``ping``/``pong``, and safe handling of ``action_rejected`` retries.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import sys
20
+ from typing import Any
21
+
22
+ from chipzen.bot import ChipzenBot
23
+ from chipzen.models import Action, GameState
24
+
25
+ logger = logging.getLogger("chipzen")
26
+
27
+ # Protocol versions this client implements. Sent in the ``authenticate`` /
28
+ # client ``hello`` so the server can negotiate a mutually supported version.
29
+ SUPPORTED_PROTOCOL_VERSIONS = ["1.0"]
30
+
31
+
32
+ def _extract_match_id(url: str) -> str:
33
+ """Extract a match UUID from a ``.../ws/match/{match_id}/...`` URL."""
34
+ parts = url.rstrip("/").split("/")
35
+ for i, part in enumerate(parts):
36
+ if part == "match" and i + 1 < len(parts):
37
+ return parts[i + 1]
38
+ return "unknown"
39
+
40
+
41
+ def _safe_fallback_action(valid_actions: list[str]) -> Action:
42
+ """Return a safe fallback for an ``action_rejected`` retry.
43
+
44
+ Prefers ``check`` when legal, otherwise ``fold``. Mirrors the server's
45
+ auto-action policy (see TRANSPORT-PROTOCOL section 8.12).
46
+ """
47
+ if "check" in valid_actions:
48
+ return Action.check()
49
+ if "fold" in valid_actions:
50
+ return Action.fold()
51
+ # Last resort: echo the first valid action the server offered.
52
+ if valid_actions:
53
+ return Action(action=valid_actions[0])
54
+ return Action.fold()
55
+
56
+
57
+ async def _send_json(ws: Any, message: dict) -> None:
58
+ await ws.send(json.dumps(message))
59
+
60
+
61
+ async def run_bot(
62
+ url: str,
63
+ bot: ChipzenBot,
64
+ *,
65
+ max_retries: int = 3,
66
+ token: str | None = None,
67
+ ticket: str | None = None,
68
+ match_id: str | None = None,
69
+ client_name: str = "chipzen-sdk",
70
+ client_version: str = "0.2.0",
71
+ ) -> None:
72
+ """Connect a bot to the Chipzen server and play until the match ends.
73
+
74
+ Args:
75
+ url: WebSocket URL, e.g.
76
+ ``ws://localhost:8001/ws/match/{match_id}/{participant_id}``
77
+ or ``.../ws/match/{match_id}/bot`` for internal bots.
78
+ bot: Your bot instance.
79
+ max_retries: Number of reconnection attempts on unexpected disconnect.
80
+ token: Bot API token (for the ``/bot`` endpoint).
81
+ ticket: Single-use ticket (for competitive endpoints).
82
+ match_id: Match UUID. Extracted from the URL if not provided.
83
+ client_name: Client software name sent in the ``hello`` handshake.
84
+ client_version: Client software version sent in the ``hello`` handshake.
85
+ """
86
+ try:
87
+ from websockets.asyncio.client import connect
88
+ except ImportError:
89
+ try:
90
+ from websockets import connect # type: ignore[assignment]
91
+ except ImportError as exc:
92
+ raise ImportError(
93
+ "The 'websockets' package is required. Install it with:\n pip install websockets"
94
+ ) from exc
95
+
96
+ if match_id is None:
97
+ match_id = _extract_match_id(url)
98
+
99
+ retries = 0
100
+ while retries <= max_retries:
101
+ try:
102
+ async with connect(url) as ws:
103
+ retries = 0 # reset on successful connect
104
+ await _run_session(
105
+ ws,
106
+ bot,
107
+ match_id=match_id,
108
+ token=token,
109
+ ticket=ticket,
110
+ client_name=client_name,
111
+ client_version=client_version,
112
+ )
113
+ # _run_session returns cleanly on match_end.
114
+ return
115
+
116
+ except asyncio.CancelledError:
117
+ raise
118
+ except Exception:
119
+ retries += 1
120
+ if retries > max_retries:
121
+ logger.exception("Max reconnection attempts reached, giving up")
122
+ raise
123
+ wait = min(2**retries, 8)
124
+ logger.warning(
125
+ "Connection lost, retrying in %ds (attempt %d/%d)",
126
+ wait,
127
+ retries,
128
+ max_retries,
129
+ )
130
+ await asyncio.sleep(wait)
131
+
132
+
133
+ async def _run_session(
134
+ ws: Any,
135
+ bot: ChipzenBot,
136
+ *,
137
+ match_id: str,
138
+ token: str | None,
139
+ ticket: str | None,
140
+ client_name: str,
141
+ client_version: str,
142
+ ) -> None:
143
+ """Execute a single connected session: handshake + message loop."""
144
+ # --- Layer 1 handshake --------------------------------------------
145
+ auth_msg: dict[str, Any] = {
146
+ "type": "authenticate",
147
+ "match_id": match_id,
148
+ }
149
+ if token is not None:
150
+ auth_msg["token"] = token
151
+ elif ticket is not None:
152
+ auth_msg["ticket"] = ticket
153
+ else:
154
+ # Sidecar / localhost dev may accept an empty token. Production
155
+ # endpoints require one of {token, ticket}.
156
+ auth_msg["token"] = ""
157
+ await _send_json(ws, auth_msg)
158
+
159
+ raw_hello = await ws.recv()
160
+ server_hello = json.loads(raw_hello)
161
+ if server_hello.get("type") != "hello":
162
+ logger.error(
163
+ "Expected 'hello' from server, got %r",
164
+ server_hello.get("type"),
165
+ )
166
+ return
167
+
168
+ selected_version = server_hello.get("selected_version")
169
+ server_versions = server_hello.get("supported_versions", []) or []
170
+ if selected_version and selected_version not in SUPPORTED_PROTOCOL_VERSIONS:
171
+ logger.error(
172
+ "Server selected unsupported protocol version %r (client supports %s)",
173
+ selected_version,
174
+ SUPPORTED_PROTOCOL_VERSIONS,
175
+ )
176
+ return
177
+ if not selected_version and server_versions:
178
+ if not any(v in SUPPORTED_PROTOCOL_VERSIONS for v in server_versions):
179
+ logger.error(
180
+ "No mutually supported protocol version (server=%s, client=%s)",
181
+ server_versions,
182
+ SUPPORTED_PROTOCOL_VERSIONS,
183
+ )
184
+ return
185
+
186
+ await _send_json(
187
+ ws,
188
+ {
189
+ "type": "hello",
190
+ "match_id": match_id,
191
+ "supported_versions": SUPPORTED_PROTOCOL_VERSIONS,
192
+ "client_name": client_name,
193
+ "client_version": client_version,
194
+ },
195
+ )
196
+ logger.info(
197
+ "Handshake complete: version=%s game_type=%s",
198
+ selected_version or "?",
199
+ server_hello.get("game_type", "?"),
200
+ )
201
+
202
+ # --- Session state tracked across messages ------------------------
203
+ your_seat: int = 0
204
+ dealer_seat: int = 0
205
+ current_round_id: str = ""
206
+ last_seq: int | None = None
207
+
208
+ # --- Main message loop --------------------------------------------
209
+ async for raw in ws:
210
+ try:
211
+ payload: dict[str, Any] = json.loads(raw)
212
+ except (TypeError, ValueError):
213
+ logger.warning("Received non-JSON frame, ignoring")
214
+ continue
215
+
216
+ msg_type = payload.get("type")
217
+ seq = payload.get("seq")
218
+ if isinstance(seq, int):
219
+ if last_seq is not None and seq != last_seq + 1:
220
+ logger.warning(
221
+ "Sequence gap detected: expected %d, got %d",
222
+ last_seq + 1,
223
+ seq,
224
+ )
225
+ last_seq = seq
226
+
227
+ if msg_type == "ping":
228
+ # Heartbeat: server expects a ``pong`` within 5000ms.
229
+ await _send_json(ws, {"type": "pong", "match_id": match_id})
230
+
231
+ elif msg_type == "session_token":
232
+ # Informational -- SDK does not currently use the session token.
233
+ logger.debug("Received session_token")
234
+
235
+ elif msg_type == "match_start":
236
+ # Determine this bot's seat from the seats array.
237
+ for seat_info in payload.get("seats", []) or []:
238
+ if seat_info.get("is_self"):
239
+ your_seat = int(seat_info.get("seat", 0))
240
+ break
241
+ bot.on_match_start(payload)
242
+
243
+ elif msg_type == "round_start":
244
+ state = payload.get("state", {}) or {}
245
+ dealer_seat = int(state.get("dealer_seat", dealer_seat))
246
+ current_round_id = str(payload.get("round_id", current_round_id))
247
+ bot.on_round_start(payload)
248
+
249
+ elif msg_type == "turn_request":
250
+ # ``turn_request`` has no round_id of its own; inject the one we
251
+ # learned from the most recent ``round_start`` so the bot can
252
+ # correlate turns to rounds.
253
+ if "round_id" not in payload and current_round_id:
254
+ payload = {**payload, "round_id": current_round_id}
255
+ state = GameState.from_turn_request(
256
+ payload,
257
+ your_seat=your_seat,
258
+ dealer_seat=dealer_seat,
259
+ )
260
+ try:
261
+ action = bot.decide(state)
262
+ except Exception:
263
+ logger.exception("Bot.decide() raised an exception, folding")
264
+ action = Action.fold()
265
+
266
+ await _send_json(
267
+ ws,
268
+ {
269
+ "type": "turn_action",
270
+ "match_id": match_id,
271
+ "request_id": payload.get("request_id"), # MUST echo
272
+ **action.to_wire(),
273
+ },
274
+ )
275
+
276
+ elif msg_type == "action_rejected":
277
+ # Retry within ``remaining_ms`` using the SAME request_id.
278
+ reason = payload.get("reason")
279
+ message = payload.get("message", "")
280
+ remaining = payload.get("remaining_ms", 0)
281
+ logger.warning(
282
+ "Action rejected (%s): %s -- %dms remaining, retrying with safe fallback",
283
+ reason,
284
+ message,
285
+ remaining,
286
+ )
287
+ # For the safe fallback we need to know what's still legal; the
288
+ # rejection message does not include valid_actions, so we fall
289
+ # back to check-or-fold which is always safe per the server's
290
+ # auto-action policy.
291
+ safe = _safe_fallback_action(["check", "fold"])
292
+ await _send_json(
293
+ ws,
294
+ {
295
+ "type": "turn_action",
296
+ "match_id": match_id,
297
+ "request_id": payload.get("request_id"),
298
+ **safe.to_wire(),
299
+ },
300
+ )
301
+
302
+ elif msg_type == "turn_result":
303
+ bot.on_turn_result(payload)
304
+
305
+ elif msg_type == "phase_change":
306
+ bot.on_phase_change(payload)
307
+
308
+ elif msg_type == "round_result":
309
+ bot.on_round_result(payload)
310
+
311
+ elif msg_type == "action_timeout":
312
+ logger.info(
313
+ "Server auto-applied %s after timeout",
314
+ payload.get("auto_action", "?"),
315
+ )
316
+
317
+ elif msg_type == "session_control":
318
+ logger.info(
319
+ "Session control: %s (%s)",
320
+ payload.get("action"),
321
+ payload.get("reason"),
322
+ )
323
+
324
+ elif msg_type == "error":
325
+ logger.error(
326
+ "Server error [%s]: %s",
327
+ payload.get("code"),
328
+ payload.get("message"),
329
+ )
330
+
331
+ elif msg_type == "reconnected":
332
+ # We are mid-session after a reconnect; resume.
333
+ logger.info("Reconnected at round %s", payload.get("round_number"))
334
+ pending = payload.get("pending_request")
335
+ if pending:
336
+ # Treat the pending request exactly like a ``turn_request``.
337
+ state = GameState.from_turn_request(
338
+ pending,
339
+ your_seat=your_seat,
340
+ dealer_seat=dealer_seat,
341
+ )
342
+ try:
343
+ action = bot.decide(state)
344
+ except Exception:
345
+ logger.exception("Bot.decide() raised an exception, folding")
346
+ action = Action.fold()
347
+ await _send_json(
348
+ ws,
349
+ {
350
+ "type": "turn_action",
351
+ "match_id": match_id,
352
+ "request_id": pending.get("request_id"),
353
+ **action.to_wire(),
354
+ },
355
+ )
356
+
357
+ elif msg_type == "match_end":
358
+ bot.on_match_end(payload)
359
+ return
360
+
361
+ else:
362
+ # Forward compatibility: silently ignore unknown message types.
363
+ logger.debug("Ignoring unknown message type %r", msg_type)
364
+
365
+
366
+ def _import_bot(specifier: str) -> ChipzenBot:
367
+ """Import a bot from a ``module:ClassName`` specifier.
368
+
369
+ Example: ``my_bot:MyBot`` imports ``MyBot`` from ``my_bot.py``.
370
+ """
371
+ if ":" not in specifier:
372
+ raise ValueError(f"Bot specifier must be 'module:ClassName', got {specifier!r}")
373
+ module_path, class_name = specifier.rsplit(":", 1)
374
+
375
+ import importlib
376
+
377
+ # Add cwd to sys.path so local modules resolve
378
+ if "" not in sys.path:
379
+ sys.path.insert(0, "")
380
+
381
+ module = importlib.import_module(module_path)
382
+ cls = getattr(module, class_name)
383
+ instance = cls()
384
+ if not isinstance(instance, ChipzenBot):
385
+ raise TypeError(
386
+ f"{class_name} must be a subclass of ChipzenBot, got {type(instance).__name__}"
387
+ )
388
+ return instance
389
+
390
+
391
+ def connect_cli(args: list[str] | None = None) -> None:
392
+ """CLI entry point: connect a bot to a server.
393
+
394
+ Usage::
395
+
396
+ python -m chipzen connect --url ws://... --bot my_bot:MyBot
397
+ """
398
+ import argparse
399
+
400
+ parser = argparse.ArgumentParser(description="Connect a Chipzen poker bot to a server")
401
+ parser.add_argument(
402
+ "--url",
403
+ required=True,
404
+ help="WebSocket URL (ws://host/ws/match/{match_id}/{participant_id})",
405
+ )
406
+ parser.add_argument(
407
+ "--bot",
408
+ required=True,
409
+ help="Bot specifier as module:ClassName (e.g. my_bot:MyBot)",
410
+ )
411
+ parser.add_argument(
412
+ "--token",
413
+ help="Bot API token for authentication",
414
+ )
415
+ parser.add_argument(
416
+ "--ticket",
417
+ help="Single-use ticket for authentication",
418
+ )
419
+ parser.add_argument(
420
+ "--retries",
421
+ type=int,
422
+ default=3,
423
+ help="Max reconnection attempts (default: 3)",
424
+ )
425
+
426
+ parsed = parser.parse_args(args)
427
+ bot = _import_bot(parsed.bot)
428
+
429
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
430
+ logger.info("Connecting to %s", parsed.url)
431
+
432
+ asyncio.run(
433
+ run_bot(
434
+ parsed.url,
435
+ bot,
436
+ max_retries=parsed.retries,
437
+ token=parsed.token,
438
+ ticket=parsed.ticket,
439
+ )
440
+ )