From 0731e5cebb144b41b573ba3db071a91de91df335 Mon Sep 17 00:00:00 2001 From: JWeBot Contributors Date: Thu, 15 Oct 2026 13:34:10 +0800 Subject: [PATCH] fix: bound chat latency and clarify blackjack flow --- CHANGELOG.md | 18 +++ README.md | 16 ++- VERSION | 2 +- docs/local-plugins-0.6.1.md | 13 +- .../jwebot/single-player-games/README.md | 3 +- .../jwebot/single-player-games/plugin.toml | 2 +- .../src/single_player_games.py | 2 +- .../single-player-games/tests/test_plugin.py | 2 +- packages/jwebot-config/pyproject.toml | 2 +- .../src/jwebot_config/__init__.py | 2 +- packages/jwebot-extension-host/pyproject.toml | 6 +- .../src/jwebot_extension_host/__init__.py | 2 +- packages/jwebot-kernel/pyproject.toml | 8 +- .../src/jwebot_kernel/__init__.py | 2 +- .../src/jwebot_kernel/edge_runtime.py | 64 ++++++++- .../jwebot-official-extensions/pyproject.toml | 4 +- .../jwebot_official_extensions/__init__.py | 2 +- .../adapters/single_player_games.py | 2 +- .../jwebot_official_extensions/descriptors.py | 3 +- .../player_games.py | 77 ++++++++--- packages/jwebot-sdk/pyproject.toml | 2 +- .../jwebot-sdk/src/jwebot_sdk/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_jwebot_edge.py | 51 ++++++- tests/test_release_062.py | 128 ++++++++++++++++++ 25 files changed, 364 insertions(+), 53 deletions(-) create mode 100644 tests/test_release_062.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f813d..5a90617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## alpha 0.6.2.1015 + +- Bound direct DeepSeek SSE reads to 30 seconds of total wall time in addition + to the existing 18-second socket inactivity timeout. +- Stop reading as soon as the SSE `[DONE]` marker arrives instead of waiting + for the upstream connection to close. +- Prevent a trickling or unfinished upstream response from occupying both + generation workers for about 15 minutes and delaying later final replies. +- Preserve fast ACK delivery, three-attempt retry behavior, two generation + workers, and strict final-reply ordering. +- Treat `/21点 开始 100` as a 100-coin stake and accept OCR forms with missing + whitespace, such as `/21点开始100` and `/blackjackstart100`. +- Keep blackjack play as explicit steps: start and deal first, then hit or + stand in a separate command. Combined forms return this workflow without + opening a game or deducting funds. +- Prefix blackjack hands, status and settlement replies with the current + group-scoped player's configured display label, such as `jacky:...`. + ## alpha 0.6.1.1015 - Add three reviewed plugins: local context summaries for 100/200/300 messages, diff --git a/README.md b/README.md index 7f9ca70..79cafe0 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,15 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`alpha 0.6.1.1015`。 +当前版本:`alpha 0.6.2.1015`。 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 [0.6.1 插件说明](docs/local-plugins-0.6.1.md)。 +0.6.2 为 DeepSeek 流式回复增加 30 秒总时限,避免未结束的上游连接长期 +占满生成 worker;21 点开局金额明确作为本局下注,并兼容 OCR 丢失空格。 +21 点牌面和结算以当前玩家展示名开头。快速 ACK、重试和严格顺序投递 +规则保持不变。 ### 运行流程 @@ -80,7 +84,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh /人物列表 [页码](/people);/人物 [名称或别名](/person) /金币(/wallet);/抽奖 [金额](/draw) /买彩票 [000~999] [金额](/lottery) -/21点 开始|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end) +/21点 开始 [金额];开局后 /21点 要牌|停牌|状态|结束 /游戏帮助(/games) /丢骰子 [NdM] /猜数字 开始 [上限] @@ -250,7 +254,7 @@ visually confirmed GUI bridge. Current version: ```text -alpha 0.6.1.1015 +alpha 0.6.2.1015 ``` ## Runtime Flow @@ -354,6 +358,12 @@ Accounts receive 1000 virtual coins once; funds, game state and event replay results commit together. OCR display aliases identify group-scoped people; native WeChat account IDs are unavailable. See [plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md). +Version 0.6.2 adds a 30-second total deadline to direct DeepSeek streams so an +unfinished upstream response cannot occupy both generation workers for about +15 minutes. Blackjack start amounts are the current round's stake; compact OCR +forms are accepted, while hit/stand remain explicit follow-up commands. +Blackjack hand and settlement replies start with the current player's configured +display label. ACKs, retries, and ordered final delivery remain unchanged. Steam prefers private per-person SteamID mappings from the active profile and uses the corresponding group display IDs and aliases. Without a private roster, diff --git a/VERSION b/VERSION index 3a14eb6..13964d1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -alpha 0.6.1.1015 +alpha 0.6.2.1015 diff --git a/docs/local-plugins-0.6.1.md b/docs/local-plugins-0.6.1.md index e4262d5..decb99c 100644 --- a/docs/local-plugins-0.6.1.md +++ b/docs/local-plugins-0.6.1.md @@ -10,7 +10,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写, | single-player-games | `/金币` | `/wallet` | | single-player-games | `/抽奖 [金额]` | `/draw [金额]` | | single-player-games | `/买彩票 [000~999] [金额]` | `/lottery [000~999] [金额]` | -| single-player-games | `/21点 开始 [金额]` | `/blackjack start [金额]` | +| single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | | single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` | | single-player-games | `/游戏帮助` | `/games` | | person-directory | `/人物列表 [页码]`、`/别名列表 [页码]` | `/people [页码]` | @@ -61,9 +61,14 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使 彩票默认随机三位号码、下注 10;可指定 000~999,命中返还五百倍。 所有返还都包含本金,回复同时报告下注、返还、净变化和余额。 -21 点默认下注 50。牌局使用洗牌后的单副 52 张牌,牌堆、双方手牌、 -下注和最后操作时间持久化。A 可作为 1 或 11;庄家软 17 停牌。 -开始后只公开一张庄家牌;要牌、停牌、认输和查询都仅针对本人牌局。 +21 点默认下注 50。`/21点 开始 100` 表示拿 100 金币作为本局下注进场, +等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌, +然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在 +同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌, +牌堆、双方手牌、下注和最后操作时间持久化。A 可作为 1 或 11; +庄家软 17 停牌。开始后只公开一张庄家牌;要牌、停牌、认输和查询都 +仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头, +例如 `jacky:你的牌 ...`;别名解析到同一人物时沿用相同展示名和账户。 自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, 平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 抽奖、彩票或开局。 diff --git a/extensions/jwebot/single-player-games/README.md b/extensions/jwebot/single-player-games/README.md index aa18e1b..c1189f9 100644 --- a/extensions/jwebot/single-player-games/README.md +++ b/extensions/jwebot/single-player-games/README.md @@ -7,7 +7,8 @@ Author: `jwebot` Help: - `/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery` -- `/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end` +- `/21点 开始 [金额](金额=本局下注,默认50,空格可省略)` +- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end` - `/游戏帮助;/games` Registered commands: diff --git a/extensions/jwebot/single-player-games/plugin.toml b/extensions/jwebot/single-player-games/plugin.toml index a92ce33..b155e44 100644 --- a/extensions/jwebot/single-player-games/plugin.toml +++ b/extensions/jwebot/single-player-games/plugin.toml @@ -1,7 +1,7 @@ id = "single-player-games" author = "jwebot" description = "本人虚拟金币账户、抽奖、彩票和单人21点" -help = ["/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", "/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end", "/游戏帮助;/games"] +help = ["/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", "/21点 开始 [金额](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"] version = "1.0.0" api_version = "1" entrypoint = "single_player_games:plugin" diff --git a/extensions/jwebot/single-player-games/src/single_player_games.py b/extensions/jwebot/single-player-games/src/single_player_games.py index 29ac9b0..d9801c8 100644 --- a/extensions/jwebot/single-player-games/src/single_player_games.py +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点' -HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games') +HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') diff --git a/extensions/jwebot/single-player-games/tests/test_plugin.py b/extensions/jwebot/single-player-games/tests/test_plugin.py index 5eae541..f9572da 100644 --- a/extensions/jwebot/single-player-games/tests/test_plugin.py +++ b/extensions/jwebot/single-player-games/tests/test_plugin.py @@ -20,7 +20,7 @@ class PluginTests(unittest.TestCase): ] self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(DESCRIPTION, '本人虚拟金币账户、抽奖、彩票和单人21点') - self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games')) + self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')) self.assertIn('/金币', registered) self.assertEqual(len(registered), 11) diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index aa9ddbf..e60b462 100644 --- a/packages/jwebot-config/pyproject.toml +++ b/packages/jwebot-config/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-config" -version = "0.6.1a0+1015" +version = "0.6.2a0+1015" description = "Profile-based configuration compiler for JWeBot" requires-python = ">=3.9" dependencies = [ diff --git a/packages/jwebot-config/src/jwebot_config/__init__.py b/packages/jwebot-config/src/jwebot_config/__init__.py index 21aacd0..475b38e 100644 --- a/packages/jwebot-config/src/jwebot_config/__init__.py +++ b/packages/jwebot-config/src/jwebot_config/__init__.py @@ -36,4 +36,4 @@ __all__ = [ "write_default_profile", ] -__version__ = "0.6.1a0+1015" +__version__ = "0.6.2a0+1015" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 6b48868..e4f7441 100644 --- a/packages/jwebot-extension-host/pyproject.toml +++ b/packages/jwebot-extension-host/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-extension-host" -version = "0.6.1a0+1015" +version = "0.6.2a0+1015" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.1a0+1015", - "jwebot-kernel==0.6.1a0+1015", + "jwebot-sdk==0.6.2a0+1015", + "jwebot-kernel==0.6.2a0+1015", ] [project.scripts] diff --git a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py index f19aab8..ff190f2 100644 --- a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py +++ b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py @@ -16,4 +16,4 @@ __all__ = [ "process_request", ] -__version__ = "0.6.1a0+1015" +__version__ = "0.6.2a0+1015" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 29af93f..32e4300 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-kernel" -version = "0.6.1a0+1015" +version = "0.6.2a0+1015" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==0.6.1a0+1015", - "jwebot-sdk==0.6.1a0+1015", - "jwebot-official-extensions==0.6.1a0+1015", + "jwebot-config==0.6.2a0+1015", + "jwebot-sdk==0.6.2a0+1015", + "jwebot-official-extensions==0.6.2a0+1015", "tomli>=2; python_version < '3.11'", ] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index a3c5570..4a60599 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "verify_installed_release_signature", ] -__version__ = "0.6.1a0+1015" +__version__ = "0.6.2a0+1015" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index ce8e9b3..bc2e267 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -92,6 +92,10 @@ MAX_REPLY_CHARS = 900 MAX_CONTEXT_TURNS = 6 MAX_OUTBOX_ATTEMPTS = 3 DIRECT_CHAT_GENERATION_WORKERS = 2 +DIRECT_API_SOCKET_TIMEOUT_SECONDS = 18.0 +DIRECT_API_WALL_TIMEOUT_SECONDS = 30.0 +DIRECT_API_MAX_RESPONSE_BYTES = 2_000_000 +DIRECT_API_READ_CHUNK_BYTES = 65_536 FAST_ACKNOWLEDGEMENT = "喵......" PROFILE_MIN_AUTOMATIC_OBSERVATIONS = 3 PROFILE_MAX_FACTS = 12 @@ -626,6 +630,59 @@ def deepseek_response_content(raw: bytes) -> str: return str(content) +def read_direct_api_response( + response: Any, + *, + wall_timeout_seconds: float = DIRECT_API_WALL_TIMEOUT_SECONDS, + max_bytes: int = DIRECT_API_MAX_RESPONSE_BYTES, +) -> bytes: + """Read an SSE response with an absolute deadline, not an idle timeout.""" + + deadline = time.monotonic() + wall_timeout_seconds + chunks: list[bytes] = [] + total = 0 + stream_tail = b"" + done_marker = re.compile( + rb"(?:^|\r?\n)data:\s*\[DONE\]\s*(?:\r?\n|$)" + ) + socket_connection = getattr( + getattr(getattr(response, "fp", None), "raw", None), + "_sock", + None, + ) + read_chunk = getattr(response, "read1", response.read) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DeepSeekUnavailable("direct API exceeded total timeout") + if socket_connection is not None: + socket_connection.settimeout( + max( + 0.001, + min(DIRECT_API_SOCKET_TIMEOUT_SECONDS, remaining), + ) + ) + try: + chunk = read_chunk( + min(DIRECT_API_READ_CHUNK_BYTES, max_bytes - total + 1) + ) + except (TimeoutError, OSError) as error: + raise DeepSeekUnavailable("direct API transport failure") from error + if not chunk: + break + if not isinstance(chunk, bytes): + raise DeepSeekUnavailable("direct API returned invalid bytes") + total += len(chunk) + if total > max_bytes: + raise DeepSeekUnavailable("direct API response exceeded size limit") + chunks.append(chunk) + stream_scan = stream_tail + chunk + if done_marker.search(stream_scan): + break + stream_tail = stream_scan[-64:] + return b"".join(chunks) + + def load_environment_file(path: Path = ENVIRONMENT_FILE) -> None: """Load the active generated profile, then the legacy compatibility file.""" @@ -3422,8 +3479,11 @@ class DeepSeekClient: method="POST", ) try: - with urllib.request.urlopen(request, timeout=18) as response: - raw = response.read(2_000_000) + with urllib.request.urlopen( + request, + timeout=DIRECT_API_SOCKET_TIMEOUT_SECONDS, + ) as response: + raw = read_direct_api_response(response) except urllib.error.HTTPError as error: raise DeepSeekUnavailable(f"direct API HTTP {error.code}") from error except (urllib.error.URLError, TimeoutError, OSError) as error: diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index c3c153f..5ed0b89 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-official-extensions" -version = "0.6.1a0+1015" +version = "0.6.2a0+1015" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.1a0+1015", + "jwebot-sdk==0.6.2a0+1015", ] [tool.setuptools.packages.find] diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py index 244c06c..79843da 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -62,4 +62,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "0.6.1a0+1015" +__version__ = "0.6.2a0+1015" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py index 29ac9b0..d9801c8 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py @@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点' -HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games') +HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py index 3cbd23c..43956a2 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -123,7 +123,8 @@ LOCAL_EXTENSIONS = ( description="本人虚拟金币账户、抽奖、彩票和单人21点", help_entries=( "/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", - "/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end", + "/21点 开始 [金额](金额=本局下注,默认50,空格可省略)", + "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games", ), command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"), diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py index 2f89a95..bf77063 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py @@ -11,6 +11,23 @@ from typing import Any INITIAL_COINS = 1000 MAX_BET = 100 GAME_TIMEOUT_SECONDS = 600 +BLACKJACK_ACTIONS = { + "要牌": "hit", + "hit": "hit", + "停牌": "stand", + "stand": "stand", + "状态": "status", + "status": "status", + "结束": "end", + "end": "end", +} +BLACKJACK_START = re.compile( + r"^(?:开始|start)(?P\d{1,3})?$" +) +BLACKJACK_COMBINED_ACTION = re.compile( + r"^(?:开始|start)(?P\d{1,3})?" + r"(?:要牌|hit|停牌|stand)$" +) def card_label(card: int) -> str: @@ -37,8 +54,9 @@ class PlayerGames: return ( "单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n" "/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;" - "/21点 开始 [金额] /blackjack start。\n" - "21点:/21点 要牌|停牌|状态|结束(hit|stand|status|end);" + "21点先用 /21点 开始 [金额] 进场,金额是本局下注,默认50;空格可省略。\n" + "看到牌后再单独发送 /21点 要牌|停牌|状态|结束" + "(/blackjack hit|stand|status|end);" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n" "抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" @@ -64,25 +82,40 @@ class PlayerGames: number = int(match[1]) if match[1] is not None else None bet = int(match[2] or 10) elif kind == "blackjack": - parts = arguments.casefold().split() - action = parts[0] if parts else "状态" - actions = {"开始": "start", "start": "start", "要牌": "hit", "hit": "hit", - "停牌": "stand", "stand": "stand", "状态": "status", "status": "status", - "结束": "end", "end": "end"} - action = actions.get(action, "") - if not action or (action != "start" and len(parts) > 1) or len(parts) > 2: - return "用法:/21点 开始 [金额]|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end)" - if action == "start": - if len(parts) == 2 and not re.fullmatch(r"\d{1,3}", parts[1]): - return "21点下注必须为 2~100 的整数金币" - bet = int(parts[1]) if len(parts) == 2 else 50 + compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态" + start = BLACKJACK_START.fullmatch(compacted) + if start is not None: + action = "start" + bet = int(start["bet"] or 50) else: + action = BLACKJACK_ACTIONS.get(compacted, "") bet = 0 + if not action: + combined = BLACKJACK_COMBINED_ACTION.fullmatch(compacted) + if combined is not None: + amount = combined["bet"] or "50" + return ( + "21点要分两步:先发送 /21点 开始 " + f"{amount}({amount} 是本局下注);看到牌后再单独发送 " + "/21点 要牌 或 /21点 停牌" + ) + return ( + "用法:先 /21点 开始 [2~100](金额是本局下注,默认50);" + "开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略" + ) else: bet = 0 if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start": if not 2 <= bet <= MAX_BET: return "下注必须为 2~100 的整数金币" + person = self.ledger.resolve_person( + str(row["group_fingerprint"]), + str(row["sender"]), + ) + player_label = self.display_label(person["display_name"]) + + def blackjack_reply(text: str) -> str: + return f"{player_label}:{text}" def execute(account: dict[str, Any]) -> str: expired = "" @@ -97,11 +130,15 @@ class PlayerGames: f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。" "首次赠送1000仅发一次;/游戏帮助 查看规则") if kind == "blackjack" and action in {"hit", "stand", "status", "end"}: - return expired + self.play_blackjack(account, action) + return blackjack_reply( + expired + self.play_blackjack(account, action) + ) if account.get("game"): - return expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束" + message = expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束" + return blackjack_reply(message) if kind == "blackjack" else message if account["balance"] < bet: - return expired + f"金币不足:余额 {account['balance']},本次需要 {bet}" + message = expired + f"金币不足:余额 {account['balance']},本次需要 {bet}" + return blackjack_reply(message) if kind == "blackjack" else message account["balance"] -= bet if kind == "blackjack": deck = list(range(52)) @@ -110,8 +147,10 @@ class PlayerGames: "dealer": deck[2:4], "updated_at": time.time()} account["game"] = game if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21: - return expired + self.finish_blackjack(account, natural=True) - return expired + self.show_hand(game) + return blackjack_reply( + expired + self.finish_blackjack(account, natural=True) + ) + return blackjack_reply(expired + self.show_hand(game)) if kind == "prize_draw": roll = self.rng.randrange(100) payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0) diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index ef3e446..b37a4d2 100644 --- a/packages/jwebot-sdk/pyproject.toml +++ b/packages/jwebot-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-sdk" -version = "0.6.1a0+1015" +version = "0.6.2a0+1015" description = "Typed, capability-limited extension API for JWeBot plugins" requires-python = ">=3.9" dependencies = [] diff --git a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py index dd211c1..4aed2b0 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -38,4 +38,4 @@ __all__ = [ "SingleGroupContext", ] -__version__ = "0.6.1a0+1015" +__version__ = "0.6.2a0+1015" diff --git a/pyproject.toml b/pyproject.toml index 7745d0b..603f6f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "alpha 0.6.1.1015" +release = "alpha 0.6.2.1015" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_jwebot_edge.py b/tests/test_jwebot_edge.py index 162c1bf..a0c61d3 100644 --- a/tests/test_jwebot_edge.py +++ b/tests/test_jwebot_edge.py @@ -1736,7 +1736,7 @@ class DirectClientTests(unittest.TestCase): ) ) response = mock.MagicMock() - response.read.return_value = ( + response.read1.return_value = ( 'data: {"choices":[{"delta":{"content":"正"}}]}\n\n' 'data: {"choices":[{"delta":{"content":"常"}}]}\n\n' "data: [DONE]\n\n" @@ -1782,6 +1782,55 @@ class DirectClientTests(unittest.TestCase): finally: temporary.cleanup() + def test_stream_reader_stops_at_done_without_waiting_for_eof(self): + response = mock.MagicMock() + response.read1.side_effect = [ + b'data: {"choices":[{"delta":{"content":"ok"}}]}\n' + b"data: [DO", + b"NE]\n\n", + AssertionError("reader continued after the stream completed"), + ] + + raw = MODULE.read_direct_api_response(response) + + self.assertEqual( + raw, + b'data: {"choices":[{"delta":{"content":"ok"}}]}\n' + b"data: [DONE]\n\n", + ) + self.assertEqual(response.read1.call_count, 2) + + def test_stream_reader_does_not_treat_content_as_done_marker(self): + response = mock.MagicMock() + response.read1.side_effect = [ + b'data: {"choices":[{"delta":{"content":"data: [DONE]"}}]}\n\n', + b"data: [DONE]\n\n", + AssertionError("reader continued after the stream completed"), + ] + + raw = MODULE.read_direct_api_response(response) + + self.assertIn(b'"content":"data: [DONE]"', raw) + self.assertTrue(raw.endswith(b"data: [DONE]\n\n")) + self.assertEqual(response.read1.call_count, 2) + + def test_stream_reader_enforces_total_wall_time(self): + response = mock.MagicMock() + response.read1.return_value = b": keep-alive\n\n" + with mock.patch.object( + MODULE.time, + "monotonic", + side_effect=[0.0, 0.0, 31.0], + ): + with self.assertRaisesRegex( + MODULE.DeepSeekUnavailable, + "exceeded total timeout", + ): + MODULE.read_direct_api_response(response) + + response.fp.raw._sock.settimeout.assert_called_once_with(18.0) + response.read1.assert_called_once() + def test_stream_parser_accepts_a_non_streaming_compatibility_payload(self): self.assertEqual( MODULE.deepseek_response_content( diff --git a/tests/test_release_062.py b/tests/test_release_062.py new file mode 100644 index 0000000..aea2c55 --- /dev/null +++ b/tests/test_release_062.py @@ -0,0 +1,128 @@ +"""Behavioral coverage for the alpha 0.6.2 latency and blackjack fixes.""" + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from test_jwebot_edge import MODULE, config + + +class Release062BlackjackTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.engine = MODULE.EdgeApplication(config(Path(self.temp.name))) + self.counter = 0 + + def tearDown(self): + self.engine.ledger.connection.close() + self.temp.cleanup() + + def command(self, question, *, event_id=None, sender="Alice"): + self.counter += 1 + row = { + "question": question, + "sender": sender, + "group_fingerprint": "group-a", + "event_id": event_id or f"test-{self.counter}", + } + return self.engine.local.handle(row, {}) + + def state(self, sender="Alice"): + key = self.engine.ledger.resolve_person("group-a", sender)["person_key"] + row = self.engine.ledger.connection.execute( + """ + SELECT state_json FROM player_accounts + WHERE group_fingerprint = ? AND person_key = ? + """, + ("group-a", key), + ).fetchone() + return json.loads(row[0]) + + @staticmethod + def hit_deck(cards): + first = [4, 5, 8, 7] + cards[:] = ( + first + + [card for card in cards if card not in first and card != 1] + + [1] + ) + + def test_compact_start_sets_stake_then_separate_hit_replays_once(self): + with mock.patch.object( + self.engine.local.players.rng, + "shuffle", + side_effect=self.hit_deck, + ): + start = self.command("/21点开始100", event_id="compact-start") + self.assertTrue(start.startswith("alice:")) + self.assertIn("下注 100", start) + self.assertEqual(self.state()["balance"], 900) + self.assertEqual(len(self.state()["game"]["player"]), 2) + + first = self.command("/21点要牌", event_id="separate-hit") + replay = self.command( + "/21点 要牌", + event_id="separate-hit", + sender="a1", + ) + + self.assertEqual(replay, first) + self.assertTrue(first.startswith("alice:")) + self.assertIn("13点", first) + self.assertEqual(self.state()["balance"], 900) + self.assertEqual(len(self.state()["game"]["player"]), 3) + + def test_spaced_chinese_and_compact_english_start_use_bet(self): + def tied_deck(cards): + first = [9, 6, 8, 7] + cards[:] = first + [card for card in cards if card not in first] + + for command, sender in ( + ("/21点 开始 50", "Alice"), + ("/blackjackstart50", "Bob"), + ): + with self.subTest(command=command): + with mock.patch.object( + self.engine.local.players.rng, + "shuffle", + side_effect=tied_deck, + ): + started = self.command(command, sender=sender) + self.assertTrue( + started.startswith( + "alice:" if sender == "Alice" else "bob:" + ) + ) + self.assertIn("下注 50", started) + self.assertEqual(self.state(sender)["balance"], 950) + self.assertIsNotNone(self.state(sender)["game"]) + self.command("/21点 结束", sender=sender) + self.assertIsNone(self.state(sender)["game"]) + + def test_default_bet_can_start_without_spaces(self): + with mock.patch.object( + self.engine.local.players.rng, + "shuffle", + side_effect=self.hit_deck, + ): + reply = self.command("/21点开始") + + self.assertTrue(reply.startswith("alice:")) + self.assertIn("下注 50", reply) + self.assertEqual(self.state()["balance"], 950) + + def test_combined_start_and_action_explains_two_step_flow(self): + reply = self.command("/21点开始100要牌") + + self.assertIn("分两步", reply) + self.assertIn("100 是本局下注", reply) + self.assertIn("再单独发送", reply) + wallet = self.command("/金币") + self.assertIn("金币 1000", wallet) + self.assertIn("无进行中的牌局", wallet) + + +if __name__ == "__main__": + unittest.main()