pokerbots-dev 0.1.2__tar.gz → 0.1.4__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 (27) hide show
  1. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/PKG-INFO +1 -1
  2. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/pyproject.toml +1 -1
  3. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/agent.py +12 -7
  4. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/api.py +1 -1
  5. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/commands/account.py +16 -7
  6. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/commands/local.py +2 -2
  7. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/commands/money.py +15 -6
  8. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/docs.md +7 -5
  9. pokerbots_dev-0.1.4/src/pokerbots_cli/fmt.py +55 -0
  10. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/tests/test_agent.py +11 -1
  11. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/tests/test_cli.py +3 -1
  12. pokerbots_dev-0.1.2/src/pokerbots_cli/fmt.py +0 -23
  13. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/.gitignore +0 -0
  14. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/README.md +0 -0
  15. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/__init__.py +0 -0
  16. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/auth.py +0 -0
  17. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/cards.py +0 -0
  18. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/commands/__init__.py +0 -0
  19. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/commands/admin.py +0 -0
  20. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/engine.py +0 -0
  21. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/house.py +0 -0
  22. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/main.py +0 -0
  23. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/template/bot.py +0 -0
  24. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/src/pokerbots_cli/wallet.py +0 -0
  25. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/tests/test_cards.py +0 -0
  26. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/tests/test_engine.py +0 -0
  27. {pokerbots_dev-0.1.2 → pokerbots_dev-0.1.4}/tests/test_wallet.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pokerbots-dev
3
- Version: 0.1.2
3
+ Version: 0.1.4
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.2"
7
+ version = "0.1.4"
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):
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
 
7
7
  from .. import auth
8
8
  from ..api import call
9
- from ..fmt import money, place, table
9
+ from ..fmt import INK, PAPER, colored, money, place, signed, table, when
10
10
 
11
11
  MAX_UPLOAD = 1 << 20
12
12
  WATCH_SECONDS = 5
@@ -53,7 +53,7 @@ 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
57
 
58
58
 
59
59
  def report():
@@ -63,12 +63,16 @@ def report():
63
63
  d = me["deployments"].get(mode)
64
64
  rows.append([mode, f"v{d['version']} rating {d['rating']:.0f} games {d['games']} today {d['today']}" if d else "-"])
65
65
  rows += [["balance", money(me["balance"])], ["wallet", me["wallet"] or "-"]]
66
- text = table(rows)
66
+ text = table(rows, labels=True)
67
67
  games = call("GET", "/games")
68
68
  if games:
69
- text += "\n\n" + table([["game", "mode", "place", "hands"]] + [
70
- [g["created_at"], g["mode"], place(g["place"]), g["hands"] or "-"] for g in games
71
- ])
69
+ live = any(g["mode"] == "live" for g in games)
70
+ head = ["game", "mode", "place", "hands", "rating"] + (["won"] if live else [])
71
+ text += "\n\n" + table([head] + [
72
+ [when(g["created_at"]), g["mode"], place(g["place"]), g["hands"] or "-", signed(g.get("rating_delta"))]
73
+ + ([signed(g.get("net"), "money")] if live else [])
74
+ for g in games
75
+ ], header=True)
72
76
  return text
73
77
 
74
78
 
@@ -77,13 +81,18 @@ def status(args):
77
81
 
78
82
 
79
83
  def watch(args):
84
+ paint = f"\x1b[48;2;{INK}m\x1b[38;2;{PAPER}m" if colored() else ""
85
+ sys.stdout.write("\x1b[?1049h\x1b[?25l" + paint + "\x1b[2J")
80
86
  try:
81
87
  while True:
82
- sys.stdout.write("\x1b[H\x1b[2J" + report() + "\n\nctrl-c to quit\n")
88
+ lines = ["", *(" " + line for line in report().split("\n")), "", " " + "ctrl-c to quit"]
89
+ sys.stdout.write("\x1b[H" + "".join(line + "\x1b[K\n" for line in lines) + "\x1b[J")
83
90
  sys.stdout.flush()
84
91
  time.sleep(WATCH_SECONDS)
85
92
  except KeyboardInterrupt:
86
93
  pass
94
+ finally:
95
+ sys.stdout.write("\x1b[0m\x1b[?25h\x1b[?1049l")
87
96
 
88
97
 
89
98
  def register(sub):
@@ -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,7 +7,7 @@ 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
13
  pb docs [--plain] show this
@@ -19,7 +19,8 @@ money
19
19
 
20
20
  Your wallet key is ~/.config/pokerbots/wallet.json and never leaves your
21
21
  machine. Send USDC on Solana to its address, then deposit. We pay the
22
- network fees. Large withdrawals may wait for review.
22
+ network fees. Withdrawals go to the wallet you last deposited from.
23
+ Large withdrawals may wait for review.
23
24
 
24
25
  bot.py
25
26
  class Bot:
@@ -29,8 +30,9 @@ bot.py
29
30
  return ("fold",)
30
31
 
31
32
  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,
33
+ Each decision gets 20 ms of CPU time, and must reply within 100 ms.
34
+ A late reply, an error or an illegal action counts as check, or fold
35
+ if check is not legal. Uploads are the bot's directory,
34
36
  up to 1 MB zipped. Bots run with the Python standard library only, no
35
37
  network and no file writes.
36
38
 
@@ -68,7 +70,7 @@ game
68
70
  blinds on hand n: n / 2n
69
71
  ends at last bot standing or 100 hands
70
72
  at 100 hands, places go by chips
71
- 20 ms per decision, else check/fold
73
+ 20 ms CPU per decision, else check/fold
72
74
  up to 5 games a minute per bot
73
75
  paper tables are filled with house bots; live tables are 6 players
74
76
 
@@ -0,0 +1,55 @@
1
+ import os
2
+ import sys
3
+ from datetime import datetime, timezone
4
+
5
+ MICRO = 1_000_000
6
+ INK, PAPER = "17;17;17", "244;243;239"
7
+
8
+
9
+ def colored():
10
+ return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
11
+
12
+
13
+ def bold(text):
14
+ return f"\x1b[1m{text}\x1b[22m" if colored() else text
15
+
16
+
17
+ def table(rows, labels=False, header=False):
18
+ """Aligned columns; bold the first column (labels) or first row (header)."""
19
+ rows = [[str(c) for c in row] for row in rows]
20
+ widths = [max(len(r[i]) for r in rows if i < len(r)) for i in range(max(map(len, rows)))]
21
+ lines = []
22
+ for n, r in enumerate(rows):
23
+ cells = [c.ljust(w) for c, w in zip(r, widths)]
24
+ if header and n == 0:
25
+ cells = [bold(c) for c in cells]
26
+ elif labels:
27
+ cells[0] = bold(cells[0])
28
+ lines.append(" ".join(cells).rstrip())
29
+ return "\n".join(lines)
30
+
31
+
32
+ def money(micro):
33
+ cents = round(micro / 10_000)
34
+ return f"{cents}¢" if abs(cents) < 100 else f"${cents / 100:,.2f}"
35
+
36
+
37
+ def signed(value, unit=""):
38
+ if value is None:
39
+ return "-"
40
+ return ("+" if value > 0 else "") + (money(value) if unit == "money" else f"{value:.0f}")
41
+
42
+
43
+ def when(utc):
44
+ return datetime.fromisoformat(utc).replace(tzinfo=timezone.utc).astimezone().strftime("%m-%d %H:%M")
45
+
46
+
47
+ def parse_money(text):
48
+ value = round(float(text.lstrip("$")) * MICRO)
49
+ if value <= 0:
50
+ raise ValueError("amount must be positive")
51
+ return value
52
+
53
+
54
+ def place(n):
55
+ return {1: "1st", 2: "2nd", 3: "3rd"}.get(n, f"{n}th") if n else "-"
@@ -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):
@@ -1,23 +0,0 @@
1
- MICRO = 1_000_000
2
-
3
-
4
- def table(rows):
5
- rows = [[str(c) for c in row] for row in rows]
6
- widths = [max(len(r[i]) for r in rows if i < len(r)) for i in range(max(map(len, rows)))]
7
- return "\n".join(" ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip() for r in rows)
8
-
9
-
10
- def money(micro):
11
- cents = round(micro / 10_000)
12
- return f"{cents}¢" if abs(cents) < 100 else f"${cents / 100:,.2f}"
13
-
14
-
15
- def parse_money(text):
16
- value = round(float(text.lstrip("$")) * MICRO)
17
- if value <= 0:
18
- raise ValueError("amount must be positive")
19
- return value
20
-
21
-
22
- def place(n):
23
- return {1: "1st", 2: "2nd", 3: "3rd"}.get(n, f"{n}th") if n else "-"
File without changes
File without changes