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.
@@ -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