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/cli.py ADDED
@@ -0,0 +1,1250 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import random
7
+ import shutil
8
+ import sys
9
+ import termios
10
+ import tty
11
+ import uuid
12
+
13
+ from rich import box
14
+ from rich.align import Align
15
+ from rich.console import Console, Group
16
+ from rich.live import Live
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from .coach import CoachError, LocalCLICoach
21
+ from .controllers import (
22
+ BOT_PROFILES,
23
+ HumanController,
24
+ LLMController,
25
+ LocalAgentCLIController,
26
+ OnlineDecisionBudget,
27
+ OfflineBot,
28
+ )
29
+ from .engine import HeadsUpGame
30
+ from .models import HandResult
31
+ from .history import (
32
+ HandHistoryStore,
33
+ HistoryError,
34
+ build_hand_record,
35
+ default_history_path,
36
+ format_history_summary,
37
+ )
38
+ from .ranges import RangeLibrary, RangePackError
39
+ from .review import format_review, format_tournament_review
40
+ from .savegame import (
41
+ SaveGameError,
42
+ load_tournament_save,
43
+ remove_save,
44
+ restore_tournament,
45
+ save_tournament,
46
+ )
47
+ from .settings import (
48
+ SettingsError,
49
+ apply_settings,
50
+ load_settings,
51
+ save_settings,
52
+ settings_from_namespace,
53
+ )
54
+ from .tournament import TournamentGame
55
+ from .usage import ModelUsageTracker
56
+
57
+
58
+ BLIND_SPEEDS = {
59
+ "turbo": 2,
60
+ "fast": 4,
61
+ "normal": 8,
62
+ "slow": 12,
63
+ }
64
+
65
+ CODEX_MODELS = (
66
+ "gpt-5.4-mini",
67
+ "gpt-5.4",
68
+ "gpt-5.6-terra",
69
+ "gpt-5.6-sol",
70
+ )
71
+ CLAUDE_MODELS = ("haiku", "sonnet", "opus")
72
+ MIXED_MODEL_PRESETS = (
73
+ ("gpt-5.4-mini", "haiku"),
74
+ ("gpt-5.4", "sonnet"),
75
+ ("gpt-5.6-sol", "opus"),
76
+ )
77
+
78
+
79
+ def build_parser() -> argparse.ArgumentParser:
80
+ parser = argparse.ArgumentParser(
81
+ description="在终端里玩带 AI 对手和赛后复盘的德州锦标赛",
82
+ epilog=(
83
+ "继续存档:poker resume|长期记录:poker history|"
84
+ "模型额度:poker usage"
85
+ ),
86
+ )
87
+ parser.add_argument(
88
+ "--opponent",
89
+ choices=(
90
+ "agent",
91
+ "codex",
92
+ "claude",
93
+ "local",
94
+ "mixed",
95
+ *BOT_PROFILES.keys(),
96
+ "llm",
97
+ ),
98
+ default="agent",
99
+ help="默认通过本地 Codex/Claude Code 登录态驱动对手;mixed 为纯离线",
100
+ )
101
+ parser.add_argument(
102
+ "--mode",
103
+ choices=("tournament", "heads-up"),
104
+ default="tournament",
105
+ help="默认是 6-max 单桌锦标赛",
106
+ )
107
+ parser.add_argument("--players", type=int, choices=range(2, 7), default=6, help="锦标赛人数")
108
+ parser.add_argument("--plain", action="store_true", help="关闭全屏牌桌,使用纯文本模式")
109
+ parser.add_argument(
110
+ "--speed",
111
+ choices=("fast", "normal", "slow"),
112
+ default="normal",
113
+ help="TUI 动画速度",
114
+ )
115
+ parser.add_argument(
116
+ "--size",
117
+ choices=("mini", "compact", "auto", "large"),
118
+ default="mini",
119
+ help="牌桌尺寸;默认 mini 使用最小布局",
120
+ )
121
+ parser.add_argument(
122
+ "--theme",
123
+ choices=("mono", "classic", "neon"),
124
+ default="mono",
125
+ help="牌桌皮肤;默认 mono 仅为红桃和方片保留颜色",
126
+ )
127
+ parser.add_argument(
128
+ "--coach",
129
+ choices=("none", "auto", "codex", "claude"),
130
+ default="none",
131
+ help="大模型教练;通过本地 CLI 复用登录态,调用频率由 --review 控制",
132
+ )
133
+ parser.add_argument(
134
+ "--codex-model",
135
+ default=os.getenv("POKER_CODEX_MODEL", "gpt-5.4-mini"),
136
+ help="Codex 模型;默认使用响应较快的 gpt-5.4-mini",
137
+ )
138
+ parser.add_argument(
139
+ "--claude-model",
140
+ default=os.getenv("POKER_CLAUDE_MODEL", "haiku"),
141
+ help="Claude Code 模型;默认 haiku,省额度,可改为 sonnet 等别名",
142
+ )
143
+ parser.add_argument(
144
+ "--review",
145
+ choices=("tournament", "hand", "none"),
146
+ default="tournament",
147
+ help="复盘频率;默认锦标赛结束后整体复盘,hand 为每手细节模式",
148
+ )
149
+ parser.add_argument("--list-opponents", action="store_true", help="列出 AI 与离线对手选项后退出")
150
+ parser.add_argument("--stack", type=int, default=1500, help="每位玩家起始筹码")
151
+ parser.add_argument("--small-blind", type=int, default=5)
152
+ parser.add_argument("--big-blind", type=int, default=10)
153
+ parser.add_argument("--hands", type=int, default=0, help="最多手数,0 表示持续游戏")
154
+ parser.add_argument(
155
+ "--blind-speed",
156
+ choices=tuple(BLIND_SPEEDS),
157
+ default="normal",
158
+ help="升盲速度:turbo=2、fast=4、normal=8、slow=12 手一级",
159
+ )
160
+ parser.add_argument(
161
+ "--hands-per-level",
162
+ type=int,
163
+ default=None,
164
+ help="自定义每个盲注级别持续手数,优先于 --blind-speed",
165
+ )
166
+ parser.add_argument("--no-save", action="store_true", help="关闭每手结束后的自动存档")
167
+ parser.add_argument(
168
+ "--no-history",
169
+ action="store_true",
170
+ help="不把已完成手牌追加到长期记录",
171
+ )
172
+ parser.add_argument(
173
+ "--online-policy",
174
+ choices=("key", "all"),
175
+ default="all",
176
+ help="key 仅在关键局面调用模型;all 每个 AI 行动都调用",
177
+ )
178
+ parser.add_argument("--seed", type=int, default=None, help="固定随机种子,便于测试")
179
+ return parser
180
+
181
+
182
+ def _menu_choice(prompt: str, options: dict[str, str], default: str) -> str:
183
+ for key, label in options.items():
184
+ suffix = "(默认)" if key == default else ""
185
+ print(f" {key}. {label}{suffix}")
186
+ while True:
187
+ try:
188
+ selected = input(f"{prompt} [{default}] > ").strip() or default
189
+ except EOFError:
190
+ return default
191
+ if selected in options:
192
+ return selected
193
+ print("请输入菜单前面的数字。")
194
+
195
+
196
+ def _positive_number(prompt: str, default: int) -> int:
197
+ while True:
198
+ try:
199
+ raw = input(f"{prompt} [{default}] > ").strip()
200
+ except EOFError:
201
+ return default
202
+ if not raw:
203
+ return default
204
+ if raw.isdigit() and int(raw) > 0:
205
+ return int(raw)
206
+ print("请输入正整数。")
207
+
208
+
209
+ def configure_quick_start(args: argparse.Namespace) -> argparse.Namespace:
210
+ if args.review != "none" and args.coach == "none":
211
+ args.coach = "auto"
212
+ if sys.stdin.isatty() and sys.stdout.isatty():
213
+ return _configure_keyboard_menu(args)
214
+ while True:
215
+ _print_setup_page(args)
216
+ try:
217
+ selected = input("修改编号;直接回车开桌;Q 退出 > ").strip().upper()
218
+ except EOFError:
219
+ selected = ""
220
+ if not selected:
221
+ _finalize_setup(args)
222
+ print("\n设置完成,正在开桌…")
223
+ return args
224
+ if selected == "Q":
225
+ args.setup_cancelled = True
226
+ return args
227
+ if selected not in {
228
+ *map(str, range(1, 10)),
229
+ "A",
230
+ "B",
231
+ "C",
232
+ "D",
233
+ "E",
234
+ "F",
235
+ "G",
236
+ }:
237
+ print("请输入页面左侧的设置编号。")
238
+ continue
239
+ _edit_setup_option(args, selected)
240
+
241
+
242
+ def _finalize_setup(args: argparse.Namespace) -> None:
243
+ if args.mode == "heads-up":
244
+ args.players = 2
245
+ if args.review == "none":
246
+ args.coach = "none"
247
+
248
+
249
+ def _model_for_provider(args: argparse.Namespace, provider: str) -> str:
250
+ return args.codex_model if provider == "codex" else args.claude_model
251
+
252
+
253
+ def _model_setup_row(args: argparse.Namespace) -> tuple[str, str, str]:
254
+ if args.opponent == "codex":
255
+ return ("Codex模型", args.codex_model, "Mini / 5.4 / Terra / Sol")
256
+ if args.opponent == "claude":
257
+ return ("Claude模型", args.claude_model, "Haiku / Sonnet / Opus")
258
+ if args.opponent == "agent":
259
+ return (
260
+ "模型组合",
261
+ f"{args.codex_model} + {args.claude_model}",
262
+ "轻量 / 标准 / 强力",
263
+ )
264
+ return ("模型", "不使用", "离线对手不调用模型")
265
+
266
+
267
+ def _setup_rows(args: argparse.Namespace) -> tuple[tuple[str, str, str, str], ...]:
268
+ opponent = {
269
+ "agent": "Codex + Claude",
270
+ "codex": "Codex",
271
+ "claude": "Claude",
272
+ "mixed": "离线混合",
273
+ }.get(args.opponent, args.opponent)
274
+ review = {
275
+ "tournament": "整届结束",
276
+ "hand": "每手",
277
+ "none": "关闭",
278
+ }[args.review]
279
+ coach = {
280
+ "auto": "自动",
281
+ "codex": "Codex",
282
+ "claude": "Claude",
283
+ "none": "本地",
284
+ }[args.coach]
285
+ level_hands = (
286
+ args.hands_per_level
287
+ if args.hands_per_level is not None
288
+ else BLIND_SPEEDS[args.blind_speed]
289
+ )
290
+ model_label, model_value, model_choices = _model_setup_row(args)
291
+ return (
292
+ ("1", "玩法", "锦标赛" if args.mode == "tournament" else "单挑", "锦标赛 / 单挑"),
293
+ ("2", "人数", str(args.players if args.mode == "tournament" else 2), "6 / 5 / 4 / 3 / 2"),
294
+ ("3", "对手", opponent, "混合在线 / Codex / Claude / 离线"),
295
+ ("4", model_label, model_value, model_choices),
296
+ ("5", "复盘", review, "整届 / 每手 / 关闭"),
297
+ ("6", "教练", coach, "自动 / Codex / Claude / 本地"),
298
+ ("7", "起始筹码", str(args.stack), "1500 / 3000 / 1000 / 自定义"),
299
+ ("8", "初始盲注", f"{args.small_blind}/{args.big_blind}", "5/10 / 10/20 / 15/30 / 自定义"),
300
+ ("9", "升盲", f"每 {level_hands} 手", "8 / 4 / 2 / 12 / 自定义"),
301
+ ("A", "显示", "纯文本" if args.plain else "TUI", "TUI / 纯文本"),
302
+ ("B", "牌桌大小", args.size, "Mini / Compact / Auto / Large"),
303
+ ("C", "皮肤", args.theme, "Mono / Classic / Neon"),
304
+ ("D", "动画", args.speed, "正常 / 快速 / 慢速"),
305
+ ("E", "自动存档", "关闭" if args.no_save else "开启", "开启 / 关闭"),
306
+ ("F", "长期记录", "关闭" if args.no_history else "开启", "开启 / 关闭"),
307
+ (
308
+ "G",
309
+ "AI 调用",
310
+ "关键局面" if args.online_policy == "key" else "每次行动",
311
+ "关键局面 / 每次行动",
312
+ ),
313
+ )
314
+
315
+
316
+ def _setup_table(args: argparse.Namespace, selected: int | None = None) -> Table:
317
+ rows = _setup_rows(args)
318
+ table = Table(
319
+ title="TERMINAL HOLD'EM · 开局设置",
320
+ box=box.ROUNDED,
321
+ header_style="bold",
322
+ border_style="grey58",
323
+ padding=(0, 1),
324
+ )
325
+ table.add_column("编号", justify="center", no_wrap=True)
326
+ table.add_column("设置", no_wrap=True)
327
+ table.add_column("当前值", no_wrap=True)
328
+ table.add_column("可选值")
329
+ for index, (key, label, current, choices) in enumerate(rows):
330
+ table.add_row(
331
+ key,
332
+ label,
333
+ f"‹ {current} ›" if index == selected else current,
334
+ choices,
335
+ style="reverse bold" if index == selected else None,
336
+ )
337
+ return table
338
+
339
+
340
+ def _print_setup_page(args: argparse.Namespace) -> None:
341
+ table = _setup_table(args)
342
+ Console().print(table)
343
+
344
+
345
+ def _setup_menu_view(args: argparse.Namespace, selected: int) -> Group:
346
+ buttons = Text(justify="center")
347
+ buttons.append(" ENTER 开始游戏 ", style="reverse bold")
348
+ buttons.append(" ")
349
+ buttons.append(" ESC / Q 返回退出 ", style="bold")
350
+ help_text = Text(
351
+ "↑↓ / Tab 选择 ←→ 切换选项",
352
+ justify="center",
353
+ style="dim",
354
+ )
355
+ return Group(
356
+ Align.center(_setup_table(args, selected)),
357
+ Text(""),
358
+ Align.center(buttons),
359
+ Align.center(help_text),
360
+ )
361
+
362
+
363
+ def _configure_keyboard_menu(args: argparse.Namespace) -> argparse.Namespace:
364
+ from .tui import read_navigation_key
365
+
366
+ console = Console()
367
+ descriptor = sys.stdin.fileno()
368
+ original = termios.tcgetattr(descriptor)
369
+ selected = 0
370
+ start_game = False
371
+ live = Live(
372
+ _setup_menu_view(args, selected),
373
+ console=console,
374
+ screen=True,
375
+ auto_refresh=False,
376
+ )
377
+ try:
378
+ tty.setcbreak(descriptor)
379
+ live.start(refresh=True)
380
+ while True:
381
+ key = read_navigation_key(descriptor)
382
+ if key in {"down", "\t"}:
383
+ selected = (selected + 1) % len(_setup_rows(args))
384
+ elif key in {"up", "shift_tab"}:
385
+ selected = (selected - 1) % len(_setup_rows(args))
386
+ elif key in {"right", " "}:
387
+ _cycle_setup_option(args, _setup_rows(args)[selected][0], 1)
388
+ elif key == "left":
389
+ _cycle_setup_option(args, _setup_rows(args)[selected][0], -1)
390
+ elif key in {"\r", "\n"}:
391
+ start_game = True
392
+ break
393
+ elif key in {"q", "\x1b"}:
394
+ break
395
+ elif key == "\x03":
396
+ raise KeyboardInterrupt
397
+ elif key == "\x04":
398
+ raise EOFError
399
+ live.update(_setup_menu_view(args, selected), refresh=True)
400
+ finally:
401
+ termios.tcsetattr(descriptor, termios.TCSADRAIN, original)
402
+ live.stop()
403
+ if start_game:
404
+ _finalize_setup(args)
405
+ print("设置完成,正在开桌…")
406
+ else:
407
+ args.setup_cancelled = True
408
+ return args
409
+
410
+
411
+ def _cycle_value(current: object, options: tuple, direction: int) -> object:
412
+ try:
413
+ index = options.index(current)
414
+ except ValueError:
415
+ index = 0 if direction < 0 else -1
416
+ return options[(index + direction) % len(options)]
417
+
418
+
419
+ def _cycle_setup_option(args: argparse.Namespace, selected: str, direction: int) -> None:
420
+ if selected == "1":
421
+ args.mode = _cycle_value(
422
+ args.mode, ("tournament", "heads-up"), direction
423
+ )
424
+ if args.mode == "heads-up":
425
+ args.players = 2
426
+ elif selected == "2":
427
+ args.players = _cycle_value(args.players, (6, 5, 4, 3, 2), direction)
428
+ elif selected == "3":
429
+ args.opponent = _cycle_value(
430
+ args.opponent, ("agent", "codex", "claude", "mixed"), direction
431
+ )
432
+ elif selected == "4":
433
+ if args.opponent == "codex":
434
+ args.codex_model = _cycle_value(
435
+ args.codex_model, CODEX_MODELS, direction
436
+ )
437
+ elif args.opponent == "claude":
438
+ args.claude_model = _cycle_value(
439
+ args.claude_model, CLAUDE_MODELS, direction
440
+ )
441
+ elif args.opponent == "agent":
442
+ args.codex_model, args.claude_model = _cycle_value(
443
+ (args.codex_model, args.claude_model),
444
+ MIXED_MODEL_PRESETS,
445
+ direction,
446
+ )
447
+ elif selected == "5":
448
+ args.review = _cycle_value(
449
+ args.review, ("tournament", "hand", "none"), direction
450
+ )
451
+ elif selected == "6":
452
+ args.coach = _cycle_value(
453
+ args.coach, ("auto", "codex", "claude", "none"), direction
454
+ )
455
+ elif selected == "7":
456
+ args.stack = _cycle_value(
457
+ args.stack, (1000, 1500, 2000, 3000, 5000), direction
458
+ )
459
+ elif selected == "8":
460
+ blinds = _cycle_value(
461
+ (args.small_blind, args.big_blind),
462
+ ((5, 10), (10, 20), (15, 30), (25, 50)),
463
+ direction,
464
+ )
465
+ args.small_blind, args.big_blind = blinds
466
+ elif selected == "9":
467
+ args.hands_per_level = _cycle_value(
468
+ (
469
+ args.hands_per_level
470
+ if args.hands_per_level is not None
471
+ else BLIND_SPEEDS[args.blind_speed]
472
+ ),
473
+ (2, 4, 6, 8, 12),
474
+ direction,
475
+ )
476
+ elif selected == "A":
477
+ args.plain = not args.plain
478
+ elif selected == "B":
479
+ args.size = _cycle_value(
480
+ args.size, ("mini", "compact", "auto", "large"), direction
481
+ )
482
+ elif selected == "C":
483
+ args.theme = _cycle_value(
484
+ args.theme, ("mono", "classic", "neon"), direction
485
+ )
486
+ elif selected == "D":
487
+ args.speed = _cycle_value(
488
+ args.speed, ("normal", "fast", "slow"), direction
489
+ )
490
+ elif selected == "E":
491
+ args.no_save = not args.no_save
492
+ elif selected == "F":
493
+ args.no_history = not args.no_history
494
+ elif selected == "G":
495
+ args.online_policy = _cycle_value(
496
+ args.online_policy, ("key", "all"), direction
497
+ )
498
+
499
+
500
+ def _edit_setup_option(args: argparse.Namespace, selected: str) -> None:
501
+ if selected == "1":
502
+ choice = _menu_choice("玩法", {"1": "锦标赛", "2": "单挑"}, "1")
503
+ args.mode = {"1": "tournament", "2": "heads-up"}[choice]
504
+ if args.mode == "heads-up":
505
+ args.players = 2
506
+ elif selected == "2":
507
+ choice = _menu_choice(
508
+ "人数",
509
+ {"1": "6 人", "2": "5 人", "3": "4 人", "4": "3 人", "5": "2 人"},
510
+ "1",
511
+ )
512
+ args.players = {"1": 6, "2": 5, "3": 4, "4": 3, "5": 2}[choice]
513
+ elif selected == "3":
514
+ choice = _menu_choice(
515
+ "对手",
516
+ {
517
+ "1": "Codex / Claude Code 混合桌",
518
+ "2": "全部 Codex",
519
+ "3": "全部 Claude Code",
520
+ "4": "纯离线混合桌",
521
+ },
522
+ "1",
523
+ )
524
+ args.opponent = {"1": "agent", "2": "codex", "3": "claude", "4": "mixed"}[
525
+ choice
526
+ ]
527
+ elif selected == "4":
528
+ if args.opponent == "codex":
529
+ choice = _menu_choice(
530
+ "Codex 模型",
531
+ {
532
+ "1": "gpt-5.4-mini:响应快",
533
+ "2": "gpt-5.4:标准",
534
+ "3": "gpt-5.6-terra:更强",
535
+ "4": "gpt-5.6-sol:最强",
536
+ },
537
+ "1",
538
+ )
539
+ args.codex_model = CODEX_MODELS[int(choice) - 1]
540
+ elif args.opponent == "claude":
541
+ choice = _menu_choice(
542
+ "Claude Code 模型",
543
+ {
544
+ "1": "Haiku:响应快",
545
+ "2": "Sonnet:标准",
546
+ "3": "Opus:最强",
547
+ },
548
+ "1",
549
+ )
550
+ args.claude_model = CLAUDE_MODELS[int(choice) - 1]
551
+ elif args.opponent == "agent":
552
+ choice = _menu_choice(
553
+ "混合桌模型",
554
+ {
555
+ "1": "轻量:Codex mini + Claude Haiku",
556
+ "2": "标准:Codex 5.4 + Claude Sonnet",
557
+ "3": "强力:Codex Sol + Claude Opus",
558
+ },
559
+ "1",
560
+ )
561
+ args.codex_model, args.claude_model = MIXED_MODEL_PRESETS[
562
+ int(choice) - 1
563
+ ]
564
+ elif selected == "5":
565
+ choice = _menu_choice(
566
+ "复盘频率",
567
+ {"1": "整届结束后", "2": "每手细节", "3": "关闭"},
568
+ "1",
569
+ )
570
+ args.review = {"1": "tournament", "2": "hand", "3": "none"}[choice]
571
+ elif selected == "6":
572
+ choice = _menu_choice(
573
+ "复盘教练",
574
+ {"1": "自动", "2": "Codex", "3": "Claude Code", "4": "仅本地简评"},
575
+ "1",
576
+ )
577
+ args.coach = {"1": "auto", "2": "codex", "3": "claude", "4": "none"}[choice]
578
+ elif selected == "7":
579
+ choice = _menu_choice(
580
+ "起始筹码",
581
+ {"1": "1500", "2": "3000", "3": "1000", "4": "自定义"},
582
+ "1",
583
+ )
584
+ args.stack = (
585
+ _positive_number("输入每位玩家的起始筹码", args.stack)
586
+ if choice == "4"
587
+ else {"1": 1500, "2": 3000, "3": 1000}[choice]
588
+ )
589
+ elif selected == "8":
590
+ choice = _menu_choice(
591
+ "初始盲注",
592
+ {"1": "5 / 10", "2": "10 / 20", "3": "15 / 30", "4": "自定义"},
593
+ "1",
594
+ )
595
+ if choice == "4":
596
+ args.small_blind = _positive_number("输入小盲", args.small_blind)
597
+ args.big_blind = _positive_number("输入大盲", args.big_blind)
598
+ while args.big_blind <= args.small_blind:
599
+ print("大盲必须大于小盲。")
600
+ args.big_blind = _positive_number("重新输入大盲", args.small_blind * 2)
601
+ else:
602
+ args.small_blind, args.big_blind = {
603
+ "1": (5, 10),
604
+ "2": (10, 20),
605
+ "3": (15, 30),
606
+ }[choice]
607
+ elif selected == "9":
608
+ choice = _menu_choice(
609
+ "升盲速度",
610
+ {
611
+ "1": "正常:每 8 手",
612
+ "2": "快速:每 4 手",
613
+ "3": "极速:每 2 手",
614
+ "4": "慢速:每 12 手",
615
+ "5": "自定义",
616
+ },
617
+ "1",
618
+ )
619
+ if choice == "5":
620
+ args.hands_per_level = _positive_number("每个级别持续几手", 8)
621
+ else:
622
+ args.blind_speed = {
623
+ "1": "normal",
624
+ "2": "fast",
625
+ "3": "turbo",
626
+ "4": "slow",
627
+ }[choice]
628
+ args.hands_per_level = None
629
+ elif selected == "A":
630
+ args.plain = _menu_choice(
631
+ "显示方式", {"1": "TUI 可视化牌桌", "2": "纯文本"}, "1"
632
+ ) == "2"
633
+ elif selected == "B":
634
+ choice = _menu_choice(
635
+ "牌桌大小",
636
+ {"1": "Mini", "2": "Compact", "3": "Auto", "4": "Large"},
637
+ "1",
638
+ )
639
+ args.size = {"1": "mini", "2": "compact", "3": "auto", "4": "large"}[choice]
640
+ elif selected == "C":
641
+ choice = _menu_choice(
642
+ "皮肤", {"1": "Mono", "2": "Classic", "3": "Neon"}, "1"
643
+ )
644
+ args.theme = {"1": "mono", "2": "classic", "3": "neon"}[choice]
645
+ elif selected == "D":
646
+ choice = _menu_choice(
647
+ "动画速度", {"1": "正常", "2": "快速", "3": "慢速"}, "1"
648
+ )
649
+ args.speed = {"1": "normal", "2": "fast", "3": "slow"}[choice]
650
+ elif selected == "E":
651
+ args.no_save = _menu_choice(
652
+ "自动存档", {"1": "开启", "2": "关闭"}, "1"
653
+ ) == "2"
654
+ elif selected == "F":
655
+ args.no_history = _menu_choice(
656
+ "长期牌局记录", {"1": "开启", "2": "关闭"}, "1"
657
+ ) == "2"
658
+ elif selected == "G":
659
+ choice = _menu_choice(
660
+ "AI 调用频率",
661
+ {"1": "每次行动", "2": "仅关键局面(每手最多 3 次)"},
662
+ "1",
663
+ )
664
+ args.online_policy = {"1": "all", "2": "key"}[choice]
665
+
666
+
667
+ def main(argv: list[str] | None = None) -> int:
668
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
669
+ if raw_argv == ["usage"]:
670
+ tracker = ModelUsageTracker()
671
+ tracker.refresh_codex_quota()
672
+ print(tracker.detailed_summary(include_empty=False))
673
+ return 0
674
+ if raw_argv and raw_argv[0] == "ranges":
675
+ return ranges_command(raw_argv[1:])
676
+ if raw_argv and raw_argv[0] == "history":
677
+ return history_command(raw_argv[1:])
678
+ resume_payload = None
679
+ if raw_argv and raw_argv[0] == "resume":
680
+ if len(raw_argv) != 1:
681
+ print("继续游戏只需运行:poker resume", file=sys.stderr)
682
+ return 2
683
+ try:
684
+ resume_payload = load_tournament_save()
685
+ except SaveGameError as error:
686
+ print(error, file=sys.stderr)
687
+ return 2
688
+ args = build_parser().parse_args([])
689
+ for key, value in resume_payload["config"].items():
690
+ if hasattr(args, key):
691
+ setattr(args, key, value)
692
+ else:
693
+ args = build_parser().parse_args(raw_argv)
694
+ if not raw_argv:
695
+ apply_settings(args, load_settings())
696
+ args = configure_quick_start(args)
697
+ if getattr(args, "setup_cancelled", False):
698
+ return 0
699
+ try:
700
+ save_settings(settings_from_namespace(args))
701
+ except SettingsError as error:
702
+ print(f"开局设置未保存:{error}", file=sys.stderr)
703
+ if args.hands_per_level is not None and args.hands_per_level < 1:
704
+ print("--hands-per-level 必须至少为 1", file=sys.stderr)
705
+ return 2
706
+ if args.list_opponents:
707
+ print("可用对手:")
708
+ print(" agent 本地 Codex / Claude Code 混合桌(复用登录态)")
709
+ print(" codex 全部使用本地 Codex")
710
+ print(" claude 全部使用本地 Claude Code")
711
+ print(" mixed 混合桌|五名不同风格的离线对手")
712
+ for profile in BOT_PROFILES.values():
713
+ print(f" {profile.key:10} {profile.name}|{profile.description}")
714
+ return 0
715
+ try:
716
+ usage_tracker = ModelUsageTracker()
717
+ coach = (
718
+ None
719
+ if args.coach == "none"
720
+ else LocalCLICoach(
721
+ args.coach,
722
+ codex_model=args.codex_model,
723
+ claude_model=args.claude_model,
724
+ usage_tracker=usage_tracker,
725
+ )
726
+ )
727
+ if args.mode == "tournament":
728
+ return run_tournament(
729
+ args,
730
+ coach,
731
+ resume_payload,
732
+ usage_tracker=usage_tracker,
733
+ )
734
+ if args.opponent in {"agent", "codex", "claude"}:
735
+ provider = _local_agent_providers(args.opponent)[0]
736
+ online_budget = (
737
+ OnlineDecisionBudget()
738
+ if args.online_policy == "key"
739
+ else None
740
+ )
741
+ opponent = LocalAgentCLIController(
742
+ provider,
743
+ "Mason",
744
+ thinking_sink=_plain_thinking,
745
+ notice_sink=print,
746
+ online_decision=(
747
+ online_budget.claim if online_budget is not None else None
748
+ ),
749
+ model=_model_for_provider(args, provider),
750
+ usage_tracker=usage_tracker,
751
+ )
752
+ opponent_name = "Mason"
753
+ elif args.opponent == "llm":
754
+ opponent = LLMController()
755
+ opponent_name = "大模型对手"
756
+ else:
757
+ profile_key = "balanced" if args.opponent in {"local", "mixed"} else args.opponent
758
+ profile = BOT_PROFILES[profile_key]
759
+ bot_seed = None if args.seed is None else args.seed + 1
760
+ opponent = OfflineBot(profile_key, rng=random.Random(bot_seed))
761
+ opponent_name = profile.name
762
+ game = HeadsUpGame(
763
+ HumanController(),
764
+ opponent,
765
+ starting_stack=args.stack,
766
+ small_blind=args.small_blind,
767
+ big_blind=args.big_blind,
768
+ opponent_name=opponent_name,
769
+ seed=args.seed,
770
+ )
771
+ except (ValueError, CoachError) as error:
772
+ print(f"配置错误:{error}", file=sys.stderr)
773
+ return 2
774
+
775
+ print("终端德州(Heads-up No-Limit Hold'em)")
776
+ if args.opponent in {"agent", "codex", "claude"}:
777
+ print("AI 对手:复用本地 Codex / Claude Code 登录态")
778
+ elif args.opponent == "llm":
779
+ print(f"LLM 对手:{os.environ.get('POKER_LLM_MODEL')}(请求仅包含其手牌与公开信息)")
780
+ else:
781
+ print(f"离线对手:{opponent_name}(对局过程不访问网络)")
782
+ if coach is not None:
783
+ print(f"赛后教练:{coach.provider}(通过本地 CLI 登录态,仅在每手结束后调用)")
784
+ completed = 0
785
+ history_store = None if args.no_history else HandHistoryStore()
786
+ history_session_id = uuid.uuid4().hex
787
+ try:
788
+ while all(player.stack >= args.big_blind for player in game.players):
789
+ result = game.play_hand()
790
+ _record_hand(
791
+ history_store,
792
+ game,
793
+ result,
794
+ session_id=history_session_id,
795
+ mode="heads-up",
796
+ )
797
+ print(format_review(result))
798
+ if coach is not None:
799
+ print(f"\n── {coach.provider} 深度复盘 ──")
800
+ try:
801
+ print(coach.review(result))
802
+ except CoachError as error:
803
+ print(f"本手大模型复盘不可用:{error}")
804
+ print(f"\n筹码|你 {game.players[0].stack}|对手 {game.players[1].stack}")
805
+ completed += 1
806
+ if args.hands and completed >= args.hands:
807
+ break
808
+ if not args.hands and not _continue_next_hand():
809
+ break
810
+ except (EOFError, KeyboardInterrupt):
811
+ print("\n游戏已结束。")
812
+ print(f"\n── 模型用量 ──\n{usage_tracker.detailed_summary()}")
813
+ return 0
814
+
815
+
816
+ def ranges_command(argv: list[str]) -> int:
817
+ parser = argparse.ArgumentParser(
818
+ prog="poker ranges",
819
+ description="管理带明确来源的翻前范围数据",
820
+ )
821
+ subparsers = parser.add_subparsers(dest="command", required=True)
822
+ import_parser = subparsers.add_parser("import", help="校验并导入 JSON 范围包")
823
+ import_parser.add_argument("file")
824
+ subparsers.add_parser("list", help="列出已经导入的来源和局面")
825
+ subparsers.add_parser("path", help="显示本机范围目录")
826
+ subparsers.add_parser("example", help="显示可导入 JSON 格式")
827
+ args = parser.parse_args(argv)
828
+ library = RangeLibrary()
829
+
830
+ if args.command == "path":
831
+ print(library.directory)
832
+ return 0
833
+ if args.command == "example":
834
+ print(
835
+ json.dumps(
836
+ {
837
+ "schema_version": 1,
838
+ "source": {
839
+ "name": "Your FreeBetRange or solver export",
840
+ "url": "https://help.freebetrange.com/Export/",
841
+ "note": "User-owned export; do not label guessed data as solver output.",
842
+ },
843
+ "spots": [
844
+ {
845
+ "id": "6max-75bb-sb-vs-btn-open",
846
+ "format": "MTT ChipEV",
847
+ "players": 6,
848
+ "stack_bb": 75,
849
+ "ante_bb": 0,
850
+ "position": "SB",
851
+ "scenario": "VS_OPEN",
852
+ "opener": "BTN",
853
+ "open_size_bb": 2,
854
+ "actions": {
855
+ "raise": "AA,KK,AKs:0.5",
856
+ "call": "AKs:0.5,QQ",
857
+ },
858
+ }
859
+ ],
860
+ },
861
+ ensure_ascii=False,
862
+ indent=2,
863
+ )
864
+ )
865
+ return 0
866
+ if args.command == "list":
867
+ spots = library.spots()
868
+ if not spots:
869
+ print(f"尚未导入范围。目录:{library.directory}")
870
+ return 0
871
+ for spot in spots:
872
+ opener = f" {spot.opener}" if spot.opener else ""
873
+ print(
874
+ f"{spot.spot_id}|{spot.source.name}|{spot.players}-max|"
875
+ f"{spot.position}|{spot.stack_bb:g}BB|{spot.scenario}{opener}|"
876
+ f"ante {spot.ante_bb:g}BB"
877
+ )
878
+ return 0
879
+ try:
880
+ target, count = library.import_pack(args.file)
881
+ except RangePackError as error:
882
+ print(f"范围包无效:{error}", file=sys.stderr)
883
+ return 2
884
+ print(f"已校验并导入 {count} 个带来源局面:{target}")
885
+ return 0
886
+
887
+
888
+ def history_command(argv: list[str]) -> int:
889
+ parser = argparse.ArgumentParser(
890
+ prog="poker history",
891
+ description="查看跨锦标赛保存的长期牌局记录",
892
+ )
893
+ parser.add_argument(
894
+ "command",
895
+ nargs="?",
896
+ choices=("list", "stats", "path"),
897
+ default="list",
898
+ )
899
+ parser.add_argument("--limit", type=int, default=10)
900
+ args = parser.parse_args(argv)
901
+ store = HandHistoryStore()
902
+ if args.command == "path":
903
+ print(default_history_path())
904
+ return 0
905
+ if args.limit < 1:
906
+ print("--limit 必须至少为 1", file=sys.stderr)
907
+ return 2
908
+ try:
909
+ records = store.records()
910
+ except HistoryError as error:
911
+ print(error, file=sys.stderr)
912
+ return 2
913
+ print(format_history_summary(records, limit=args.limit))
914
+ print(f"\n原始记录:{store.path}")
915
+ return 0
916
+
917
+
918
+ def run_tournament(
919
+ args: argparse.Namespace,
920
+ coach: LocalCLICoach | None,
921
+ resume_payload: dict | None = None,
922
+ *,
923
+ usage_tracker: ModelUsageTracker | None = None,
924
+ ) -> int:
925
+ usage_tracker = usage_tracker or ModelUsageTracker()
926
+ history_store = None if args.no_history else HandHistoryStore()
927
+ history_session_id = (
928
+ str(resume_payload["config"].get("tournament_id"))
929
+ if resume_payload is not None
930
+ and resume_payload["config"].get("tournament_id")
931
+ else uuid.uuid4().hex
932
+ )
933
+ use_tui = not args.plain and sys.stdin.isatty() and sys.stdout.isatty()
934
+ table = None
935
+ if use_tui:
936
+ try:
937
+ from .tui import RichHumanController, TournamentTUI
938
+ except ImportError:
939
+ print("未安装 Rich,已回退到纯文本模式。运行 pip install -e . 可启用牌桌。")
940
+ use_tui = False
941
+ if use_tui:
942
+ table = TournamentTUI(
943
+ animation_speed=args.speed,
944
+ theme=args.theme,
945
+ size=args.size,
946
+ usage_tracker=usage_tracker,
947
+ )
948
+ hero_controller = RichHumanController(table)
949
+ event_sink = table.log
950
+ state_sink = table.render
951
+ thinking_sink = table.set_thinking
952
+ else:
953
+ hero_controller = HumanController()
954
+ event_sink = print
955
+ state_sink = None
956
+ thinking_sink = _plain_thinking
957
+
958
+ bot_names = ("Mason", "Olivia", "Liam", "Chloe", "Ethan")
959
+ saved_profiles = (
960
+ resume_payload["config"].get("seat_profiles")
961
+ if resume_payload is not None
962
+ else None
963
+ )
964
+ if isinstance(saved_profiles, list) and len(saved_profiles) == 5:
965
+ mixed_profiles = [str(profile) for profile in saved_profiles]
966
+ else:
967
+ mixed_profiles = ["aggressive", "tight", "tricky", "balanced", "aggressive"]
968
+ profile_rng = random.Random(None if args.seed is None else args.seed + 10_000)
969
+ profile_rng.shuffle(mixed_profiles)
970
+ local_agent_providers = (
971
+ _local_agent_providers(args.opponent)
972
+ if args.opponent in {"agent", "codex", "claude"}
973
+ else []
974
+ )
975
+ online_budget = (
976
+ OnlineDecisionBudget()
977
+ if local_agent_providers and args.online_policy == "key"
978
+ else None
979
+ )
980
+ personas = (
981
+ "patient, position-aware, and capable of disciplined folds",
982
+ "assertive but selective, preferring initiative over loose calls",
983
+ "observant and deceptive, using traps sparingly",
984
+ "pragmatic and pot-control oriented with medium-strength hands",
985
+ "adaptive and pressure-oriented when stacks and position allow",
986
+ )
987
+ seats: list[tuple[str, object]] = [("你", hero_controller)]
988
+ for seat_index in range(1, args.players):
989
+ name = bot_names[seat_index - 1]
990
+ if local_agent_providers:
991
+ provider = local_agent_providers[(seat_index - 1) % len(local_agent_providers)]
992
+ controller = LocalAgentCLIController(
993
+ provider,
994
+ name,
995
+ thinking_sink=thinking_sink,
996
+ notice_sink=event_sink,
997
+ persona=personas[seat_index - 1],
998
+ online_decision=(
999
+ online_budget.claim if online_budget is not None else None
1000
+ ),
1001
+ model=_model_for_provider(args, provider),
1002
+ usage_tracker=usage_tracker,
1003
+ fast_forward=(
1004
+ (lambda: table.fast_forward_requested)
1005
+ if table is not None
1006
+ else None
1007
+ ),
1008
+ )
1009
+ elif args.opponent == "llm":
1010
+ controller = LLMController()
1011
+ else:
1012
+ if args.opponent in {"local", "mixed"}:
1013
+ profile_key = mixed_profiles[(seat_index - 1) % len(mixed_profiles)]
1014
+ else:
1015
+ profile_key = args.opponent
1016
+ bot_seed = None if args.seed is None else args.seed + seat_index
1017
+ controller = OfflineBot(
1018
+ profile_key,
1019
+ rng=random.Random(bot_seed),
1020
+ samples=140,
1021
+ )
1022
+ seats.append((name, controller))
1023
+
1024
+ game = TournamentGame(
1025
+ seats, # type: ignore[arg-type]
1026
+ starting_stack=args.stack,
1027
+ small_blind=args.small_blind,
1028
+ big_blind=args.big_blind,
1029
+ hands_per_level=(
1030
+ args.hands_per_level
1031
+ if args.hands_per_level is not None
1032
+ else BLIND_SPEEDS[args.blind_speed]
1033
+ ),
1034
+ seed=args.seed,
1035
+ event_sink=event_sink,
1036
+ state_sink=state_sink,
1037
+ )
1038
+ tournament_results = []
1039
+ if resume_payload is not None:
1040
+ try:
1041
+ tournament_results = restore_tournament(game, resume_payload)
1042
+ except SaveGameError as error:
1043
+ print(f"无法继续游戏:{error}", file=sys.stderr)
1044
+ return 2
1045
+ print(
1046
+ f"已继续存档|第 {game.hand_id + 1} 手待开始|"
1047
+ f"盲注 {game.small_blind}/{game.big_blind}"
1048
+ )
1049
+ print(
1050
+ f"单桌锦标赛|{args.players} 人|起始筹码 {game.starting_stack}|"
1051
+ f"每 {game.hands_per_level} 手升盲"
1052
+ )
1053
+ if coach is not None:
1054
+ print(f"赛后教练:{coach.provider}")
1055
+ if "codex" in local_agent_providers or (
1056
+ coach is not None and coach.provider == "codex"
1057
+ ):
1058
+ usage_tracker.refresh_codex_quota_async(force=True)
1059
+ save_config = _tournament_save_config(args, game, mixed_profiles)
1060
+ save_config["tournament_id"] = history_session_id
1061
+ autosave_enabled = not args.no_save
1062
+ save_path = None
1063
+ if autosave_enabled and resume_payload is None:
1064
+ try:
1065
+ save_path = save_tournament(game, tournament_results, save_config)
1066
+ except SaveGameError as error:
1067
+ autosave_enabled = False
1068
+ print(f"自动存档不可用:{error}", file=sys.stderr)
1069
+ completed = 0
1070
+ try:
1071
+ while game.hero_alive and not game.is_over:
1072
+ if table is not None:
1073
+ table.start()
1074
+ result = game.play_hand()
1075
+ tournament_results.append(result)
1076
+ _record_hand(
1077
+ history_store,
1078
+ game,
1079
+ result,
1080
+ session_id=history_session_id,
1081
+ mode="tournament",
1082
+ )
1083
+ continue_requested = True
1084
+ if table is not None:
1085
+ continue_requested = table.wait_for_next_hand()
1086
+ table.stop()
1087
+ if args.review == "hand" and result.hero_decisions:
1088
+ print(format_review(result))
1089
+ if coach is not None:
1090
+ print(f"\n── {coach.provider} 深度复盘 ──")
1091
+ try:
1092
+ print(coach.review(result))
1093
+ except CoachError as error:
1094
+ print(f"本手大模型复盘不可用:{error}")
1095
+ standings = "|".join(
1096
+ f"{player.name} {player.stack}"
1097
+ for player in sorted(game.players, key=lambda item: item.stack, reverse=True)
1098
+ )
1099
+ print(f"\n筹码榜|{standings}")
1100
+ completed += 1
1101
+ if autosave_enabled:
1102
+ try:
1103
+ save_path = save_tournament(
1104
+ game,
1105
+ tournament_results,
1106
+ save_config,
1107
+ )
1108
+ except SaveGameError as error:
1109
+ autosave_enabled = False
1110
+ print(f"自动存档不可用:{error}", file=sys.stderr)
1111
+ if args.hands and completed >= args.hands:
1112
+ break
1113
+ if not continue_requested:
1114
+ break
1115
+ if table is None and not args.hands and not _continue_next_hand():
1116
+ break
1117
+ except (EOFError, KeyboardInterrupt):
1118
+ if table is not None:
1119
+ table.stop()
1120
+ print("\n锦标赛已暂停。")
1121
+ finally:
1122
+ if table is not None:
1123
+ table.stop()
1124
+
1125
+ if game.is_over:
1126
+ outcome = f"冠军:{game.champion}"
1127
+ print(f"\n🏆 冠军:{game.champion}")
1128
+ elif not game.hero_alive:
1129
+ outcome = f"玩家最终名次:第 {game.hero_place} 名"
1130
+ print(f"\n你已被淘汰,最终名次:第 {game.hero_place} 名")
1131
+ else:
1132
+ outcome = "锦标赛未完成,本次为阶段性总结"
1133
+
1134
+ if args.review == "tournament" and tournament_results:
1135
+ print(format_tournament_review(tournament_results, outcome))
1136
+ if coach is not None:
1137
+ print(f"\n── {coach.provider} 锦标赛整体复盘 ──")
1138
+ try:
1139
+ print(coach.review_tournament(tournament_results, outcome))
1140
+ except CoachError as error:
1141
+ print(f"大模型整体复盘不可用:{error}")
1142
+ if game.is_over or not game.hero_alive:
1143
+ if autosave_enabled:
1144
+ remove_save()
1145
+ elif save_path is not None:
1146
+ print(f"\n进度已保存|下次运行 poker resume")
1147
+ print(f"\n── 模型用量 ──\n{usage_tracker.detailed_summary()}")
1148
+ return 0
1149
+
1150
+
1151
+ def _tournament_save_config(
1152
+ args: argparse.Namespace,
1153
+ game: TournamentGame,
1154
+ seat_profiles: list[str],
1155
+ ) -> dict:
1156
+ keys = (
1157
+ "opponent",
1158
+ "mode",
1159
+ "players",
1160
+ "plain",
1161
+ "speed",
1162
+ "size",
1163
+ "theme",
1164
+ "coach",
1165
+ "codex_model",
1166
+ "claude_model",
1167
+ "review",
1168
+ "stack",
1169
+ "small_blind",
1170
+ "big_blind",
1171
+ "hands",
1172
+ "seed",
1173
+ "blind_speed",
1174
+ "online_policy",
1175
+ "no_history",
1176
+ )
1177
+ config = {key: getattr(args, key) for key in keys}
1178
+ config["hands_per_level"] = game.hands_per_level
1179
+ config["seat_profiles"] = list(seat_profiles)
1180
+ config["no_save"] = False
1181
+ return config
1182
+
1183
+
1184
+ def _local_agent_providers(opponent: str) -> list[str]:
1185
+ requested = ("codex", "claude") if opponent == "agent" else (opponent,)
1186
+ providers = [provider for provider in requested if shutil.which(provider)]
1187
+ if not providers:
1188
+ names = "Codex 或 Claude Code" if opponent == "agent" else opponent
1189
+ raise ValueError(f"找不到本地 {names} 命令")
1190
+ return providers
1191
+
1192
+
1193
+ def _plain_thinking(actor: str, provider: str | None) -> None:
1194
+ if provider is not None:
1195
+ print(f"{actor}|{provider.upper()} thinking…", flush=True)
1196
+
1197
+
1198
+ def _record_hand(
1199
+ store: HandHistoryStore | None,
1200
+ game: object,
1201
+ result: HandResult,
1202
+ *,
1203
+ session_id: str,
1204
+ mode: str,
1205
+ ) -> None:
1206
+ if store is None:
1207
+ return
1208
+ try:
1209
+ store.append(
1210
+ build_hand_record(
1211
+ game,
1212
+ result,
1213
+ session_id=session_id,
1214
+ mode=mode,
1215
+ )
1216
+ )
1217
+ except HistoryError as error:
1218
+ print(f"长期牌局记录不可用:{error}", file=sys.stderr)
1219
+
1220
+
1221
+ def _continue_next_hand() -> bool:
1222
+ while True:
1223
+ prompt = "\n下一手|1 继续 / 0 结束 [1] > "
1224
+ if not sys.stdin.isatty():
1225
+ selected = input(prompt).strip()
1226
+ else:
1227
+ print(prompt, end="", flush=True)
1228
+ descriptor = sys.stdin.fileno()
1229
+ original = termios.tcgetattr(descriptor)
1230
+ try:
1231
+ tty.setcbreak(descriptor)
1232
+ selected = sys.stdin.read(1)
1233
+ if selected == "\x03":
1234
+ raise KeyboardInterrupt
1235
+ if selected == "\x04":
1236
+ raise EOFError
1237
+ finally:
1238
+ termios.tcsetattr(descriptor, termios.TCSADRAIN, original)
1239
+ if selected in {"\r", "\n"}:
1240
+ selected = ""
1241
+ print(selected)
1242
+ if selected in {"", "1"}:
1243
+ return True
1244
+ if selected == "0":
1245
+ return False
1246
+ print("请输入 1 继续,或 0 结束。")
1247
+
1248
+
1249
+ if __name__ == "__main__":
1250
+ raise SystemExit(main())