fix: bound chat latency and clarify blackjack flow
This commit is contained in:
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# 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
|
## alpha 0.6.1.1015
|
||||||
|
|
||||||
- Add three reviewed plugins: local context summaries for 100/200/300 messages,
|
- Add three reviewed plugins: local context summaries for 100/200/300 messages,
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
|||||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||||
并通过带视觉确认的 GUI 发送链路回复。
|
并通过带视觉确认的 GUI 发送链路回复。
|
||||||
|
|
||||||
当前版本:`alpha 0.6.1.1015`。
|
当前版本:`alpha 0.6.2.1015`。
|
||||||
|
|
||||||
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
|
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
|
||||||
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
|
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
|
||||||
[0.6.1 插件说明](docs/local-plugins-0.6.1.md)。
|
[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)
|
/人物列表 [页码](/people);/人物 [名称或别名](/person)
|
||||||
/金币(/wallet);/抽奖 [金额](/draw)
|
/金币(/wallet);/抽奖 [金额](/draw)
|
||||||
/买彩票 [000~999] [金额](/lottery)
|
/买彩票 [000~999] [金额](/lottery)
|
||||||
/21点 开始|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end)
|
/21点 开始 [金额];开局后 /21点 要牌|停牌|状态|结束
|
||||||
/游戏帮助(/games)
|
/游戏帮助(/games)
|
||||||
/丢骰子 [NdM]
|
/丢骰子 [NdM]
|
||||||
/猜数字 开始 [上限]
|
/猜数字 开始 [上限]
|
||||||
@@ -250,7 +254,7 @@ visually confirmed GUI bridge.
|
|||||||
Current version:
|
Current version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
alpha 0.6.1.1015
|
alpha 0.6.2.1015
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runtime Flow
|
## 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;
|
results commit together. OCR display aliases identify group-scoped people;
|
||||||
native WeChat account IDs are unavailable. See
|
native WeChat account IDs are unavailable. See
|
||||||
[plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md).
|
[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
|
Steam prefers private per-person SteamID mappings from the active profile and
|
||||||
uses the corresponding group display IDs and aliases. Without a private roster,
|
uses the corresponding group display IDs and aliases. Without a private roster,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
|
|||||||
| single-player-games | `/金币` | `/wallet` |
|
| single-player-games | `/金币` | `/wallet` |
|
||||||
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
|
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
|
||||||
| single-player-games | `/买彩票 [000~999] [金额]` | `/lottery [000~999] [金额]` |
|
| 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 | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` |
|
||||||
| single-player-games | `/游戏帮助` | `/games` |
|
| single-player-games | `/游戏帮助` | `/games` |
|
||||||
| person-directory | `/人物列表 [页码]`、`/别名列表 [页码]` | `/people [页码]` |
|
| person-directory | `/人物列表 [页码]`、`/别名列表 [页码]` | `/people [页码]` |
|
||||||
@@ -61,9 +61,14 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
|
|||||||
彩票默认随机三位号码、下注 10;可指定 000~999,命中返还五百倍。
|
彩票默认随机三位号码、下注 10;可指定 000~999,命中返还五百倍。
|
||||||
所有返还都包含本金,回复同时报告下注、返还、净变化和余额。
|
所有返还都包含本金,回复同时报告下注、返还、净变化和余额。
|
||||||
|
|
||||||
21 点默认下注 50。牌局使用洗牌后的单副 52 张牌,牌堆、双方手牌、
|
21 点默认下注 50。`/21点 开始 100` 表示拿 100 金币作为本局下注进场,
|
||||||
下注和最后操作时间持久化。A 可作为 1 或 11;庄家软 17 停牌。
|
等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌,
|
||||||
开始后只公开一张庄家牌;要牌、停牌、认输和查询都仅针对本人牌局。
|
然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在
|
||||||
|
同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌,
|
||||||
|
牌堆、双方手牌、下注和最后操作时间持久化。A 可作为 1 或 11;
|
||||||
|
庄家软 17 停牌。开始后只公开一张庄家牌;要牌、停牌、认输和查询都
|
||||||
|
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
|
||||||
|
例如 `jacky:你的牌 ...`;别名解析到同一人物时沿用相同展示名和账户。
|
||||||
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
|
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
|
||||||
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
|
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
|
||||||
抽奖、彩票或开局。
|
抽奖、彩票或开局。
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ Author: `jwebot`
|
|||||||
Help:
|
Help:
|
||||||
|
|
||||||
- `/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery`
|
- `/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery`
|
||||||
- `/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end`
|
- `/21点 开始 [金额](金额=本局下注,默认50,空格可省略)`
|
||||||
|
- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end`
|
||||||
- `/游戏帮助;/games`
|
- `/游戏帮助;/games`
|
||||||
|
|
||||||
Registered commands:
|
Registered commands:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "single-player-games"
|
id = "single-player-games"
|
||||||
author = "jwebot"
|
author = "jwebot"
|
||||||
description = "本人虚拟金币账户、抽奖、彩票和单人21点"
|
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"
|
version = "1.0.0"
|
||||||
api_version = "1"
|
api_version = "1"
|
||||||
entrypoint = "single_player_games:plugin"
|
entrypoint = "single_player_games:plugin"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
|||||||
PROFILE = 'single-player-games'
|
PROFILE = 'single-player-games'
|
||||||
AUTHOR = 'jwebot'
|
AUTHOR = 'jwebot'
|
||||||
DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点'
|
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')
|
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class PluginTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
self.assertEqual(AUTHOR, 'jwebot')
|
self.assertEqual(AUTHOR, 'jwebot')
|
||||||
self.assertEqual(DESCRIPTION, '本人虚拟金币账户、抽奖、彩票和单人21点')
|
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.assertIn('/金币', registered)
|
||||||
self.assertEqual(len(registered), 11)
|
self.assertEqual(len(registered), 11)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-config"
|
name = "jwebot-config"
|
||||||
version = "0.6.1a0+1015"
|
version = "0.6.2a0+1015"
|
||||||
description = "Profile-based configuration compiler for JWeBot"
|
description = "Profile-based configuration compiler for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ __all__ = [
|
|||||||
"write_default_profile",
|
"write_default_profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.1a0+1015"
|
__version__ = "0.6.2a0+1015"
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-extension-host"
|
name = "jwebot-extension-host"
|
||||||
version = "0.6.1a0+1015"
|
version = "0.6.2a0+1015"
|
||||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==0.6.1a0+1015",
|
"jwebot-sdk==0.6.2a0+1015",
|
||||||
"jwebot-kernel==0.6.1a0+1015",
|
"jwebot-kernel==0.6.2a0+1015",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ __all__ = [
|
|||||||
"process_request",
|
"process_request",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.1a0+1015"
|
__version__ = "0.6.2a0+1015"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-kernel"
|
name = "jwebot-kernel"
|
||||||
version = "0.6.1a0+1015"
|
version = "0.6.2a0+1015"
|
||||||
description = "Stable routing and extension authority for JWeBot"
|
description = "Stable routing and extension authority for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-config==0.6.1a0+1015",
|
"jwebot-config==0.6.2a0+1015",
|
||||||
"jwebot-sdk==0.6.1a0+1015",
|
"jwebot-sdk==0.6.2a0+1015",
|
||||||
"jwebot-official-extensions==0.6.1a0+1015",
|
"jwebot-official-extensions==0.6.2a0+1015",
|
||||||
"tomli>=2; python_version < '3.11'",
|
"tomli>=2; python_version < '3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -107,4 +107,4 @@ __all__ = [
|
|||||||
"verify_installed_release_signature",
|
"verify_installed_release_signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.1a0+1015"
|
__version__ = "0.6.2a0+1015"
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ MAX_REPLY_CHARS = 900
|
|||||||
MAX_CONTEXT_TURNS = 6
|
MAX_CONTEXT_TURNS = 6
|
||||||
MAX_OUTBOX_ATTEMPTS = 3
|
MAX_OUTBOX_ATTEMPTS = 3
|
||||||
DIRECT_CHAT_GENERATION_WORKERS = 2
|
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 = "喵......"
|
FAST_ACKNOWLEDGEMENT = "喵......"
|
||||||
PROFILE_MIN_AUTOMATIC_OBSERVATIONS = 3
|
PROFILE_MIN_AUTOMATIC_OBSERVATIONS = 3
|
||||||
PROFILE_MAX_FACTS = 12
|
PROFILE_MAX_FACTS = 12
|
||||||
@@ -626,6 +630,59 @@ def deepseek_response_content(raw: bytes) -> str:
|
|||||||
return str(content)
|
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:
|
def load_environment_file(path: Path = ENVIRONMENT_FILE) -> None:
|
||||||
"""Load the active generated profile, then the legacy compatibility file."""
|
"""Load the active generated profile, then the legacy compatibility file."""
|
||||||
|
|
||||||
@@ -3422,8 +3479,11 @@ class DeepSeekClient:
|
|||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=18) as response:
|
with urllib.request.urlopen(
|
||||||
raw = response.read(2_000_000)
|
request,
|
||||||
|
timeout=DIRECT_API_SOCKET_TIMEOUT_SECONDS,
|
||||||
|
) as response:
|
||||||
|
raw = read_direct_api_response(response)
|
||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
raise DeepSeekUnavailable(f"direct API HTTP {error.code}") from error
|
raise DeepSeekUnavailable(f"direct API HTTP {error.code}") from error
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-official-extensions"
|
name = "jwebot-official-extensions"
|
||||||
version = "0.6.1a0+1015"
|
version = "0.6.2a0+1015"
|
||||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==0.6.1a0+1015",
|
"jwebot-sdk==0.6.2a0+1015",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -62,4 +62,4 @@ __all__ = [
|
|||||||
"render_feature_list",
|
"render_feature_list",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.1a0+1015"
|
__version__ = "0.6.2a0+1015"
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
|||||||
PROFILE = 'single-player-games'
|
PROFILE = 'single-player-games'
|
||||||
AUTHOR = 'jwebot'
|
AUTHOR = 'jwebot'
|
||||||
DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点'
|
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')
|
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ LOCAL_EXTENSIONS = (
|
|||||||
description="本人虚拟金币账户、抽奖、彩票和单人21点",
|
description="本人虚拟金币账户、抽奖、彩票和单人21点",
|
||||||
help_entries=(
|
help_entries=(
|
||||||
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery",
|
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery",
|
||||||
"/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end",
|
"/21点 开始 [金额](金额=本局下注,默认50,空格可省略)",
|
||||||
|
"/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end",
|
||||||
"/游戏帮助;/games",
|
"/游戏帮助;/games",
|
||||||
),
|
),
|
||||||
command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
|
command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
|
||||||
|
|||||||
+58
-19
@@ -11,6 +11,23 @@ from typing import Any
|
|||||||
INITIAL_COINS = 1000
|
INITIAL_COINS = 1000
|
||||||
MAX_BET = 100
|
MAX_BET = 100
|
||||||
GAME_TIMEOUT_SECONDS = 600
|
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<bet>\d{1,3})?$"
|
||||||
|
)
|
||||||
|
BLACKJACK_COMBINED_ACTION = re.compile(
|
||||||
|
r"^(?:开始|start)(?P<bet>\d{1,3})?"
|
||||||
|
r"(?:要牌|hit|停牌|stand)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def card_label(card: int) -> str:
|
def card_label(card: int) -> str:
|
||||||
@@ -37,8 +54,9 @@ class PlayerGames:
|
|||||||
return (
|
return (
|
||||||
"单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n"
|
"单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n"
|
||||||
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;"
|
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;"
|
||||||
"/21点 开始 [金额] /blackjack start。\n"
|
"21点先用 /21点 开始 [金额] 进场,金额是本局下注,默认50;空格可省略。\n"
|
||||||
"21点:/21点 要牌|停牌|状态|结束(hit|stand|status|end);"
|
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
|
||||||
|
"(/blackjack hit|stand|status|end);"
|
||||||
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
|
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
|
||||||
"抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;"
|
"抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;"
|
||||||
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
|
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
|
||||||
@@ -64,25 +82,40 @@ class PlayerGames:
|
|||||||
number = int(match[1]) if match[1] is not None else None
|
number = int(match[1]) if match[1] is not None else None
|
||||||
bet = int(match[2] or 10)
|
bet = int(match[2] or 10)
|
||||||
elif kind == "blackjack":
|
elif kind == "blackjack":
|
||||||
parts = arguments.casefold().split()
|
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
|
||||||
action = parts[0] if parts else "状态"
|
start = BLACKJACK_START.fullmatch(compacted)
|
||||||
actions = {"开始": "start", "start": "start", "要牌": "hit", "hit": "hit",
|
if start is not None:
|
||||||
"停牌": "stand", "stand": "stand", "状态": "status", "status": "status",
|
action = "start"
|
||||||
"结束": "end", "end": "end"}
|
bet = int(start["bet"] or 50)
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
|
action = BLACKJACK_ACTIONS.get(compacted, "")
|
||||||
bet = 0
|
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:
|
else:
|
||||||
bet = 0
|
bet = 0
|
||||||
if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start":
|
if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start":
|
||||||
if not 2 <= bet <= MAX_BET:
|
if not 2 <= bet <= MAX_BET:
|
||||||
return "下注必须为 2~100 的整数金币"
|
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:
|
def execute(account: dict[str, Any]) -> str:
|
||||||
expired = ""
|
expired = ""
|
||||||
@@ -97,11 +130,15 @@ class PlayerGames:
|
|||||||
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
|
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
|
||||||
"首次赠送1000仅发一次;/游戏帮助 查看规则")
|
"首次赠送1000仅发一次;/游戏帮助 查看规则")
|
||||||
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
|
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"):
|
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:
|
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
|
account["balance"] -= bet
|
||||||
if kind == "blackjack":
|
if kind == "blackjack":
|
||||||
deck = list(range(52))
|
deck = list(range(52))
|
||||||
@@ -110,8 +147,10 @@ class PlayerGames:
|
|||||||
"dealer": deck[2:4], "updated_at": time.time()}
|
"dealer": deck[2:4], "updated_at": time.time()}
|
||||||
account["game"] = game
|
account["game"] = game
|
||||||
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
|
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
|
||||||
return expired + self.finish_blackjack(account, natural=True)
|
return blackjack_reply(
|
||||||
return expired + self.show_hand(game)
|
expired + self.finish_blackjack(account, natural=True)
|
||||||
|
)
|
||||||
|
return blackjack_reply(expired + self.show_hand(game))
|
||||||
if kind == "prize_draw":
|
if kind == "prize_draw":
|
||||||
roll = self.rng.randrange(100)
|
roll = self.rng.randrange(100)
|
||||||
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
|
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-sdk"
|
name = "jwebot-sdk"
|
||||||
version = "0.6.1a0+1015"
|
version = "0.6.2a0+1015"
|
||||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ __all__ = [
|
|||||||
"SingleGroupContext",
|
"SingleGroupContext",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.1a0+1015"
|
__version__ = "0.6.2a0+1015"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.jwebot]
|
[tool.jwebot]
|
||||||
release = "alpha 0.6.1.1015"
|
release = "alpha 0.6.2.1015"
|
||||||
production_deployments_enabled_by_default = false
|
production_deployments_enabled_by_default = false
|
||||||
|
|
||||||
[tool.jwebot.workspace]
|
[tool.jwebot.workspace]
|
||||||
|
|||||||
@@ -1736,7 +1736,7 @@ class DirectClientTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
response = mock.MagicMock()
|
response = mock.MagicMock()
|
||||||
response.read.return_value = (
|
response.read1.return_value = (
|
||||||
'data: {"choices":[{"delta":{"content":"正"}}]}\n\n'
|
'data: {"choices":[{"delta":{"content":"正"}}]}\n\n'
|
||||||
'data: {"choices":[{"delta":{"content":"常"}}]}\n\n'
|
'data: {"choices":[{"delta":{"content":"常"}}]}\n\n'
|
||||||
"data: [DONE]\n\n"
|
"data: [DONE]\n\n"
|
||||||
@@ -1782,6 +1782,55 @@ class DirectClientTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
temporary.cleanup()
|
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):
|
def test_stream_parser_accepts_a_non_streaming_compatibility_payload(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
MODULE.deepseek_response_content(
|
MODULE.deepseek_response_content(
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user