Skip to content

Commit 19d9fe4

Browse files
authored
Add preset poker hands to support duplicate poker. (#524)
* Add preset poker hands to support duplicate poker. * Add preset-hand loader toggle and fast repeated poker tests.
1 parent 960f9e6 commit 19d9fe4

File tree

4 files changed

+10425
-1
lines changed

4 files changed

+10425
-1
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
"""Utility for generating preset hand sequences for repeated poker."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import logging
9+
import pathlib
10+
import random
11+
from typing import Iterable
12+
13+
DEFAULT_DECK_SIZE = 52
14+
DEFAULT_NUM_HANDS = 100
15+
DEFAULT_CARDS_PER_HAND = 9
16+
17+
LOGGER = logging.getLogger(__name__)
18+
19+
20+
def parse_args() -> argparse.Namespace:
21+
parser = argparse.ArgumentParser(description=__doc__)
22+
parser.add_argument(
23+
"--seed",
24+
type=int,
25+
default=0,
26+
help="Base random seed used to sample cards.",
27+
)
28+
parser.add_argument(
29+
"--num-hands",
30+
type=int,
31+
default=DEFAULT_NUM_HANDS,
32+
help="Number of hands to generate for each preset group.",
33+
)
34+
parser.add_argument(
35+
"--cards-per-hand",
36+
type=int,
37+
default=DEFAULT_CARDS_PER_HAND,
38+
help="Number of card chance actions to sample for each hand.",
39+
)
40+
parser.add_argument(
41+
"--num-presets",
42+
type=int,
43+
default=1,
44+
help="Number of preset hand groups to emit.",
45+
)
46+
parser.add_argument(
47+
"--deck-size",
48+
type=int,
49+
default=DEFAULT_DECK_SIZE,
50+
help="Size of the deck to sample from (expected 52 for standard hold'em).",
51+
)
52+
parser.add_argument(
53+
"--output",
54+
type=pathlib.Path,
55+
default=pathlib.Path(__file__).with_name("preset_hands.jsonl"),
56+
help="Output JSONL path. Defaults to preset_hands.jsonl in the same directory.",
57+
)
58+
parser.add_argument(
59+
"--force",
60+
action="store_true",
61+
help="Overwrite the output file if it already exists.",
62+
)
63+
parser.add_argument(
64+
"--skip-verify",
65+
action="store_true",
66+
help="Skip confirming the OpenSpiel chance action range (pyspiel required).",
67+
)
68+
parser.add_argument(
69+
"--log-level",
70+
default="INFO",
71+
choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
72+
help="Logging verbosity.",
73+
)
74+
args = parser.parse_args()
75+
logging.basicConfig(level=getattr(logging, args.log_level))
76+
return args
77+
78+
79+
def confirm_chance_action_range(deck_size: int) -> None:
80+
"""Confirm that OpenSpiel emits chance actions in [0, deck_size)."""
81+
try:
82+
import pyspiel # type: ignore
83+
except ImportError: # pragma: no cover - pyspiel unavailable in many dev envs.
84+
LOGGER.warning("pyspiel not installed; skipping chance action verification.")
85+
return
86+
87+
game = pyspiel.load_game("repeated_poker")
88+
state = game.new_initial_state()
89+
if not state.is_chance_node():
90+
LOGGER.debug("Initial state not a chance node; advancing until chance.")
91+
while not state.is_terminal() and not state.is_chance_node():
92+
legal = state.legal_actions()
93+
if not legal:
94+
break
95+
state.apply_action(legal[0])
96+
97+
if not state.is_chance_node():
98+
raise RuntimeError("Failed to reach a chance node while verifying deck range.")
99+
100+
outcomes, _ = zip(*state.chance_outcomes())
101+
observed = set(outcomes)
102+
expected = set(range(deck_size))
103+
if observed != expected:
104+
raise ValueError(
105+
"Unexpected chance action range: "
106+
f"observed {min(observed)}-{max(observed)} covering {len(observed)} actions, "
107+
f"expected {deck_size} actions spanning 0-{deck_size - 1}."
108+
)
109+
LOGGER.info(
110+
"Verified OpenSpiel chance actions span 0-%d (%d entries).",
111+
deck_size - 1,
112+
deck_size,
113+
)
114+
115+
116+
def generate_hands(
117+
rng: random.Random,
118+
*,
119+
num_hands: int,
120+
cards_per_hand: int,
121+
deck_size: int,
122+
) -> list[list[int]]:
123+
if not 0 < cards_per_hand <= deck_size:
124+
raise ValueError(
125+
f"cards_per_hand must be in [1, deck_size]; got cards_per_hand={cards_per_hand}, deck_size={deck_size}."
126+
)
127+
return [
128+
rng.sample(range(deck_size), cards_per_hand) for _ in range(num_hands)
129+
]
130+
131+
132+
def generate_presets(
133+
*,
134+
seed: int,
135+
num_presets: int,
136+
num_hands: int,
137+
cards_per_hand: int,
138+
deck_size: int,
139+
) -> Iterable[dict[str, object]]:
140+
rng = random.Random(seed)
141+
for preset_index in range(num_presets):
142+
hands = generate_hands(
143+
rng,
144+
num_hands=num_hands,
145+
cards_per_hand=cards_per_hand,
146+
deck_size=deck_size,
147+
)
148+
yield {
149+
"presetHands": hands,
150+
"seed": seed,
151+
"presetIndex": preset_index,
152+
"numHands": num_hands,
153+
"cardsPerHand": cards_per_hand,
154+
"deckSize": deck_size,
155+
}
156+
157+
158+
def main() -> None:
159+
args = parse_args()
160+
if args.output.exists() and not args.force:
161+
raise FileExistsError(
162+
f"{args.output} already exists. Pass --force to overwrite."
163+
)
164+
165+
if not args.skip_verify:
166+
confirm_chance_action_range(args.deck_size)
167+
168+
args.output.parent.mkdir(parents=True, exist_ok=True)
169+
with args.output.open("w", encoding="utf-8") as outfile:
170+
for preset in generate_presets(
171+
seed=args.seed,
172+
num_presets=args.num_presets,
173+
num_hands=args.num_hands,
174+
cards_per_hand=args.cards_per_hand,
175+
deck_size=args.deck_size,
176+
):
177+
outfile.write(json.dumps(preset))
178+
outfile.write("\n")
179+
LOGGER.info(
180+
"Wrote %d preset hand group(s) to %s", args.num_presets, args.output.resolve()
181+
)
182+
183+
184+
if __name__ == "__main__":
185+
main()

0 commit comments

Comments
 (0)