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