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/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())
|