pokerbots-dev 0.1.3__tar.gz → 0.1.5__tar.gz

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.
Files changed (28) hide show
  1. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/PKG-INFO +1 -1
  2. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/pyproject.toml +1 -1
  3. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/agent.py +12 -7
  4. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/api.py +1 -1
  5. pokerbots_dev-0.1.5/src/pokerbots_cli/commands/__init__.py +3 -0
  6. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/commands/account.py +37 -7
  7. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/commands/admin.py +1 -1
  8. pokerbots_dev-0.1.5/src/pokerbots_cli/commands/history.py +53 -0
  9. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/commands/local.py +2 -2
  10. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/commands/money.py +15 -6
  11. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/docs.md +50 -11
  12. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/engine.py +66 -11
  13. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/fmt.py +2 -1
  14. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/tests/test_agent.py +11 -1
  15. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/tests/test_cli.py +3 -1
  16. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/tests/test_engine.py +19 -2
  17. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/tests/test_wallet.py +1 -1
  18. pokerbots_dev-0.1.3/src/pokerbots_cli/commands/__init__.py +0 -3
  19. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/.gitignore +0 -0
  20. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/README.md +0 -0
  21. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/__init__.py +0 -0
  22. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/auth.py +0 -0
  23. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/cards.py +0 -0
  24. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/house.py +0 -0
  25. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/main.py +0 -0
  26. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/template/bot.py +0 -0
  27. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/src/pokerbots_cli/wallet.py +0 -0
  28. {pokerbots_dev-0.1.3 → pokerbots_dev-0.1.5}/tests/test_cards.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pokerbots-dev
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: pb: command-line client for pokerbots.dev
5
5
  Project-URL: Homepage, https://pokerbots.dev
6
6
  Project-URL: Source, https://github.com/pokerbots-dev/pokerbots
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pokerbots-dev"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "pb: command-line client for pokerbots.dev"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,7 +1,8 @@
1
1
  import traceback
2
- from time import perf_counter
2
+ from time import perf_counter, thread_time
3
3
 
4
- LIMIT = 0.020
4
+ LIMIT = 0.020 # CPU seconds per decision
5
+ WALL = 0.100 # backstop so a bot cannot stall the table
5
6
 
6
7
 
7
8
  def fallback(state):
@@ -19,7 +20,10 @@ def normalize(action, state):
19
20
 
20
21
 
21
22
  class Agent:
22
- """Runs an untrusted bot: enforces the time limit and returns only legal actions."""
23
+ """Runs an untrusted bot: enforces the CPU and wall limits and returns only legal actions.
24
+
25
+ CPU time comes from bot.cpu() when the bot runs elsewhere (a sandboxed process), else this thread.
26
+ """
23
27
 
24
28
  def __init__(self, bot, limit=LIMIT):
25
29
  self.bot = bot
@@ -30,14 +34,15 @@ class Agent:
30
34
 
31
35
  def __call__(self, state):
32
36
  self.decisions += 1
33
- start = perf_counter()
37
+ clock = getattr(self.bot, "cpu", thread_time)
38
+ wall, cpu = perf_counter(), clock()
34
39
  try:
35
40
  action = self.bot.act(state)
36
41
  except Exception:
37
42
  return self.fail(state, traceback.format_exc())
38
- elapsed = perf_counter() - start
39
- self.slowest = max(self.slowest, elapsed)
40
- if elapsed > self.limit:
43
+ wall, cpu = perf_counter() - wall, clock() - cpu
44
+ self.slowest = max(self.slowest, cpu)
45
+ if cpu > self.limit or wall > WALL:
41
46
  self.late += 1
42
47
  return fallback(state)
43
48
  return normalize(action, state) or self.fail(state, f"invalid action {action!r}, legal {state['legal']}")
@@ -35,7 +35,7 @@ def call(method, path, body=None, data=None, headers=None):
35
35
  message = f"{e.code} {e.reason}"
36
36
  raise ApiError(message) from None
37
37
  except urllib.error.URLError as e:
38
- raise ApiError(str(e.reason)) from None
38
+ raise ApiError(f"cannot reach {base()}: {e.reason}") from None
39
39
 
40
40
 
41
41
  def admin(method, path, body=None):
@@ -0,0 +1,3 @@
1
+ from . import account, admin, history, local, money
2
+
3
+ MODULES = (account, history, local, money, admin)
@@ -53,7 +53,12 @@ def deploy(args):
53
53
  def stop(args):
54
54
  mode = "live" if args.live else "paper"
55
55
  call("DELETE", f"/deployments/{mode}")
56
- print(f"stopped {mode}")
56
+ print(f"stopped {mode}; games already queued will still play")
57
+
58
+
59
+ def rank(mode):
60
+ board = call("GET", f"/leaderboard/{mode}")
61
+ return f"#{board['you']} of {board['ranked']}" if board["you"] else f"unranked until {board['min_games']} games"
57
62
 
58
63
 
59
64
  def report():
@@ -61,7 +66,7 @@ def report():
61
66
  rows = [["user", me["login"]], ["versions", " ".join(f"v{v}" for v in me["versions"]) or "-"]]
62
67
  for mode in ("paper", "live"):
63
68
  d = me["deployments"].get(mode)
64
- rows.append([mode, f"v{d['version']} rating {d['rating']:.0f} games {d['games']} today {d['today']}" if d else "-"])
69
+ rows.append([mode, f"v{d['version']} rating {d['rating']:.0f} games {d['games']} today {d['today']} {rank(mode)}" if d else "-"])
65
70
  rows += [["balance", money(me["balance"])], ["wallet", me["wallet"] or "-"]]
66
71
  text = table(rows, labels=True)
67
72
  games = call("GET", "/games")
@@ -76,16 +81,26 @@ def report():
76
81
  return text
77
82
 
78
83
 
79
- def status(args):
80
- print(report())
84
+ def leaderboard(mode):
85
+ board = call("GET", f"/leaderboard/{mode}")
86
+ live = mode == "live"
87
+ head = ["rank", "user", "rating", "games"] + (["won"] if live else [])
88
+ rows = [[i + 1, r["login"], f"{r['rating']:.0f}", r["games"]] + ([signed(r["won"], "money")] if live else [])
89
+ for i, r in enumerate(board["rows"])]
90
+ text = f"{mode} leaderboard\n\n" + (table([head] + rows, header=True) if rows else f"no bots with {board['min_games']} games yet")
91
+ you = f"#{board['you']} of {board['ranked']}" if board["you"] else f"unranked until {board['min_games']} games"
92
+ return text + "\n\n" + table([["you", you]], labels=True)
81
93
 
82
94
 
83
- def watch(args):
95
+ def screen(render):
96
+ """Full-screen, flicker-free view in the homepage colours, redrawn until ctrl-c."""
97
+ if not sys.stdout.isatty():
98
+ return print(render())
84
99
  paint = f"\x1b[48;2;{INK}m\x1b[38;2;{PAPER}m" if colored() else ""
85
100
  sys.stdout.write("\x1b[?1049h\x1b[?25l" + paint + "\x1b[2J")
86
101
  try:
87
102
  while True:
88
- lines = ["", *(" " + line for line in report().split("\n")), "", " " + "ctrl-c to quit"]
103
+ lines = ["", *(" " + line for line in render().split("\n")), "", " ctrl-c to quit"]
89
104
  sys.stdout.write("\x1b[H" + "".join(line + "\x1b[K\n" for line in lines) + "\x1b[J")
90
105
  sys.stdout.flush()
91
106
  time.sleep(WATCH_SECONDS)
@@ -95,8 +110,24 @@ def watch(args):
95
110
  sys.stdout.write("\x1b[0m\x1b[?25h\x1b[?1049l")
96
111
 
97
112
 
113
+ def status(args):
114
+ print(report())
115
+
116
+
117
+ def watch(args):
118
+ screen(report)
119
+
120
+
121
+ def top(args):
122
+ screen(lambda: leaderboard("live" if args.live else "paper"))
123
+
124
+
98
125
  def register(sub):
99
126
  sub.add_parser("login", help="sign in with GitHub").set_defaults(run=login)
127
+ sub.add_parser("watch", help="status, live").set_defaults(run=watch)
128
+ p = sub.add_parser("top", help="leaderboard, live")
129
+ p.add_argument("--live", action="store_true", help="live play instead of paper")
130
+ p.set_defaults(run=top)
100
131
 
101
132
  p = sub.add_parser("upload", help="upload a new version")
102
133
  p.add_argument("dir", nargs="?", default=".")
@@ -112,4 +143,3 @@ def register(sub):
112
143
  p.set_defaults(run=run)
113
144
 
114
145
  sub.add_parser("status", help="your bot, rating, balance and recent games").set_defaults(run=status)
115
- sub.add_parser("watch", help="status, live").set_defaults(run=watch)
@@ -26,7 +26,7 @@ def decide(args):
26
26
 
27
27
 
28
28
  def register(sub):
29
- p = sub.add_parser("admin", help="operator commands")
29
+ p = sub.add_parser("admin")
30
30
  admin_sub = p.add_subparsers(metavar="command", required=True)
31
31
  admin_sub.add_parser("stats").set_defaults(run=stats)
32
32
  admin_sub.add_parser("withdrawals", help="pending withdrawals").set_defaults(run=withdrawals)
@@ -0,0 +1,53 @@
1
+ import json
2
+ from datetime import date, timedelta
3
+ from pathlib import Path
4
+ from urllib.parse import urlencode
5
+
6
+ from ..api import call
7
+
8
+ DEFAULT_DAYS = 7
9
+
10
+
11
+ def known_ids(directory):
12
+ return {json.loads(line)["id"] for f in directory.glob("*.jsonl") for line in f.read_text().splitlines() if line}
13
+
14
+
15
+ def history(args):
16
+ out = Path(args.out)
17
+ out.mkdir(parents=True, exist_ok=True)
18
+ have = known_ids(out)
19
+ params = {
20
+ "since": args.since or (date.today() - timedelta(days=DEFAULT_DAYS)).isoformat(),
21
+ "until": args.until or date.today().isoformat(),
22
+ }
23
+ if args.live or args.paper:
24
+ params["mode"] = "live" if args.live else "paper"
25
+ if args.all:
26
+ params["all"] = "1"
27
+ saved = skipped = 0
28
+ after = None
29
+ while True:
30
+ page = call("GET", "/history?" + urlencode({**params, **({"after": after} if after else {})}))
31
+ for game in page["games"]:
32
+ if game["id"] in have:
33
+ skipped += 1
34
+ continue
35
+ with open(out / f"{game['finished_at'][:10]}.jsonl", "a") as f:
36
+ f.write(json.dumps(game) + "\n")
37
+ saved += 1
38
+ after = page["next"]
39
+ if not after:
40
+ break
41
+ print(f"saved {saved} games to {out}/" + (f", {skipped} already there" if skipped else ""))
42
+
43
+
44
+ def register(sub):
45
+ p = sub.add_parser("history", help="download finished games as JSON lines")
46
+ p.add_argument("--since", metavar="YYYY-MM-DD", help=f"default {DEFAULT_DAYS} days ago")
47
+ p.add_argument("--until", metavar="YYYY-MM-DD", help="default today")
48
+ mode = p.add_mutually_exclusive_group()
49
+ mode.add_argument("--paper", action="store_true")
50
+ mode.add_argument("--live", action="store_true")
51
+ p.add_argument("--all", action="store_true", help="every player's games, not just yours")
52
+ p.add_argument("-o", "--out", default="history", help="directory, default ./history")
53
+ p.set_defaults(run=history)
@@ -58,9 +58,9 @@ def test(args):
58
58
  ["2nd", places.count(2)],
59
59
  ["avg place", f"{sum(places) / len(places):.1f}"],
60
60
  ["decisions", mine.decisions],
61
- ["over 20 ms", mine.late],
61
+ ["late", mine.late],
62
62
  ["errors", mine.errors],
63
- ["slowest", f"{mine.slowest * 1000:.2f} ms"],
63
+ ["slowest", f"{mine.slowest * 1000:.2f} ms cpu"],
64
64
  ]))
65
65
  if mine.error:
66
66
  print(f"\nfirst error\n{mine.error}")
@@ -1,3 +1,5 @@
1
+ import sys
2
+
1
3
  from .. import wallet as keys
2
4
  from ..api import call
3
5
  from ..fmt import money, parse_money, table
@@ -6,25 +8,32 @@ from ..fmt import money, parse_money, table
6
8
  def registered_key():
7
9
  key = keys.load_or_create()
8
10
  address = keys.address(key)
9
- if call("GET", "/me")["wallet"] != address:
11
+ me = call("GET", "/me")
12
+ if me["wallet"] != address:
10
13
  call("PUT", "/wallet", {"address": address})
11
- return key, address
14
+ return key, address, me
12
15
 
13
16
 
14
17
  def wallet(args):
15
- _, address = registered_key()
16
- print(table([["address", address], ["balance", money(call("GET", "/me")["balance"])]]))
18
+ _, address, me = registered_key()
19
+ rows = [["address", address], ["balance", money(me["balance"])]]
20
+ if me["withdraw_to"] and me["withdraw_to"] != address:
21
+ rows.append(["withdraws to", me["withdraw_to"]])
22
+ print(table(rows, labels=True))
17
23
 
18
24
 
19
25
  def deposit(args):
20
- key, _ = registered_key()
26
+ key, _, _ = registered_key()
21
27
  prepared = call("POST", "/deposits", {"amount": parse_money(args.amount)})
22
28
  reply = call("POST", f"/deposits/{prepared['signature']}", {"transaction": keys.sign(prepared["transaction"], key)})
23
29
  print(f"deposit {reply['status']}: {money(reply['amount'])}")
24
30
 
25
31
 
26
32
  def withdraw(args):
27
- registered_key()
33
+ _, address, me = registered_key()
34
+ if me["withdraw_to"] and me["withdraw_to"] != address:
35
+ sys.exit(f"withdrawals go to {me['withdraw_to']}, the wallet you last deposited from.\n"
36
+ f"to use this wallet instead, deposit from it once: pb deposit 0.10")
28
37
  reply = call("POST", "/withdrawals", {"amount": parse_money(args.amount)})
29
38
  print(f"withdrawal {reply['status']}: {money(reply['amount'])}")
30
39
 
@@ -7,9 +7,11 @@ start
7
7
  pb upload [dir] upload a new version: v1, v2, ...
8
8
  pb deploy v1 --paper play free games with v1
9
9
  pb deploy v1 --live play for money with v1
10
- pb stop --paper|--live stop playing
10
+ pb stop --paper|--live stop playing; queued games still play
11
11
  pb status versions, rating, balance, recent games
12
12
  pb watch the same, refreshed every 5 seconds
13
+ pb top [--live] leaderboard: rating order, 20+ games; live shows won
14
+ pb history download your finished games, see history below
13
15
  pb docs [--plain] show this
14
16
 
15
17
  money
@@ -19,7 +21,8 @@ money
19
21
 
20
22
  Your wallet key is ~/.config/pokerbots/wallet.json and never leaves your
21
23
  machine. Send USDC on Solana to its address, then deposit. We pay the
22
- network fees. Large withdrawals may wait for review.
24
+ network fees. Withdrawals go to the wallet you last deposited from.
25
+ Large withdrawals may wait for review.
23
26
 
24
27
  bot.py
25
28
  class Bot:
@@ -29,8 +32,9 @@ bot.py
29
32
  return ("fold",)
30
33
 
31
34
  A new Bot is created for every game. act is called for every decision.
32
- A late reply (over 20 ms), an error or an illegal action counts as
33
- check, or fold if check is not legal. Uploads are the bot's directory,
35
+ Each decision gets 20 ms of CPU time, and must reply within 100 ms.
36
+ A late reply, an error or an illegal action counts as check, or fold
37
+ if check is not legal. Uploads are the bot's directory,
34
38
  up to 1 MB zipped. Bots run with the Python standard library only, no
35
39
  network and no file writes.
36
40
 
@@ -41,12 +45,34 @@ actions
41
45
  ("raise", n) n is your total bet this round, from min_raise to max_raise
42
46
 
43
47
  state
44
- hand hand number, from 1
45
- seat your seat, 0-5
46
- button button seat
47
- blinds [small, big]
48
- hole your two cards, e.g. ["As", "Td"]
49
- board shared cards
48
+ hand hand number, from 1
49
+ hands_left hands until the game ends at 100
50
+ street preflop, flop, turn or river
51
+ seat your seat, 0-5
52
+ players login by seat, or "house"
53
+ button button seat
54
+ small_blind small blind seat
55
+ big_blind big blind seat
56
+ blinds [small, big] amounts
57
+ hole your two cards, e.g. ["As", "Td"]
58
+ board shared cards
59
+ stacks_start chips by seat when this hand began
60
+ stacks chips behind, by seat
61
+ bets chips bet this round, by seat
62
+ current_bet the bet to match this round
63
+ pot chips bet this hand
64
+ to_call chips needed to call
65
+ legal actions allowed now
66
+ min_raise smallest raise total, when raise is legal
67
+ max_raise all in, when raise is legal
68
+ live seats still in the hand
69
+ all_in seats in the hand with no chips behind
70
+ out eliminated seats, first out first
71
+ actions this hand so far, [cards on board, seat, action, bet this round]
72
+ past finished hands this game: hand, board, shown (cards by seat,
73
+ null unless shown down), won (chips by seat)
74
+
75
+ cards
50
76
  stacks chips behind, by seat
51
77
  bets chips bet this round, by seat
52
78
  pot chips bet this hand
@@ -68,13 +94,26 @@ game
68
94
  blinds on hand n: n / 2n
69
95
  ends at last bot standing or 100 hands
70
96
  at 100 hands, places go by chips
71
- 20 ms per decision, else check/fold
97
+ 20 ms CPU per decision, else check/fold
72
98
  up to 5 games a minute per bot
73
99
  paper tables are filled with house bots; live tables are 6 players
74
100
 
75
101
  rating
76
102
  Elo, from 1500; each game counts as a win over every bot placed below
77
103
 
104
+ history
105
+ pb history [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--paper|--live] [--all] [-o dir]
106
+
107
+ One JSON line per finished game, one file per day: history/2026-10-29.jsonl.
108
+ Games appear 10 minutes after they end. Default: your games, last 7 days.
109
+ --all adds everyone's games. You see your own hole cards and any cards
110
+ shown down; folded cards stay hidden. Re-running skips games you have.
111
+
112
+ game id, mode, finished_at, hands_played, you (your seat),
113
+ players [seat, user, place, rating_change, won ($, live)],
114
+ hands [hand, button, small_blind, big_blind, blinds, stacks_start,
115
+ hole, board, actions, shown, won]
116
+
78
117
  price
79
118
  paper free, 1,000 games/day
80
119
 
@@ -1,10 +1,11 @@
1
1
  import random
2
- from dataclasses import dataclass
2
+ from dataclasses import dataclass, field
3
3
 
4
4
  from .cards import DECK, best
5
5
 
6
6
  STACK = 1000
7
7
  MAX_HANDS = 100
8
+ STREETS = ("preflop", "flop", "turn", "river")
8
9
 
9
10
 
10
11
  @dataclass
@@ -28,31 +29,52 @@ class Player:
28
29
  self.committed += chips
29
30
 
30
31
 
32
+ @dataclass
33
+ class Game:
34
+ """What every hand of one game shares: names, finished hands, eliminations."""
35
+ names: list
36
+ max_hands: int = MAX_HANDS
37
+ past: list = field(default_factory=list) # public summaries, as bots see them
38
+ hands: list = field(default_factory=list) # full records, every hole card
39
+ out: list = field(default_factory=list)
40
+
41
+
31
42
  @dataclass
32
43
  class Result:
33
44
  places: list
34
45
  hands: int
35
46
  stacks: list
47
+ history: list
48
+
49
+
50
+ def per_seat(n, values=None, default=None):
51
+ out = [default] * n
52
+ for seat, value in (values or {}).items():
53
+ out[seat] = value
54
+ return out
36
55
 
37
56
 
38
57
  class Hand:
39
- def __init__(self, number, players, button, deck, seats):
58
+ def __init__(self, number, players, button, deck, game):
40
59
  self.number = number
41
60
  self.players = players
42
61
  self.button = button
43
62
  self.deck = deck
44
- self.seats = seats
63
+ self.game = game
64
+ self.seats = len(game.names)
45
65
  self.blinds = [number, 2 * number]
46
66
  self.board = []
47
67
  self.log = []
48
68
 
49
69
  def play(self):
50
70
  n = len(self.players)
71
+ self.stacks_start = per_seat(self.seats, {p.seat: p.stack for p in self.players}, 0)
51
72
  for p in self.players:
52
73
  p.hole = (self.deck.pop(), self.deck.pop())
53
74
  p.bet = p.committed = 0
54
75
  p.folded = False
55
76
  small = self.button if n == 2 else (self.button + 1) % n
77
+ self.blind_seats = [self.players[small].seat, self.players[(small + 1) % n].seat]
56
78
  for i, blind in zip((small, (small + 1) % n), self.blinds):
57
79
  self.players[i].put(blind)
58
80
  self.record(self.players[i], "blind")
@@ -64,7 +86,7 @@ class Hand:
64
86
  p.bet = 0
65
87
  self.board += [self.deck.pop() for _ in range(count)]
66
88
  self.betting((self.button + 1) % n, 0)
67
- self.award()
89
+ self.finish(self.award())
68
90
 
69
91
  def betting(self, start, current):
70
92
  order = self.players[start:] + self.players[:start]
@@ -77,7 +99,7 @@ class Hand:
77
99
  continue
78
100
  to_call = current - p.bet
79
101
  legal = ["fold", "call"] if to_call > 0 else ["check"]
80
- state = self.state(p, legal, to_call)
102
+ state = self.state(p, legal, to_call, current)
81
103
  if others and p.stack > to_call:
82
104
  legal.append("raise")
83
105
  state["max_raise"] = p.bet + p.stack
@@ -98,29 +120,40 @@ class Hand:
98
120
  def record(self, p, action):
99
121
  self.log.append([len(self.board), p.seat, action, p.bet])
100
122
 
101
- def state(self, p, legal, to_call):
123
+ def state(self, p, legal, to_call, current):
102
124
  stacks, bets = [0] * self.seats, [0] * self.seats
103
125
  for q in self.players:
104
126
  stacks[q.seat], bets[q.seat] = q.stack, q.bet
105
127
  return {
106
128
  "hand": self.number,
129
+ "hands_left": self.game.max_hands - self.number,
130
+ "street": STREETS[max(0, len(self.board) - 2)],
107
131
  "seat": p.seat,
132
+ "players": list(self.game.names),
108
133
  "button": self.players[self.button].seat,
134
+ "small_blind": self.blind_seats[0],
135
+ "big_blind": self.blind_seats[1],
109
136
  "blinds": list(self.blinds),
110
137
  "hole": list(p.hole),
111
138
  "board": list(self.board),
139
+ "stacks_start": list(self.stacks_start),
112
140
  "stacks": stacks,
113
141
  "bets": bets,
142
+ "current_bet": current,
114
143
  "pot": sum(q.committed for q in self.players),
115
144
  "to_call": to_call,
116
145
  "legal": legal,
117
146
  "live": [q.seat for q in self.players if not q.folded],
147
+ "all_in": [q.seat for q in self.players if not q.folded and q.stack == 0],
148
+ "out": list(self.game.out),
118
149
  "actions": [list(a) for a in self.log],
150
+ "past": list(self.game.past),
119
151
  }
120
152
 
121
153
  def award(self):
122
154
  live = [p for p in self.players if not p.folded]
123
155
  strength = {p.seat: best(p.hole + tuple(self.board)) for p in live} if len(live) > 1 else {}
156
+ won = {}
124
157
  previous = 0
125
158
  for level in sorted({p.committed for p in self.players}):
126
159
  pot = sum(min(p.committed, level) - min(p.committed, previous) for p in self.players)
@@ -131,12 +164,34 @@ class Hand:
131
164
  share, odd = divmod(pot, len(winners))
132
165
  for i, w in enumerate(winners):
133
166
  w.stack += share + (i < odd)
167
+ won[w.seat] = won.get(w.seat, 0) + share + (i < odd)
168
+ return won
169
+
170
+ def finish(self, won):
171
+ shown = {p.seat: list(p.hole) for p in self.players if not p.folded} if sum(not p.folded for p in self.players) > 1 else {}
172
+ summary = {
173
+ "hand": self.number,
174
+ "board": list(self.board),
175
+ "shown": per_seat(self.seats, shown),
176
+ "won": per_seat(self.seats, won, 0),
177
+ }
178
+ self.game.past.append(summary)
179
+ self.game.hands.append({
180
+ **summary,
181
+ "button": self.players[self.button].seat,
182
+ "small_blind": self.blind_seats[0],
183
+ "big_blind": self.blind_seats[1],
184
+ "blinds": list(self.blinds),
185
+ "stacks_start": list(self.stacks_start),
186
+ "hole": per_seat(self.seats, {p.seat: list(p.hole) for p in self.players}),
187
+ "actions": [list(a) for a in self.log],
188
+ })
134
189
 
135
190
 
136
- def play(agents, rng=None, max_hands=MAX_HANDS):
191
+ def play(agents, rng=None, max_hands=MAX_HANDS, names=None):
137
192
  rng = rng or random.Random()
138
193
  table = [Player(seat, agent) for seat, agent in enumerate(agents)]
139
- busted = []
194
+ game = Game(list(names or [f"seat {i}" for i in range(len(table))]), max_hands)
140
195
  button = rng.randrange(len(table))
141
196
  hands = 0
142
197
  while hands < max_hands and sum(p.stack > 0 for p in table) > 1:
@@ -144,8 +199,8 @@ def play(agents, rng=None, max_hands=MAX_HANDS):
144
199
  alive = [p for p in table if p.stack > 0]
145
200
  b = next((i for i, p in enumerate(alive) if p.seat >= button), 0)
146
201
  before = {p.seat: p.stack for p in alive}
147
- Hand(hands, alive, b, rng.sample(DECK, len(DECK)), len(table)).play()
148
- busted += sorted((p.seat for p in alive if p.stack == 0), key=before.get)
202
+ Hand(hands, alive, b, rng.sample(DECK, len(DECK)), game).play()
203
+ game.out += sorted((p.seat for p in alive if p.stack == 0), key=before.get)
149
204
  button = alive[b].seat + 1
150
205
  standing = sorted((p for p in table if p.stack > 0), key=lambda p: -p.stack)
151
- return Result([p.seat for p in standing] + busted[::-1], hands, [p.stack for p in table])
206
+ return Result([p.seat for p in standing] + game.out[::-1], hands, [p.stack for p in table], game.hands)
@@ -31,7 +31,8 @@ def table(rows, labels=False, header=False):
31
31
 
32
32
  def money(micro):
33
33
  cents = round(micro / 10_000)
34
- return f"{cents}¢" if abs(cents) < 100 else f"${cents / 100:,.2f}"
34
+ sign = "-" if cents < 0 else ""
35
+ return f"{cents}¢" if abs(cents) < 100 else f"{sign}${abs(cents) / 100:,.2f}"
35
36
 
36
37
 
37
38
  def signed(value, unit=""):
@@ -13,6 +13,12 @@ class Fixed:
13
13
  return self.reply(state)
14
14
 
15
15
 
16
+ def busy(seconds):
17
+ end = time.thread_time() + seconds
18
+ while time.thread_time() < end:
19
+ pass
20
+
21
+
16
22
  def run(reply):
17
23
  agent = Agent(Fixed(reply))
18
24
  return agent(dict(STATE)), agent
@@ -25,8 +31,12 @@ def test_legal_actions_pass_and_raises_clamp():
25
31
 
26
32
 
27
33
  def test_bad_bots_fold():
28
- action, agent = run(lambda s: time.sleep(0.03))
34
+ action, agent = run(lambda s: time.sleep(0.15))
35
+ assert action == ("fold",) and agent.late == 1
36
+ action, agent = run(lambda s: busy(0.03))
29
37
  assert action == ("fold",) and agent.late == 1
38
+ action, agent = run(lambda s: time.sleep(0.03) or ("call",))
39
+ assert action == ("call",) and agent.late == 0
30
40
  action, agent = run(lambda s: 1 / 0)
31
41
  assert action == ("fold",) and "ZeroDivisionError" in agent.error
32
42
  action, agent = run(lambda s: ("check",))
@@ -1,3 +1,5 @@
1
+ import re
2
+
1
3
  import pytest
2
4
 
3
5
  from pokerbots_cli.main import main
@@ -14,7 +16,7 @@ def test_init_then_test(tmp_path, capsys):
14
16
  bot = tmp_path / "mybot"
15
17
  assert main(["init", str(bot)]) == 0
16
18
  assert main(["test", str(bot), "--games", "3", "--seed", "1"]) == 0
17
- assert "errors 0" in capsys.readouterr().out
19
+ assert re.search(r"errors\s+0\n", capsys.readouterr().out)
18
20
 
19
21
 
20
22
  def test_docs_plain(capsys):
@@ -24,13 +24,30 @@ def test_side_pots():
24
24
  stacks = [100, 300, 300]
25
25
  players = [engine.Player(seat, shove, stack) for seat, stack in enumerate(stacks)]
26
26
  dealt = "Ah Ad Kh Kd Qh Qd 2c 7d 9h Js 4c".split()
27
- engine.Hand(1, players, 0, dealt[::-1], 3).play()
27
+ engine.Hand(1, players, 0, dealt[::-1], engine.Game(["a", "b", "c"])).play()
28
28
  assert [p.stack for p in players] == [300, 400, 0]
29
29
 
30
30
 
31
31
  def test_heads_up_button_posts_small_blind():
32
32
  seen = []
33
33
  players = [engine.Player(s, lambda st: seen.append(st) or ("fold",)) for s in range(2)]
34
- engine.Hand(3, players, 1, list(engine.DECK), 2).play()
34
+ engine.Hand(3, players, 1, list(engine.DECK), engine.Game(["a", "b"])).play()
35
35
  assert seen[0]["seat"] == 1 and seen[0]["bets"] == [6, 3]
36
36
  assert [p.stack for p in players] == [1003, 997]
37
+
38
+
39
+ def test_state_and_history():
40
+ seen = []
41
+
42
+ def agent(state):
43
+ seen.append(state)
44
+ return shove(state) if state["seat"] == 0 else ("call",) if "call" in state["legal"] else ("check",)
45
+
46
+ result = engine.play([agent] * 6, random.Random(3), names=list("abcdef"))
47
+ last = seen[-1]
48
+ assert last["players"] == list("abcdef") and last["street"] in engine.STREETS
49
+ assert len(last["past"]) == last["hand"] - 1 and last["hands_left"] == engine.MAX_HANDS - last["hand"]
50
+ assert len(result.history) == result.hands
51
+ for h in result.history:
52
+ assert sum(h["won"]) > 0
53
+ assert all(h["shown"][s] in (None, h["hole"][s]) for s in range(6))
@@ -20,5 +20,5 @@ def test_sign_fills_our_slot_only():
20
20
  def test_b58_and_money():
21
21
  assert wallet.b58(bytes(32)) == "1" * 32
22
22
  assert wallet.b58(b"\x00\x01") == "12"
23
- assert [money(100_000), money(370_000), money(4_200_000)] == ["10¢", "37¢", "$4.20"]
23
+ assert [money(100_000), money(370_000), money(4_200_000), money(-1_100_000)] == ["10¢", "37¢", "$4.20", "-$1.10"]
24
24
  assert parse_money("$0.10") == 100_000
@@ -1,3 +0,0 @@
1
- from . import account, admin, local, money
2
-
3
- MODULES = (account, local, money, admin)
File without changes
File without changes