pokerbots-dev 0.0.1__tar.gz → 0.1.0__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.0.1 → pokerbots_dev-0.1.0}/.gitignore +1 -0
  2. {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/PKG-INFO +2 -1
  3. {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/pyproject.toml +2 -2
  4. pokerbots_dev-0.1.0/src/pokerbots_cli/agent.py +48 -0
  5. pokerbots_dev-0.1.0/src/pokerbots_cli/api.py +48 -0
  6. pokerbots_dev-0.1.0/src/pokerbots_cli/auth.py +61 -0
  7. pokerbots_dev-0.1.0/src/pokerbots_cli/cards.py +41 -0
  8. pokerbots_dev-0.1.0/src/pokerbots_cli/commands/__init__.py +3 -0
  9. pokerbots_dev-0.1.0/src/pokerbots_cli/commands/account.py +89 -0
  10. pokerbots_dev-0.1.0/src/pokerbots_cli/commands/admin.py +36 -0
  11. pokerbots_dev-0.1.0/src/pokerbots_cli/commands/local.py +83 -0
  12. pokerbots_dev-0.1.0/src/pokerbots_cli/commands/money.py +37 -0
  13. pokerbots_dev-0.1.0/src/pokerbots_cli/docs.md +91 -0
  14. pokerbots_dev-0.1.0/src/pokerbots_cli/engine.py +151 -0
  15. pokerbots_dev-0.1.0/src/pokerbots_cli/fmt.py +23 -0
  16. pokerbots_dev-0.1.0/src/pokerbots_cli/house.py +19 -0
  17. pokerbots_dev-0.1.0/src/pokerbots_cli/main.py +31 -0
  18. pokerbots_dev-0.1.0/src/pokerbots_cli/template/bot.py +5 -0
  19. pokerbots_dev-0.1.0/src/pokerbots_cli/wallet.py +67 -0
  20. pokerbots_dev-0.1.0/tests/test_agent.py +33 -0
  21. pokerbots_dev-0.1.0/tests/test_cards.py +27 -0
  22. pokerbots_dev-0.1.0/tests/test_cli.py +22 -0
  23. pokerbots_dev-0.1.0/tests/test_engine.py +36 -0
  24. pokerbots_dev-0.1.0/tests/test_wallet.py +24 -0
  25. pokerbots_dev-0.0.1/src/pokerbots_cli/main.py +0 -15
  26. pokerbots_dev-0.0.1/tests/test_cli.py +0 -15
  27. {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/README.md +0 -0
  28. {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/src/pokerbots_cli/__init__.py +0 -0
@@ -9,3 +9,4 @@ node_modules/
9
9
  .wrangler/
10
10
  .dev.vars
11
11
  .env
12
+ site/llms.txt
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pokerbots-dev
3
- Version: 0.0.1
3
+ Version: 0.1.0
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
7
7
  License-Expression: MIT
8
8
  Requires-Python: >=3.10
9
+ Requires-Dist: cryptography>=42
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: pytest>=8; extra == 'dev'
11
12
  Description-Content-Type: text/markdown
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pokerbots-dev"
7
- version = "0.0.1"
7
+ version = "0.1.0"
8
8
  description = "pb: command-line client for pokerbots.dev"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
- dependencies = []
12
+ dependencies = ["cryptography>=42"]
13
13
 
14
14
  [project.optional-dependencies]
15
15
  dev = ["pytest>=8"]
@@ -0,0 +1,48 @@
1
+ import traceback
2
+ from time import perf_counter
3
+
4
+ LIMIT = 0.020
5
+
6
+
7
+ def fallback(state):
8
+ return ("check",) if "check" in state["legal"] else ("fold",)
9
+
10
+
11
+ def normalize(action, state):
12
+ if not isinstance(action, (tuple, list)) or not action or action[0] not in state["legal"]:
13
+ return None
14
+ if action[0] != "raise":
15
+ return (action[0],)
16
+ if len(action) < 2 or not isinstance(action[1], int) or isinstance(action[1], bool):
17
+ return None
18
+ return ("raise", min(max(action[1], state["min_raise"]), state["max_raise"]))
19
+
20
+
21
+ class Agent:
22
+ """Runs an untrusted bot: enforces the time limit and returns only legal actions."""
23
+
24
+ def __init__(self, bot, limit=LIMIT):
25
+ self.bot = bot
26
+ self.limit = limit
27
+ self.decisions = self.late = self.errors = 0
28
+ self.slowest = 0.0
29
+ self.error = None
30
+
31
+ def __call__(self, state):
32
+ self.decisions += 1
33
+ start = perf_counter()
34
+ try:
35
+ action = self.bot.act(state)
36
+ except Exception:
37
+ return self.fail(state, traceback.format_exc())
38
+ elapsed = perf_counter() - start
39
+ self.slowest = max(self.slowest, elapsed)
40
+ if elapsed > self.limit:
41
+ self.late += 1
42
+ return fallback(state)
43
+ return normalize(action, state) or self.fail(state, f"invalid action {action!r}, legal {state['legal']}")
44
+
45
+ def fail(self, state, error):
46
+ self.errors += 1
47
+ self.error = self.error or error
48
+ return fallback(state)
@@ -0,0 +1,48 @@
1
+ import json
2
+ import os
3
+ import urllib.error
4
+ import urllib.request
5
+
6
+ from . import auth
7
+
8
+
9
+ class ApiError(Exception):
10
+ pass
11
+
12
+
13
+ def base():
14
+ return os.environ.get("PB_API", "https://api.pokerbots.dev").rstrip("/")
15
+
16
+
17
+ def call(method, path, body=None, data=None, headers=None):
18
+ headers = dict(headers or {})
19
+ if "CF-Access-Client-Id" not in headers:
20
+ credentials = auth.load()
21
+ if not credentials:
22
+ raise ApiError("not logged in: pb login")
23
+ headers["Authorization"] = f"Bearer {credentials['github_token']}"
24
+ if body is not None:
25
+ data = json.dumps(body).encode()
26
+ headers["Content-Type"] = "application/json"
27
+ request = urllib.request.Request(base() + path, data, headers, method=method)
28
+ try:
29
+ with urllib.request.urlopen(request) as response:
30
+ return json.load(response)
31
+ except urllib.error.HTTPError as e:
32
+ try:
33
+ message = json.load(e)["error"]
34
+ except (ValueError, KeyError):
35
+ message = f"{e.code} {e.reason}"
36
+ raise ApiError(message) from None
37
+ except urllib.error.URLError as e:
38
+ raise ApiError(str(e.reason)) from None
39
+
40
+
41
+ def admin(method, path, body=None):
42
+ headers = {
43
+ "CF-Access-Client-Id": os.environ.get("CLOUDFLARE_ACCESS_CLIENT_ID", ""),
44
+ "CF-Access-Client-Secret": os.environ.get("CLOUDFLARE_ACCESS_CLIENT_SECRET", ""),
45
+ }
46
+ if not all(headers.values()):
47
+ raise ApiError("set CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET")
48
+ return call(method, "/admin" + path, body, headers=headers)
@@ -0,0 +1,61 @@
1
+ import json
2
+ import os
3
+ import time
4
+ import urllib.parse
5
+ import urllib.request
6
+ import webbrowser
7
+ from pathlib import Path
8
+
9
+ CLIENT_ID = "Ov23li6ejCOkXTqOb0SW"
10
+ DEVICE_URL = "https://github.com/login/device/code"
11
+ TOKEN_URL = "https://github.com/login/oauth/access_token"
12
+ USER_URL = "https://api.github.com/user"
13
+
14
+
15
+ def credentials_path():
16
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "pokerbots" / "credentials.json"
17
+
18
+
19
+ def save(credentials):
20
+ path = credentials_path()
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.touch(mode=0o600, exist_ok=True)
23
+ path.write_text(json.dumps(credentials))
24
+
25
+
26
+ def load():
27
+ path = credentials_path()
28
+ return json.loads(path.read_text()) if path.exists() else None
29
+
30
+
31
+ def request(url, data=None, token=None):
32
+ headers = {"Accept": "application/json"}
33
+ if token:
34
+ headers["Authorization"] = f"Bearer {token}"
35
+ body = urllib.parse.urlencode(data).encode() if data else None
36
+ with urllib.request.urlopen(urllib.request.Request(url, body, headers)) as response:
37
+ return json.load(response)
38
+
39
+
40
+ def login(out=print):
41
+ device = request(DEVICE_URL, {"client_id": CLIENT_ID})
42
+ out(f"open {device['verification_uri']}\ncode {device['user_code']}")
43
+ webbrowser.open(device["verification_uri"])
44
+ interval = device["interval"]
45
+ deadline = time.monotonic() + device["expires_in"]
46
+ while time.monotonic() < deadline:
47
+ time.sleep(interval)
48
+ reply = request(TOKEN_URL, {
49
+ "client_id": CLIENT_ID,
50
+ "device_code": device["device_code"],
51
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
52
+ })
53
+ if "access_token" in reply:
54
+ user = request(USER_URL, token=reply["access_token"])["login"]
55
+ save({"github_token": reply["access_token"], "user": user})
56
+ return user
57
+ if reply.get("error") == "slow_down":
58
+ interval = reply.get("interval", interval + 5)
59
+ elif reply.get("error") != "authorization_pending":
60
+ raise RuntimeError(reply.get("error_description") or reply.get("error"))
61
+ raise RuntimeError("code expired")
@@ -0,0 +1,41 @@
1
+ from collections import Counter
2
+ from itertools import combinations
3
+
4
+ RANKS = "23456789TJQKA"
5
+ SUITS = "cdhs"
6
+ DECK = tuple(r + s for r in RANKS for s in SUITS)
7
+
8
+
9
+ def rank5(cards):
10
+ values = sorted((RANKS.index(c[0]) for c in cards), reverse=True)
11
+ counts = Counter(values)
12
+ ordered = sorted(counts, key=lambda v: (counts[v], v), reverse=True)
13
+ shape = sorted(counts.values(), reverse=True)
14
+ flush = len({c[1] for c in cards}) == 1
15
+ straight = None
16
+ if len(counts) == 5:
17
+ if values[0] - values[4] == 4:
18
+ straight = values[0]
19
+ elif values == [12, 3, 2, 1, 0]:
20
+ straight = 3
21
+ if straight is not None and flush:
22
+ return (8, straight)
23
+ if shape[0] == 4:
24
+ return (7, *ordered)
25
+ if shape == [3, 2]:
26
+ return (6, *ordered)
27
+ if flush:
28
+ return (5, *values)
29
+ if straight is not None:
30
+ return (4, straight)
31
+ if shape[0] == 3:
32
+ return (3, *ordered)
33
+ if shape == [2, 2, 1]:
34
+ return (2, *ordered)
35
+ if shape[0] == 2:
36
+ return (1, *ordered)
37
+ return (0, *values)
38
+
39
+
40
+ def best(cards):
41
+ return max(rank5(five) for five in combinations(cards, 5))
@@ -0,0 +1,3 @@
1
+ from . import account, admin, local, money
2
+
3
+ MODULES = (account, local, money, admin)
@@ -0,0 +1,89 @@
1
+ import io
2
+ import sys
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ from .. import auth
7
+ from ..api import call
8
+ from ..fmt import money, place, table
9
+
10
+ MAX_UPLOAD = 1 << 20
11
+ ATTEST = "I am 18 or older, and paid skill competitions are allowed where I am. [y/N] "
12
+
13
+
14
+ def login(args):
15
+ try:
16
+ print(f"logged in as {auth.login()}")
17
+ except (RuntimeError, OSError) as e:
18
+ sys.exit(f"login failed: {e}")
19
+
20
+
21
+ def bundle(directory):
22
+ root = Path(directory)
23
+ if not (root / "bot.py").exists():
24
+ sys.exit(f"no bot.py in {directory}")
25
+ buffer = io.BytesIO()
26
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as z:
27
+ for path in sorted(root.rglob("*")):
28
+ relative = path.relative_to(root)
29
+ if path.is_file() and not any(p.startswith(".") or p == "__pycache__" for p in relative.parts):
30
+ z.write(path, relative.as_posix())
31
+ if buffer.tell() > MAX_UPLOAD:
32
+ sys.exit("bot is over 1 MB zipped")
33
+ return buffer.getvalue()
34
+
35
+
36
+ def upload(args):
37
+ reply = call("POST", "/versions", data=bundle(args.dir), headers={"Content-Type": "application/zip"})
38
+ print(f"uploaded v{reply['version']}")
39
+
40
+
41
+ def deploy(args):
42
+ mode = "live" if args.live else "paper"
43
+ if mode == "live" and not call("GET", "/me")["attested"]:
44
+ if input(ATTEST).strip().lower() != "y":
45
+ sys.exit("not deployed")
46
+ call("POST", "/attest")
47
+ call("POST", "/deployments", {"mode": mode, "version": int(args.version.lstrip("v"))})
48
+ print(f"{args.version} deployed {mode}")
49
+
50
+
51
+ def stop(args):
52
+ mode = "live" if args.live else "paper"
53
+ call("DELETE", f"/deployments/{mode}")
54
+ print(f"stopped {mode}")
55
+
56
+
57
+ def status(args):
58
+ me = call("GET", "/me")
59
+ rows = [["user", me["login"]], ["versions", " ".join(f"v{v}" for v in me["versions"]) or "-"]]
60
+ for mode in ("paper", "live"):
61
+ d = me["deployments"].get(mode)
62
+ rows.append([mode, f"v{d['version']} rating {d['rating']:.0f} games {d['games']} today {d['today']}" if d else "-"])
63
+ rows += [["balance", money(me["balance"])], ["wallet", me["wallet"] or "-"]]
64
+ print(table(rows))
65
+ games = call("GET", "/games")
66
+ if games:
67
+ print()
68
+ print(table([["game", "mode", "place", "hands"]] + [
69
+ [g["created_at"], g["mode"], place(g["place"]), g["hands"] or "-"] for g in games
70
+ ]))
71
+
72
+
73
+ def register(sub):
74
+ sub.add_parser("login", help="sign in with GitHub").set_defaults(run=login)
75
+
76
+ p = sub.add_parser("upload", help="upload a new version")
77
+ p.add_argument("dir", nargs="?", default=".")
78
+ p.set_defaults(run=upload)
79
+
80
+ for name, run, help in (("deploy", deploy, "play a version"), ("stop", stop, "stop playing")):
81
+ p = sub.add_parser(name, help=help)
82
+ if name == "deploy":
83
+ p.add_argument("version", help="e.g. v1")
84
+ mode = p.add_mutually_exclusive_group(required=True)
85
+ mode.add_argument("--paper", action="store_true")
86
+ mode.add_argument("--live", action="store_true")
87
+ p.set_defaults(run=run)
88
+
89
+ sub.add_parser("status", help="your bot, rating, balance and recent games").set_defaults(run=status)
@@ -0,0 +1,36 @@
1
+ from ..api import admin
2
+ from ..fmt import money, table
3
+
4
+
5
+ def stats(args):
6
+ s = admin("GET", "/stats")
7
+ print(table([
8
+ ["users", s["users"]],
9
+ ["games today", s["games_today"]],
10
+ ["owed to users", money(s["liabilities"])],
11
+ ["hot wallet", f"{money(s['hot_usdc'])} USDC, {s['hot_sol']} SOL"],
12
+ ["withdrawn today", money(s["withdrawn_today"])],
13
+ ]))
14
+
15
+
16
+ def withdrawals(args):
17
+ rows = admin("GET", "/withdrawals")
18
+ print(table([["id", "user", "amount", "requested"]] + [
19
+ [w["id"], w["login"], money(w["amount"]), w["created_at"]] for w in rows
20
+ ]) if rows else "no pending withdrawals")
21
+
22
+
23
+ def decide(args):
24
+ reply = admin("POST", f"/withdrawals/{args.id}", {"action": args.action})
25
+ print(f"{args.id} {reply['status']}")
26
+
27
+
28
+ def register(sub):
29
+ p = sub.add_parser("admin", help="operator commands")
30
+ admin_sub = p.add_subparsers(metavar="command", required=True)
31
+ admin_sub.add_parser("stats").set_defaults(run=stats)
32
+ admin_sub.add_parser("withdrawals", help="pending withdrawals").set_defaults(run=withdrawals)
33
+ for action in ("approve", "reject"):
34
+ q = admin_sub.add_parser(action)
35
+ q.add_argument("id")
36
+ q.set_defaults(run=decide, action=action)
@@ -0,0 +1,83 @@
1
+ import importlib.util
2
+ import pydoc
3
+ import random
4
+ import shutil
5
+ import sys
6
+ from importlib import resources
7
+ from pathlib import Path
8
+
9
+ from .. import engine
10
+ from ..agent import Agent
11
+ from ..fmt import table
12
+ from ..house import BOTS
13
+
14
+ PACKAGE = resources.files("pokerbots_cli")
15
+
16
+
17
+ def docs(args):
18
+ text = PACKAGE.joinpath("docs.md").read_text()
19
+ if args.plain or not sys.stdout.isatty():
20
+ print(text, end="")
21
+ else:
22
+ pydoc.pager(text)
23
+
24
+
25
+ def init(args):
26
+ path = Path(args.name)
27
+ if path.exists():
28
+ sys.exit(f"{path} already exists")
29
+ path.mkdir(parents=True)
30
+ shutil.copy(PACKAGE.joinpath("template", "bot.py"), path / "bot.py")
31
+ print(f"created {path / 'bot.py'}")
32
+
33
+
34
+ def load_bot(directory):
35
+ path = Path(directory) / "bot.py"
36
+ if not path.exists():
37
+ sys.exit(f"no bot.py in {directory}")
38
+ sys.path.insert(0, str(path.parent.resolve()))
39
+ spec = importlib.util.spec_from_file_location("bot", path)
40
+ module = importlib.util.module_from_spec(spec)
41
+ spec.loader.exec_module(module)
42
+ return module.Bot
43
+
44
+
45
+ def test(args):
46
+ Bot = load_bot(args.dir)
47
+ rng = random.Random(args.seed)
48
+ mine = Agent(None)
49
+ places = []
50
+ for _ in range(args.games):
51
+ mine.bot = Bot()
52
+ agents = [mine] + [Agent(cls()) for cls in BOTS]
53
+ rng.shuffle(agents)
54
+ places.append(engine.play(agents, rng).places.index(agents.index(mine)) + 1)
55
+ print(table([
56
+ ["games", args.games],
57
+ ["1st", places.count(1)],
58
+ ["2nd", places.count(2)],
59
+ ["avg place", f"{sum(places) / len(places):.1f}"],
60
+ ["decisions", mine.decisions],
61
+ ["over 20 ms", mine.late],
62
+ ["errors", mine.errors],
63
+ ["slowest", f"{mine.slowest * 1000:.2f} ms"],
64
+ ]))
65
+ if mine.error:
66
+ print(f"\nfirst error\n{mine.error}")
67
+ return 1
68
+
69
+
70
+ def register(sub):
71
+ p = sub.add_parser("docs", help="show the docs")
72
+ p.add_argument("--plain", action="store_true", help="no pager, for agents")
73
+ p.set_defaults(run=docs)
74
+
75
+ p = sub.add_parser("init", help="create a bot")
76
+ p.add_argument("name")
77
+ p.set_defaults(run=init)
78
+
79
+ p = sub.add_parser("test", help="play local games against house bots")
80
+ p.add_argument("dir", nargs="?", default=".")
81
+ p.add_argument("--games", type=int, default=100)
82
+ p.add_argument("--seed", type=int)
83
+ p.set_defaults(run=test)
@@ -0,0 +1,37 @@
1
+ from .. import wallet as keys
2
+ from ..api import call
3
+ from ..fmt import money, parse_money, table
4
+
5
+
6
+ def registered_key():
7
+ key = keys.load_or_create()
8
+ address = keys.address(key)
9
+ if call("GET", "/me")["wallet"] != address:
10
+ call("PUT", "/wallet", {"address": address})
11
+ return key, address
12
+
13
+
14
+ def wallet(args):
15
+ _, address = registered_key()
16
+ print(table([["address", address], ["balance", money(call("GET", "/me")["balance"])]]))
17
+
18
+
19
+ def deposit(args):
20
+ key, _ = registered_key()
21
+ prepared = call("POST", "/deposits", {"amount": parse_money(args.amount)})
22
+ reply = call("POST", f"/deposits/{prepared['signature']}", {"transaction": keys.sign(prepared["transaction"], key)})
23
+ print(f"deposit {reply['status']}: {money(reply['amount'])}")
24
+
25
+
26
+ def withdraw(args):
27
+ registered_key()
28
+ reply = call("POST", "/withdrawals", {"amount": parse_money(args.amount)})
29
+ print(f"withdrawal {reply['status']}: {money(reply['amount'])}")
30
+
31
+
32
+ def register(sub):
33
+ sub.add_parser("wallet", help="your Solana wallet address and balance").set_defaults(run=wallet)
34
+ for name, run in (("deposit", deposit), ("withdraw", withdraw)):
35
+ p = sub.add_parser(name, help=f"{name} USDC, e.g. pb {name} 5")
36
+ p.add_argument("amount", help="dollars")
37
+ p.set_defaults(run=run)
@@ -0,0 +1,91 @@
1
+ pokerbots.dev
2
+
3
+ start
4
+ pb login sign in with GitHub
5
+ pb init mybot create mybot/bot.py
6
+ pb test [dir] play 100 local games against house bots
7
+ pb upload [dir] upload a new version: v1, v2, ...
8
+ pb deploy v1 --paper play free games with v1
9
+ pb deploy v1 --live play for money with v1
10
+ pb stop --paper|--live stop playing
11
+ pb status versions, rating, balance, recent games
12
+ pb docs [--plain] show this
13
+
14
+ money
15
+ pb wallet your Solana wallet address and balance
16
+ pb deposit 5 move $5 USDC from your wallet to your balance
17
+ pb withdraw 5 move $5 back to your wallet
18
+
19
+ Your wallet key is ~/.config/pokerbots/wallet.json and never leaves your
20
+ machine. Send USDC on Solana to its address, then deposit. We pay the
21
+ network fees. Large withdrawals may wait for review.
22
+
23
+ bot.py
24
+ class Bot:
25
+ def act(self, state):
26
+ if "check" in state["legal"]:
27
+ return ("check",)
28
+ return ("fold",)
29
+
30
+ A new Bot is created for every game. act is called for every decision.
31
+ A late reply (over 20 ms), an error or an illegal action counts as
32
+ check, or fold if check is not legal. Uploads are the bot's directory,
33
+ up to 1 MB zipped. Bots run with the Python standard library only, no
34
+ network and no file writes.
35
+
36
+ actions
37
+ ("fold",)
38
+ ("check",)
39
+ ("call",)
40
+ ("raise", n) n is your total bet this round, from min_raise to max_raise
41
+
42
+ state
43
+ hand hand number, from 1
44
+ seat your seat, 0-5
45
+ button button seat
46
+ blinds [small, big]
47
+ hole your two cards, e.g. ["As", "Td"]
48
+ board shared cards
49
+ stacks chips behind, by seat
50
+ bets chips bet this round, by seat
51
+ pot chips bet this hand
52
+ to_call chips needed to call
53
+ legal actions allowed now
54
+ min_raise smallest raise total, when raise is legal
55
+ max_raise all in, when raise is legal
56
+ live seats still in the hand
57
+ actions this hand so far, [cards on board, seat, action, bet this round]
58
+
59
+ cards
60
+ ranks 23456789TJQKA
61
+ suits cdhs
62
+
63
+ game
64
+ no-limit hold'em sit-and-go
65
+ 6 bots, new random table every game
66
+ 1,000 chips each
67
+ blinds on hand n: n / 2n
68
+ ends at last bot standing or 100 hands
69
+ at 100 hands, places go by chips
70
+ 20 ms per decision, else check/fold
71
+ up to 5 games a minute per bot
72
+ paper tables are filled with house bots; live tables are 6 players
73
+
74
+ rating
75
+ Elo, from 1500; each game counts as a win over every bot placed below
76
+
77
+ price
78
+ paper free, 1,000 games/day
79
+
80
+ live* buy-in 10¢
81
+ pot 60¢
82
+ 1st 37¢
83
+ 2nd 20¢
84
+ paid in USDC on Solana
85
+
86
+ * 18+, and only where paid skill competitions are allowed for you.
87
+ deposits are held in our Solana wallets and tracked by us until you
88
+ withdraw.
89
+
90
+ contact
91
+ hello@pokerbots.dev
@@ -0,0 +1,151 @@
1
+ import random
2
+ from dataclasses import dataclass
3
+
4
+ from .cards import DECK, best
5
+
6
+ STACK = 1000
7
+ MAX_HANDS = 100
8
+
9
+
10
+ @dataclass
11
+ class Player:
12
+ seat: int
13
+ agent: object
14
+ stack: int = STACK
15
+ hole: tuple = ()
16
+ bet: int = 0
17
+ committed: int = 0
18
+ folded: bool = False
19
+
20
+ @property
21
+ def can_act(self):
22
+ return not self.folded and self.stack > 0
23
+
24
+ def put(self, chips):
25
+ chips = min(chips, self.stack)
26
+ self.stack -= chips
27
+ self.bet += chips
28
+ self.committed += chips
29
+
30
+
31
+ @dataclass
32
+ class Result:
33
+ places: list
34
+ hands: int
35
+ stacks: list
36
+
37
+
38
+ class Hand:
39
+ def __init__(self, number, players, button, deck, seats):
40
+ self.number = number
41
+ self.players = players
42
+ self.button = button
43
+ self.deck = deck
44
+ self.seats = seats
45
+ self.blinds = [number, 2 * number]
46
+ self.board = []
47
+ self.log = []
48
+
49
+ def play(self):
50
+ n = len(self.players)
51
+ for p in self.players:
52
+ p.hole = (self.deck.pop(), self.deck.pop())
53
+ p.bet = p.committed = 0
54
+ p.folded = False
55
+ small = self.button if n == 2 else (self.button + 1) % n
56
+ for i, blind in zip((small, (small + 1) % n), self.blinds):
57
+ self.players[i].put(blind)
58
+ self.record(self.players[i], "blind")
59
+ self.betting((small + 2) % n, self.blinds[1])
60
+ for count in (3, 1, 1):
61
+ if sum(not p.folded for p in self.players) < 2:
62
+ break
63
+ for p in self.players:
64
+ p.bet = 0
65
+ self.board += [self.deck.pop() for _ in range(count)]
66
+ self.betting((self.button + 1) % n, 0)
67
+ self.award()
68
+
69
+ def betting(self, start, current):
70
+ order = self.players[start:] + self.players[:start]
71
+ queue = [p for p in order if p.can_act]
72
+ step = self.blinds[1]
73
+ while queue and sum(not p.folded for p in self.players) > 1:
74
+ p = queue.pop(0)
75
+ others = any(q.can_act for q in self.players if q is not p)
76
+ if not p.can_act or (p.bet >= current and not others):
77
+ continue
78
+ to_call = current - p.bet
79
+ legal = ["fold", "call"] if to_call > 0 else ["check"]
80
+ state = self.state(p, legal, to_call)
81
+ if others and p.stack > to_call:
82
+ legal.append("raise")
83
+ state["max_raise"] = p.bet + p.stack
84
+ state["min_raise"] = min(current + step, state["max_raise"])
85
+ action = p.agent(state)
86
+ if action[0] == "fold":
87
+ p.folded = True
88
+ elif action[0] == "call":
89
+ p.put(to_call)
90
+ elif action[0] == "raise":
91
+ p.put(action[1] - p.bet)
92
+ step = max(step, p.bet - current)
93
+ current = max(current, p.bet)
94
+ i = order.index(p)
95
+ queue = [q for q in order[i + 1:] + order[:i] if q.can_act]
96
+ self.record(p, action[0])
97
+
98
+ def record(self, p, action):
99
+ self.log.append([len(self.board), p.seat, action, p.bet])
100
+
101
+ def state(self, p, legal, to_call):
102
+ stacks, bets = [0] * self.seats, [0] * self.seats
103
+ for q in self.players:
104
+ stacks[q.seat], bets[q.seat] = q.stack, q.bet
105
+ return {
106
+ "hand": self.number,
107
+ "seat": p.seat,
108
+ "button": self.players[self.button].seat,
109
+ "blinds": list(self.blinds),
110
+ "hole": list(p.hole),
111
+ "board": list(self.board),
112
+ "stacks": stacks,
113
+ "bets": bets,
114
+ "pot": sum(q.committed for q in self.players),
115
+ "to_call": to_call,
116
+ "legal": legal,
117
+ "live": [q.seat for q in self.players if not q.folded],
118
+ "actions": [list(a) for a in self.log],
119
+ }
120
+
121
+ def award(self):
122
+ live = [p for p in self.players if not p.folded]
123
+ strength = {p.seat: best(p.hole + tuple(self.board)) for p in live} if len(live) > 1 else {}
124
+ previous = 0
125
+ for level in sorted({p.committed for p in self.players}):
126
+ pot = sum(min(p.committed, level) - min(p.committed, previous) for p in self.players)
127
+ previous = level
128
+ eligible = [p for p in live if p.committed >= level]
129
+ top = max(strength.get(p.seat, ()) for p in eligible)
130
+ winners = [p for p in eligible if strength.get(p.seat, ()) == top]
131
+ share, odd = divmod(pot, len(winners))
132
+ for i, w in enumerate(winners):
133
+ w.stack += share + (i < odd)
134
+
135
+
136
+ def play(agents, rng=None, max_hands=MAX_HANDS):
137
+ rng = rng or random.Random()
138
+ table = [Player(seat, agent) for seat, agent in enumerate(agents)]
139
+ busted = []
140
+ button = rng.randrange(len(table))
141
+ hands = 0
142
+ while hands < max_hands and sum(p.stack > 0 for p in table) > 1:
143
+ hands += 1
144
+ alive = [p for p in table if p.stack > 0]
145
+ b = next((i for i, p in enumerate(alive) if p.seat >= button), 0)
146
+ 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)
149
+ button = alive[b].seat + 1
150
+ 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])
@@ -0,0 +1,23 @@
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 "-"
@@ -0,0 +1,19 @@
1
+ import random
2
+
3
+
4
+ class Caller:
5
+ def act(self, state):
6
+ return ("call",) if "call" in state["legal"] else ("check",)
7
+
8
+
9
+ class Raiser:
10
+ def __init__(self, rng=None):
11
+ self.rng = rng or random.Random()
12
+
13
+ def act(self, state):
14
+ if "raise" in state["legal"] and self.rng.random() < 0.3:
15
+ return ("raise", min(state["max_raise"], state["min_raise"] + self.rng.randint(0, state["pot"])))
16
+ return Caller().act(state)
17
+
18
+
19
+ BOTS = (Caller, Raiser, Caller, Raiser, Caller)
@@ -0,0 +1,31 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from pokerbots_cli import __version__
5
+ from pokerbots_cli.api import ApiError
6
+ from pokerbots_cli.commands import MODULES
7
+
8
+
9
+ def parser():
10
+ p = argparse.ArgumentParser(prog="pb", description="pokerbots.dev")
11
+ p.add_argument("--version", action="version", version=f"pb {__version__}")
12
+ sub = p.add_subparsers(metavar="command")
13
+ for module in MODULES:
14
+ module.register(sub)
15
+ return p
16
+
17
+
18
+ def main(argv=None):
19
+ p = parser()
20
+ args = p.parse_args(argv)
21
+ if not hasattr(args, "run"):
22
+ p.print_help()
23
+ return 0
24
+ try:
25
+ return args.run(args) or 0
26
+ except (ApiError, ValueError) as e:
27
+ sys.exit(f"error: {e}")
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ class Bot:
2
+ def act(self, state):
3
+ if "check" in state["legal"]:
4
+ return ("check",)
5
+ return ("fold",)
@@ -0,0 +1,67 @@
1
+ import base64
2
+ import json
3
+
4
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
5
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
6
+
7
+ from .auth import credentials_path
8
+
9
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
10
+
11
+
12
+ def b58(data):
13
+ n = int.from_bytes(data, "big")
14
+ out = ""
15
+ while n:
16
+ n, r = divmod(n, 58)
17
+ out = ALPHABET[r] + out
18
+ return "1" * (len(data) - len(data.lstrip(b"\0"))) + out
19
+
20
+
21
+ def path():
22
+ return credentials_path().with_name("wallet.json")
23
+
24
+
25
+ def public(key):
26
+ return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
27
+
28
+
29
+ def load_or_create():
30
+ """Solana keypair file format: JSON array of 64 bytes, secret seed then public key."""
31
+ p = path()
32
+ if p.exists():
33
+ return Ed25519PrivateKey.from_private_bytes(bytes(json.loads(p.read_text())[:32]))
34
+ key = Ed25519PrivateKey.generate()
35
+ seed = key.private_bytes_raw()
36
+ p.parent.mkdir(parents=True, exist_ok=True)
37
+ p.touch(mode=0o600)
38
+ p.write_text(json.dumps(list(seed + public(key))))
39
+ return key
40
+
41
+
42
+ def address(key):
43
+ return b58(public(key))
44
+
45
+
46
+ def shortvec(data, i):
47
+ value = shift = 0
48
+ while True:
49
+ byte = data[i]
50
+ i += 1
51
+ value |= (byte & 0x7F) << shift
52
+ shift += 7
53
+ if byte < 0x80:
54
+ return value, i
55
+
56
+
57
+ def sign(transaction_b64, key):
58
+ """Adds our signature to a partially signed wire transaction."""
59
+ wire = base64.b64decode(transaction_b64)
60
+ count, start = shortvec(wire, 0)
61
+ message = wire[start + 64 * count:]
62
+ versioned = message[0] & 0x80
63
+ signers = message[1 if versioned else 0]
64
+ _, offset = shortvec(message, (1 if versioned else 0) + 3)
65
+ keys = [message[offset + 32 * i: offset + 32 * (i + 1)] for i in range(signers)]
66
+ at = start + 64 * keys.index(public(key))
67
+ return base64.b64encode(wire[:at] + key.sign(message) + wire[at + 64:]).decode()
@@ -0,0 +1,33 @@
1
+ import time
2
+
3
+ from pokerbots_cli.agent import Agent
4
+
5
+ STATE = {"legal": ["fold", "call", "raise"], "min_raise": 40, "max_raise": 500}
6
+
7
+
8
+ class Fixed:
9
+ def __init__(self, reply):
10
+ self.reply = reply
11
+
12
+ def act(self, state):
13
+ return self.reply(state)
14
+
15
+
16
+ def run(reply):
17
+ agent = Agent(Fixed(reply))
18
+ return agent(dict(STATE)), agent
19
+
20
+
21
+ def test_legal_actions_pass_and_raises_clamp():
22
+ assert run(lambda s: ("call",))[0] == ("call",)
23
+ assert run(lambda s: ("raise", 10_000))[0] == ("raise", 500)
24
+ assert run(lambda s: ("raise", 1))[0] == ("raise", 40)
25
+
26
+
27
+ def test_bad_bots_fold():
28
+ action, agent = run(lambda s: time.sleep(0.03))
29
+ assert action == ("fold",) and agent.late == 1
30
+ action, agent = run(lambda s: 1 / 0)
31
+ assert action == ("fold",) and "ZeroDivisionError" in agent.error
32
+ action, agent = run(lambda s: ("check",))
33
+ assert action == ("fold",) and agent.errors == 1
@@ -0,0 +1,27 @@
1
+ from pokerbots_cli.cards import best
2
+
3
+
4
+ def hand(text):
5
+ return best(text.split())
6
+
7
+
8
+ def test_categories_ordered():
9
+ ladder = [
10
+ "2c 7d 9h Js Kc 3d 4h", # high card
11
+ "2c 2d 9h Js Kc 3d 4h", # pair
12
+ "2c 2d 9h 9s Kc 3d 4h", # two pair
13
+ "2c 2d 2h Js Kc 3d 4h", # trips
14
+ "Ac 2d 3h 4s 5c 9d Jh", # wheel
15
+ "6c 2d 3h 4s 5c 9d Jh", # six-high straight
16
+ "2c 7c 9c Jc Kc 3d 4h", # flush
17
+ "2c 2d 2h Js Jc 3d 4h", # full house
18
+ "2c 2d 2h 2s Kc 3d 4h", # quads
19
+ "Ac 2c 3c 4c 5c 9d Jh", # steel wheel
20
+ ]
21
+ ranks = [hand(h) for h in ladder]
22
+ assert ranks == sorted(ranks) and len(set(ranks)) == len(ranks)
23
+
24
+
25
+ def test_kicker_and_board_play():
26
+ assert hand("As Kd 7c 7d 9h Js 2c") > hand("As Qd 7c 7d 9h Js 2c")
27
+ assert hand("2s 3d Ac Kc Qc Jc Tc") == hand("4s 5d Ac Kc Qc Jc Tc")
@@ -0,0 +1,22 @@
1
+ import pytest
2
+
3
+ from pokerbots_cli.main import main
4
+
5
+
6
+ def test_version(capsys):
7
+ with pytest.raises(SystemExit) as exc:
8
+ main(["--version"])
9
+ assert exc.value.code == 0
10
+ assert capsys.readouterr().out.startswith("pb ")
11
+
12
+
13
+ def test_init_then_test(tmp_path, capsys):
14
+ bot = tmp_path / "mybot"
15
+ assert main(["init", str(bot)]) == 0
16
+ assert main(["test", str(bot), "--games", "3", "--seed", "1"]) == 0
17
+ assert "errors 0" in capsys.readouterr().out
18
+
19
+
20
+ def test_docs_plain(capsys):
21
+ assert main(["docs", "--plain"]) == 0
22
+ assert "min_raise" in capsys.readouterr().out
@@ -0,0 +1,36 @@
1
+ import random
2
+
3
+ from pokerbots_cli import engine
4
+ from pokerbots_cli.agent import Agent
5
+ from pokerbots_cli.house import BOTS, Raiser
6
+
7
+
8
+ def shove(state):
9
+ if "raise" in state["legal"]:
10
+ return ("raise", state["max_raise"])
11
+ return ("call",) if "call" in state["legal"] else ("check",)
12
+
13
+
14
+ def test_games_conserve_chips_and_rank_everyone():
15
+ rng = random.Random(1)
16
+ for _ in range(20):
17
+ result = engine.play([Agent(Raiser(rng))] + [Agent(cls()) for cls in BOTS], rng)
18
+ assert sum(result.stacks) == 6 * engine.STACK
19
+ assert sorted(result.places) == list(range(6))
20
+ assert result.hands <= engine.MAX_HANDS
21
+
22
+
23
+ def test_side_pots():
24
+ stacks = [100, 300, 300]
25
+ players = [engine.Player(seat, shove, stack) for seat, stack in enumerate(stacks)]
26
+ dealt = "Ah Ad Kh Kd Qh Qd 2c 7d 9h Js 4c".split()
27
+ engine.Hand(1, players, 0, dealt[::-1], 3).play()
28
+ assert [p.stack for p in players] == [300, 400, 0]
29
+
30
+
31
+ def test_heads_up_button_posts_small_blind():
32
+ seen = []
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()
35
+ assert seen[0]["seat"] == 1 and seen[0]["bets"] == [6, 3]
36
+ assert [p.stack for p in players] == [1003, 997]
@@ -0,0 +1,24 @@
1
+ import base64
2
+
3
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
4
+
5
+ from pokerbots_cli import wallet
6
+ from pokerbots_cli.fmt import money, parse_money
7
+
8
+
9
+ def test_sign_fills_our_slot_only():
10
+ payer, me = Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()
11
+ message = bytes([0x80, 2, 0, 1, 3]) + wallet.public(payer) + wallet.public(me) + bytes(32) + bytes(40)
12
+ payer_signature = payer.sign(message)
13
+ wire = bytes([2]) + payer_signature + bytes(64) + message
14
+ signed = base64.b64decode(wallet.sign(base64.b64encode(wire).decode(), me))
15
+ assert signed[1:65] == payer_signature
16
+ me.public_key().verify(signed[65:129], message)
17
+ assert signed[129:] == message
18
+
19
+
20
+ def test_b58_and_money():
21
+ assert wallet.b58(bytes(32)) == "1" * 32
22
+ assert wallet.b58(b"\x00\x01") == "12"
23
+ assert [money(100_000), money(370_000), money(4_200_000)] == ["10¢", "37¢", "$4.20"]
24
+ assert parse_money("$0.10") == 100_000
@@ -1,15 +0,0 @@
1
- import argparse
2
-
3
- from pokerbots_cli import __version__
4
-
5
-
6
- def main(argv: list[str] | None = None) -> int:
7
- parser = argparse.ArgumentParser(prog="pb", description="pokerbots.dev command-line client")
8
- parser.add_argument("--version", action="version", version=f"pb {__version__}")
9
- parser.parse_args(argv)
10
- parser.print_help()
11
- return 0
12
-
13
-
14
- if __name__ == "__main__":
15
- raise SystemExit(main())
@@ -1,15 +0,0 @@
1
- import pytest
2
-
3
- from pokerbots_cli.main import main
4
-
5
-
6
- def test_version(capsys):
7
- with pytest.raises(SystemExit) as exc:
8
- main(["--version"])
9
- assert exc.value.code == 0
10
- assert capsys.readouterr().out.startswith("pb ")
11
-
12
-
13
- def test_no_args_prints_help(capsys):
14
- assert main([]) == 0
15
- assert "usage: pb" in capsys.readouterr().out
File without changes