poker-cli 0.11.2__py3-none-any.whl
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.
- poker_cli/__init__.py +3 -0
- poker_cli/builtin_ranges.py +132 -0
- poker_cli/cards.py +42 -0
- poker_cli/cli.py +1250 -0
- poker_cli/coach.py +233 -0
- poker_cli/controllers.py +539 -0
- poker_cli/engine.py +281 -0
- poker_cli/evaluator.py +67 -0
- poker_cli/history.py +198 -0
- poker_cli/models.py +129 -0
- poker_cli/ranges.py +418 -0
- poker_cli/review.py +70 -0
- poker_cli/savegame.py +235 -0
- poker_cli/settings.py +178 -0
- poker_cli/strategy.py +80 -0
- poker_cli/tournament.py +427 -0
- poker_cli/tui.py +1325 -0
- poker_cli/usage.py +276 -0
- poker_cli-0.11.2.dist-info/METADATA +201 -0
- poker_cli-0.11.2.dist-info/RECORD +24 -0
- poker_cli-0.11.2.dist-info/WHEEL +5 -0
- poker_cli-0.11.2.dist-info/entry_points.txt +2 -0
- poker_cli-0.11.2.dist-info/licenses/LICENSE +21 -0
- poker_cli-0.11.2.dist-info/top_level.txt +1 -0
poker_cli/controllers.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
from .cards import Card
|
|
20
|
+
from .models import Action, ActionKind, Observation, Street
|
|
21
|
+
from .strategy import estimate_equity
|
|
22
|
+
from .usage import ModelUsageTracker, parse_claude_result, parse_codex_usage
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Controller(ABC):
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def act(self, observation: Observation) -> Action:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HumanController(Controller):
|
|
32
|
+
def act(self, observation: Observation) -> Action:
|
|
33
|
+
legal = observation.legal_actions
|
|
34
|
+
while True:
|
|
35
|
+
pieces = []
|
|
36
|
+
if legal.can_check:
|
|
37
|
+
pieces.append("check")
|
|
38
|
+
if legal.call_amount:
|
|
39
|
+
pieces.append(f"call {legal.call_amount}")
|
|
40
|
+
if legal.can_fold:
|
|
41
|
+
pieces.append("fold")
|
|
42
|
+
if legal.min_raise_to is not None:
|
|
43
|
+
pieces.append(f"raise {legal.min_raise_to}-{legal.max_raise_to}")
|
|
44
|
+
raw = input(f"你的行动 [{', '.join(pieces)}] > ").strip().lower()
|
|
45
|
+
action = parse_action(raw)
|
|
46
|
+
if action is not None and is_legal(action, observation):
|
|
47
|
+
return action
|
|
48
|
+
print("无效行动,请按提示输入,例如 check、call、fold 或 raise 80。")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class BotProfile:
|
|
53
|
+
key: str
|
|
54
|
+
name: str
|
|
55
|
+
description: str
|
|
56
|
+
value_raise: float
|
|
57
|
+
bluff_rate: float
|
|
58
|
+
trap_rate: float
|
|
59
|
+
fold_bias: float
|
|
60
|
+
bet_sizes: tuple[float, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
BOT_PROFILES = {
|
|
64
|
+
"balanced": BotProfile(
|
|
65
|
+
"balanced",
|
|
66
|
+
"均衡型",
|
|
67
|
+
"价值下注、半诈唬和控池都有,行动相对稳定",
|
|
68
|
+
0.64,
|
|
69
|
+
0.07,
|
|
70
|
+
0.12,
|
|
71
|
+
0.00,
|
|
72
|
+
(0.50, 0.66, 0.75, 1.00),
|
|
73
|
+
),
|
|
74
|
+
"tight": BotProfile(
|
|
75
|
+
"tight",
|
|
76
|
+
"紧手型",
|
|
77
|
+
"入池谨慎,强牌持续施压,很少做纯诈唬",
|
|
78
|
+
0.70,
|
|
79
|
+
0.02,
|
|
80
|
+
0.16,
|
|
81
|
+
0.05,
|
|
82
|
+
(0.33, 0.50, 0.66),
|
|
83
|
+
),
|
|
84
|
+
"aggressive": BotProfile(
|
|
85
|
+
"aggressive",
|
|
86
|
+
"松凶型",
|
|
87
|
+
"宽范围入池,频繁施压,包含较多诈唬和超池下注",
|
|
88
|
+
0.57,
|
|
89
|
+
0.17,
|
|
90
|
+
0.05,
|
|
91
|
+
-0.04,
|
|
92
|
+
(0.50, 0.75, 1.00, 1.25, 1.50),
|
|
93
|
+
),
|
|
94
|
+
"tricky": BotProfile(
|
|
95
|
+
"tricky",
|
|
96
|
+
"诡诈型",
|
|
97
|
+
"强牌慢打、弱牌突袭,行动频率更难预测",
|
|
98
|
+
0.62,
|
|
99
|
+
0.13,
|
|
100
|
+
0.30,
|
|
101
|
+
-0.01,
|
|
102
|
+
(0.33, 0.50, 0.75, 1.25),
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OfflineBot(Controller):
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
profile: str = "balanced",
|
|
111
|
+
rng: random.Random | None = None,
|
|
112
|
+
samples: int = 450,
|
|
113
|
+
) -> None:
|
|
114
|
+
if profile not in BOT_PROFILES:
|
|
115
|
+
raise ValueError(f"unknown offline profile: {profile}")
|
|
116
|
+
self.profile = BOT_PROFILES[profile]
|
|
117
|
+
self.rng = rng or random.Random()
|
|
118
|
+
self.samples = samples
|
|
119
|
+
|
|
120
|
+
def act(self, observation: Observation) -> Action:
|
|
121
|
+
hole = [Card.parse(code) for code in observation.hole_cards]
|
|
122
|
+
board = [Card.parse(code) for code in observation.community_cards]
|
|
123
|
+
equity = estimate_equity(
|
|
124
|
+
hole,
|
|
125
|
+
board,
|
|
126
|
+
opponents=observation.active_opponents,
|
|
127
|
+
samples=self.samples,
|
|
128
|
+
rng=self.rng,
|
|
129
|
+
)
|
|
130
|
+
legal = observation.legal_actions
|
|
131
|
+
to_call = legal.call_amount
|
|
132
|
+
pot_odds = to_call / (observation.pot + to_call) if to_call else 0
|
|
133
|
+
noise = self.rng.uniform(-0.045, 0.045)
|
|
134
|
+
perceived_equity = max(0.0, min(1.0, equity + noise))
|
|
135
|
+
can_raise = legal.min_raise_to is not None
|
|
136
|
+
bluff = can_raise and self.rng.random() < self.profile.bluff_rate
|
|
137
|
+
strong = perceived_equity >= self.profile.value_raise
|
|
138
|
+
|
|
139
|
+
if to_call:
|
|
140
|
+
fold_threshold = max(0.16, pot_odds + self.profile.fold_bias)
|
|
141
|
+
if perceived_equity < fold_threshold and not bluff:
|
|
142
|
+
return Action(ActionKind.FOLD)
|
|
143
|
+
if can_raise and (strong or bluff):
|
|
144
|
+
if strong and self.rng.random() < self.profile.trap_rate:
|
|
145
|
+
return Action(ActionKind.CALL)
|
|
146
|
+
return Action(ActionKind.RAISE, self._raise_target(observation))
|
|
147
|
+
return Action(ActionKind.CALL)
|
|
148
|
+
|
|
149
|
+
if can_raise:
|
|
150
|
+
medium_probe = 0.46 <= perceived_equity < self.profile.value_raise
|
|
151
|
+
probe_rate = 0.16 + self.profile.bluff_rate
|
|
152
|
+
if strong and self.rng.random() >= self.profile.trap_rate:
|
|
153
|
+
return Action(ActionKind.RAISE, self._raise_target(observation))
|
|
154
|
+
if bluff or (medium_probe and self.rng.random() < probe_rate):
|
|
155
|
+
return Action(ActionKind.RAISE, self._raise_target(observation))
|
|
156
|
+
return Action(ActionKind.CHECK)
|
|
157
|
+
|
|
158
|
+
def _raise_target(self, observation: Observation) -> int:
|
|
159
|
+
legal = observation.legal_actions
|
|
160
|
+
assert legal.min_raise_to is not None and legal.max_raise_to is not None
|
|
161
|
+
size = self.rng.choice(self.profile.bet_sizes)
|
|
162
|
+
to_call = legal.call_amount
|
|
163
|
+
if observation.street.value == "preflop":
|
|
164
|
+
extra = round(observation.big_blind * max(2.0, size * 3.0))
|
|
165
|
+
else:
|
|
166
|
+
extra = round(max(observation.big_blind, observation.pot * size))
|
|
167
|
+
target = observation.own_street_bet + to_call + extra
|
|
168
|
+
return min(legal.max_raise_to, max(legal.min_raise_to, target))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class StrategyBot(OfflineBot):
|
|
172
|
+
"""Backwards-compatible name for the balanced offline bot."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, rng: random.Random | None = None, samples: int = 450) -> None:
|
|
175
|
+
super().__init__("balanced", rng=rng, samples=samples)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
ThinkingSink = Callable[[str, str | None], None]
|
|
179
|
+
NoticeSink = Callable[[str], None]
|
|
180
|
+
FastForwardCheck = Callable[[], bool]
|
|
181
|
+
OnlineDecisionCheck = Callable[[Observation], bool]
|
|
182
|
+
|
|
183
|
+
POKER_ACTION_SCHEMA = {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {
|
|
186
|
+
"action": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"enum": ["fold", "check", "call", "raise"],
|
|
189
|
+
},
|
|
190
|
+
"amount": {"type": "integer"},
|
|
191
|
+
},
|
|
192
|
+
"required": ["action", "amount"],
|
|
193
|
+
"additionalProperties": False,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class FastForwardRequested(RuntimeError):
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class OnlineDecisionBudget:
|
|
202
|
+
"""Share a small model-call budget across every online seat in one hand."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, max_calls_per_hand: int = 3) -> None:
|
|
205
|
+
self.max_calls_per_hand = max_calls_per_hand
|
|
206
|
+
self.hand_id = 0
|
|
207
|
+
self.calls = 0
|
|
208
|
+
|
|
209
|
+
def claim(self, observation: Observation) -> bool:
|
|
210
|
+
if observation.hand_id != self.hand_id:
|
|
211
|
+
self.hand_id = observation.hand_id
|
|
212
|
+
self.calls = 0
|
|
213
|
+
if self.calls >= self.max_calls_per_hand:
|
|
214
|
+
return False
|
|
215
|
+
big_blind = max(1, observation.big_blind)
|
|
216
|
+
meaningful_call = observation.legal_actions.call_amount >= big_blind * 2
|
|
217
|
+
developed_pot = observation.pot >= big_blind * 6
|
|
218
|
+
late_decision = (
|
|
219
|
+
observation.street in {Street.TURN, Street.RIVER}
|
|
220
|
+
and observation.pot >= big_blind * 3
|
|
221
|
+
)
|
|
222
|
+
if not (meaningful_call or developed_pot or late_decision):
|
|
223
|
+
return False
|
|
224
|
+
self.calls += 1
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class LocalAgentCLIController(Controller):
|
|
229
|
+
"""Poker opponent backed by an authenticated local Codex or Claude CLI.
|
|
230
|
+
|
|
231
|
+
Only the immutable Observation payload is serialized for the agent. The
|
|
232
|
+
engine, deck, and other players' hole cards never enter the prompt.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
provider: str,
|
|
238
|
+
actor_name: str,
|
|
239
|
+
*,
|
|
240
|
+
thinking_sink: ThinkingSink | None = None,
|
|
241
|
+
notice_sink: NoticeSink | None = None,
|
|
242
|
+
timeout: int = 120,
|
|
243
|
+
fallback: Controller | None = None,
|
|
244
|
+
persona: str = "",
|
|
245
|
+
fast_forward: FastForwardCheck | None = None,
|
|
246
|
+
online_decision: OnlineDecisionCheck | None = None,
|
|
247
|
+
model: str | None = None,
|
|
248
|
+
usage_tracker: ModelUsageTracker | None = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
if provider not in {"codex", "claude"}:
|
|
251
|
+
raise ValueError(f"unknown local agent provider: {provider}")
|
|
252
|
+
if shutil.which(provider) is None:
|
|
253
|
+
raise ValueError(f"找不到本地 {provider} 命令")
|
|
254
|
+
self.provider = provider
|
|
255
|
+
self.actor_name = actor_name
|
|
256
|
+
self.thinking_sink = thinking_sink
|
|
257
|
+
self.notice_sink = notice_sink
|
|
258
|
+
self.timeout = timeout
|
|
259
|
+
self.fallback = fallback or OfflineBot("balanced", samples=40)
|
|
260
|
+
self.persona = persona
|
|
261
|
+
self.fast_forward = fast_forward
|
|
262
|
+
self.online_decision = online_decision
|
|
263
|
+
self.model = model
|
|
264
|
+
self.usage_tracker = usage_tracker
|
|
265
|
+
|
|
266
|
+
def act(self, observation: Observation) -> Action:
|
|
267
|
+
if self.fast_forward is not None and self.fast_forward():
|
|
268
|
+
return self.fallback.act(observation)
|
|
269
|
+
if self.online_decision is not None and not self.online_decision(observation):
|
|
270
|
+
return self.fallback.act(observation)
|
|
271
|
+
if self.thinking_sink is not None:
|
|
272
|
+
self.thinking_sink(self.actor_name, self.provider)
|
|
273
|
+
try:
|
|
274
|
+
prompt = build_local_agent_prompt(observation, self.persona)
|
|
275
|
+
content = self._run_codex(prompt) if self.provider == "codex" else self._run_claude(prompt)
|
|
276
|
+
action = parse_llm_action(content)
|
|
277
|
+
if action is None or not is_legal(action, observation):
|
|
278
|
+
raise ValueError("返回了非法或无法解析的行动")
|
|
279
|
+
return action
|
|
280
|
+
except FastForwardRequested:
|
|
281
|
+
return self.fallback.act(observation)
|
|
282
|
+
except (OSError, subprocess.SubprocessError, ValueError) as error:
|
|
283
|
+
if self.notice_sink is not None:
|
|
284
|
+
self.notice_sink(
|
|
285
|
+
f"{self.actor_name} 的 {self.provider} 不可用,已使用离线决策:{error}"
|
|
286
|
+
)
|
|
287
|
+
return self.fallback.act(observation)
|
|
288
|
+
finally:
|
|
289
|
+
if self.thinking_sink is not None:
|
|
290
|
+
self.thinking_sink(self.actor_name, None)
|
|
291
|
+
|
|
292
|
+
def _run_codex(self, prompt: str) -> str:
|
|
293
|
+
with tempfile.TemporaryDirectory(prefix="poker-opponent-") as temp_dir:
|
|
294
|
+
output_path = Path(temp_dir) / "action.txt"
|
|
295
|
+
schema_path = Path(temp_dir) / "action-schema.json"
|
|
296
|
+
schema_path.write_text(
|
|
297
|
+
json.dumps(POKER_ACTION_SCHEMA, separators=(",", ":")),
|
|
298
|
+
encoding="utf-8",
|
|
299
|
+
)
|
|
300
|
+
command = [
|
|
301
|
+
"codex",
|
|
302
|
+
"exec",
|
|
303
|
+
"--ephemeral",
|
|
304
|
+
"--ignore-user-config",
|
|
305
|
+
"--ignore-rules",
|
|
306
|
+
"--disable",
|
|
307
|
+
"plugins",
|
|
308
|
+
"--disable",
|
|
309
|
+
"remote_plugin",
|
|
310
|
+
"--disable",
|
|
311
|
+
"apps",
|
|
312
|
+
"--sandbox",
|
|
313
|
+
"read-only",
|
|
314
|
+
"--skip-git-repo-check",
|
|
315
|
+
"--color",
|
|
316
|
+
"never",
|
|
317
|
+
"--json",
|
|
318
|
+
"--output-last-message",
|
|
319
|
+
str(output_path),
|
|
320
|
+
"--output-schema",
|
|
321
|
+
str(schema_path),
|
|
322
|
+
]
|
|
323
|
+
if self.model:
|
|
324
|
+
command.extend(("-m", self.model))
|
|
325
|
+
command.extend(("-c", 'model_reasoning_effort="low"', "-"))
|
|
326
|
+
completed = self._run(command, prompt, cwd=temp_dir)
|
|
327
|
+
usage = parse_codex_usage(completed.stdout)
|
|
328
|
+
if usage is not None and self.usage_tracker is not None:
|
|
329
|
+
self.usage_tracker.record("codex", usage)
|
|
330
|
+
if not output_path.exists():
|
|
331
|
+
raise ValueError("Codex 没有生成行动")
|
|
332
|
+
return output_path.read_text(encoding="utf-8").strip()
|
|
333
|
+
|
|
334
|
+
def _run_claude(self, prompt: str) -> str:
|
|
335
|
+
command = [
|
|
336
|
+
"claude",
|
|
337
|
+
"-p",
|
|
338
|
+
"--safe-mode",
|
|
339
|
+
"--tools",
|
|
340
|
+
"",
|
|
341
|
+
"--permission-mode",
|
|
342
|
+
"dontAsk",
|
|
343
|
+
"--no-session-persistence",
|
|
344
|
+
"--model",
|
|
345
|
+
self.model or "haiku",
|
|
346
|
+
"--effort",
|
|
347
|
+
"low",
|
|
348
|
+
"--system-prompt",
|
|
349
|
+
"Choose quickly. Never use tools.",
|
|
350
|
+
"--json-schema",
|
|
351
|
+
json.dumps(POKER_ACTION_SCHEMA, separators=(",", ":")),
|
|
352
|
+
"--output-format",
|
|
353
|
+
"json",
|
|
354
|
+
]
|
|
355
|
+
content, usage = parse_claude_result(self._run(command, prompt).stdout)
|
|
356
|
+
if usage is not None and self.usage_tracker is not None:
|
|
357
|
+
self.usage_tracker.record("claude", usage)
|
|
358
|
+
return content
|
|
359
|
+
|
|
360
|
+
def _run(
|
|
361
|
+
self,
|
|
362
|
+
command: list[str],
|
|
363
|
+
prompt: str,
|
|
364
|
+
*,
|
|
365
|
+
cwd: str | None = None,
|
|
366
|
+
) -> subprocess.CompletedProcess[str]:
|
|
367
|
+
with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as stdout_file, (
|
|
368
|
+
tempfile.TemporaryFile(mode="w+", encoding="utf-8")
|
|
369
|
+
) as stderr_file:
|
|
370
|
+
try:
|
|
371
|
+
process = subprocess.Popen(
|
|
372
|
+
command,
|
|
373
|
+
stdin=subprocess.PIPE,
|
|
374
|
+
stdout=stdout_file,
|
|
375
|
+
stderr=stderr_file,
|
|
376
|
+
text=True,
|
|
377
|
+
cwd=cwd,
|
|
378
|
+
start_new_session=True,
|
|
379
|
+
)
|
|
380
|
+
except OSError:
|
|
381
|
+
raise
|
|
382
|
+
assert process.stdin is not None
|
|
383
|
+
process.stdin.write(prompt)
|
|
384
|
+
process.stdin.close()
|
|
385
|
+
process.stdin = None
|
|
386
|
+
deadline = time.monotonic() + self.timeout
|
|
387
|
+
while process.poll() is None:
|
|
388
|
+
if self.fast_forward is not None and self.fast_forward():
|
|
389
|
+
self._terminate_process_group(process)
|
|
390
|
+
raise FastForwardRequested
|
|
391
|
+
if time.monotonic() >= deadline:
|
|
392
|
+
self._terminate_process_group(process)
|
|
393
|
+
raise ValueError(f"{self.provider} 思考超时")
|
|
394
|
+
time.sleep(0.05)
|
|
395
|
+
stdout_file.seek(0)
|
|
396
|
+
stderr_file.seek(0)
|
|
397
|
+
completed = subprocess.CompletedProcess(
|
|
398
|
+
command,
|
|
399
|
+
process.returncode,
|
|
400
|
+
stdout_file.read(),
|
|
401
|
+
stderr_file.read(),
|
|
402
|
+
)
|
|
403
|
+
if completed.returncode != 0:
|
|
404
|
+
details = completed.stderr.strip().splitlines()
|
|
405
|
+
suffix = f":{details[-1]}" if details else ""
|
|
406
|
+
raise ValueError(f"{self.provider} 调用失败{suffix}")
|
|
407
|
+
return completed
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _terminate_process_group(process: subprocess.Popen[str]) -> None:
|
|
411
|
+
try:
|
|
412
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
413
|
+
process.wait(timeout=0.5)
|
|
414
|
+
except (ProcessLookupError, subprocess.TimeoutExpired):
|
|
415
|
+
try:
|
|
416
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
417
|
+
except ProcessLookupError:
|
|
418
|
+
pass
|
|
419
|
+
process.wait()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def build_local_agent_prompt(observation: Observation, persona: str = "") -> str:
|
|
423
|
+
packet = observation.to_dict()
|
|
424
|
+
return (
|
|
425
|
+
"Pick one legal no-limit Hold'em action with a simple human heuristic. "
|
|
426
|
+
"Use at most 3 brief internal checks. Use only your cards and public state. "
|
|
427
|
+
f"Tendency: {persona or 'balanced'}. "
|
|
428
|
+
"For raise, amount is the total street bet inside the legal range; otherwise 0. "
|
|
429
|
+
f"STATE={json.dumps(packet, ensure_ascii=False, separators=(',', ':'))}"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class LLMController(Controller):
|
|
434
|
+
"""OpenAI-compatible chat-completions adapter.
|
|
435
|
+
|
|
436
|
+
The adapter only accepts an Observation, which intentionally contains no
|
|
437
|
+
opponent hole cards or undealt deck state.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
def __init__(
|
|
441
|
+
self,
|
|
442
|
+
*,
|
|
443
|
+
base_url: str | None = None,
|
|
444
|
+
api_key: str | None = None,
|
|
445
|
+
model: str | None = None,
|
|
446
|
+
timeout: float = 20,
|
|
447
|
+
fallback: Controller | None = None,
|
|
448
|
+
) -> None:
|
|
449
|
+
self.base_url = (base_url or os.getenv("POKER_LLM_BASE_URL", "")).rstrip("/")
|
|
450
|
+
self.api_key = api_key or os.getenv("POKER_LLM_API_KEY", "")
|
|
451
|
+
self.model = model or os.getenv("POKER_LLM_MODEL", "")
|
|
452
|
+
self.timeout = timeout
|
|
453
|
+
self.fallback = fallback or StrategyBot()
|
|
454
|
+
if not self.base_url or not self.model:
|
|
455
|
+
raise ValueError("LLM opponent requires POKER_LLM_BASE_URL and POKER_LLM_MODEL")
|
|
456
|
+
|
|
457
|
+
def act(self, observation: Observation) -> Action:
|
|
458
|
+
payload = {
|
|
459
|
+
"model": self.model,
|
|
460
|
+
"temperature": 0.35,
|
|
461
|
+
"messages": [
|
|
462
|
+
{
|
|
463
|
+
"role": "system",
|
|
464
|
+
"content": (
|
|
465
|
+
"你是一个无限注德州扑克牌手。你只能使用用户提供的公开信息和自己的手牌。"
|
|
466
|
+
"只输出 JSON:{\"action\":\"fold|check|call|raise\",\"amount\":整数}。"
|
|
467
|
+
"raise 的 amount 表示本轮下注总额,必须落在合法范围。"
|
|
468
|
+
),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
"role": "user",
|
|
472
|
+
"content": json.dumps(observation.to_dict(), ensure_ascii=False),
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
}
|
|
476
|
+
request = urllib.request.Request(
|
|
477
|
+
f"{self.base_url}/chat/completions",
|
|
478
|
+
data=json.dumps(payload).encode(),
|
|
479
|
+
headers={
|
|
480
|
+
"Content-Type": "application/json",
|
|
481
|
+
**({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}),
|
|
482
|
+
},
|
|
483
|
+
method="POST",
|
|
484
|
+
)
|
|
485
|
+
try:
|
|
486
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
487
|
+
body = json.loads(response.read())
|
|
488
|
+
content = body["choices"][0]["message"]["content"]
|
|
489
|
+
action = parse_llm_action(content)
|
|
490
|
+
if action is None or not is_legal(action, observation):
|
|
491
|
+
return self.fallback.act(observation)
|
|
492
|
+
return action
|
|
493
|
+
except (urllib.error.URLError, TimeoutError, KeyError, ValueError, json.JSONDecodeError):
|
|
494
|
+
return self.fallback.act(observation)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def parse_action(raw: str) -> Action | None:
|
|
498
|
+
parts = raw.split()
|
|
499
|
+
aliases = {"f": "fold", "x": "check", "c": "call", "r": "raise"}
|
|
500
|
+
if not parts:
|
|
501
|
+
return None
|
|
502
|
+
kind_text = aliases.get(parts[0], parts[0])
|
|
503
|
+
try:
|
|
504
|
+
kind = ActionKind(kind_text)
|
|
505
|
+
except ValueError:
|
|
506
|
+
return None
|
|
507
|
+
if kind == ActionKind.RAISE:
|
|
508
|
+
if len(parts) != 2 or not parts[1].isdigit():
|
|
509
|
+
return None
|
|
510
|
+
return Action(kind, int(parts[1]))
|
|
511
|
+
return Action(kind)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def parse_llm_action(content: str) -> Action | None:
|
|
515
|
+
match = re.search(r"\{.*?\}", content, re.DOTALL)
|
|
516
|
+
if not match:
|
|
517
|
+
return None
|
|
518
|
+
try:
|
|
519
|
+
value = json.loads(match.group(0))
|
|
520
|
+
kind = ActionKind(str(value["action"]).lower())
|
|
521
|
+
amount = int(value.get("amount", 0))
|
|
522
|
+
return Action(kind, amount)
|
|
523
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def is_legal(action: Action, observation: Observation) -> bool:
|
|
528
|
+
legal = observation.legal_actions
|
|
529
|
+
if action.kind == ActionKind.FOLD:
|
|
530
|
+
return legal.can_fold
|
|
531
|
+
if action.kind == ActionKind.CHECK:
|
|
532
|
+
return legal.can_check
|
|
533
|
+
if action.kind == ActionKind.CALL:
|
|
534
|
+
return legal.call_amount > 0
|
|
535
|
+
if action.kind == ActionKind.RAISE:
|
|
536
|
+
if legal.min_raise_to is None or legal.max_raise_to is None:
|
|
537
|
+
return False
|
|
538
|
+
return legal.min_raise_to <= action.amount <= legal.max_raise_to
|
|
539
|
+
return False
|