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.
- {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/.gitignore +1 -0
- {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/PKG-INFO +2 -1
- {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/pyproject.toml +2 -2
- pokerbots_dev-0.1.0/src/pokerbots_cli/agent.py +48 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/api.py +48 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/auth.py +61 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/cards.py +41 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/commands/__init__.py +3 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/commands/account.py +89 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/commands/admin.py +36 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/commands/local.py +83 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/commands/money.py +37 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/docs.md +91 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/engine.py +151 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/fmt.py +23 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/house.py +19 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/main.py +31 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/template/bot.py +5 -0
- pokerbots_dev-0.1.0/src/pokerbots_cli/wallet.py +67 -0
- pokerbots_dev-0.1.0/tests/test_agent.py +33 -0
- pokerbots_dev-0.1.0/tests/test_cards.py +27 -0
- pokerbots_dev-0.1.0/tests/test_cli.py +22 -0
- pokerbots_dev-0.1.0/tests/test_engine.py +36 -0
- pokerbots_dev-0.1.0/tests/test_wallet.py +24 -0
- pokerbots_dev-0.0.1/src/pokerbots_cli/main.py +0 -15
- pokerbots_dev-0.0.1/tests/test_cli.py +0 -15
- {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/README.md +0 -0
- {pokerbots_dev-0.0.1 → pokerbots_dev-0.1.0}/src/pokerbots_cli/__init__.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pokerbots-dev
|
|
3
|
-
Version: 0.0
|
|
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
|
|
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,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,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
|
|
File without changes
|