Author SHA1 Message Date
JWeBot Contributors 22edf6ff15 Cache OCR-negative bubbles
check / local-gate (push) Failing after 42s
2026-10-16 11:38:03 +08:00
JWeBot Contributors 572159f571 Fix OCR thread test isolation
check / local-gate (push) Failing after 17m10s
2026-10-16 11:19:55 +08:00
JWeBot Contributors 99a251c19a Limit OCR runtime threads
check / local-gate (push) Failing after 44s
2026-10-16 11:16:23 +08:00
JWeBot Contributors 3fa04755d0 Add multiplayer blackjack table flow
check / local-gate (push) Failing after 51s
2026-10-16 11:03:32 +08:00
42 changed files with 2140 additions and 45 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## beta 1.0.2.1016 - 2026-10-16
- Cache OCR-negative visible bubbles and reuse stable visual signatures as the
OCR cache key so unreadable or lightly re-rendered bubbles do not trigger
repeated 10+ second RapidOCR passes during active chat windows.
## beta 1.0.1.1016 - 2026-10-16
- Limit RapidOCR ONNXRuntime intra/inter operation threads by default so the
adaptive OCR recorder no longer saturates CPU after restart and slow recovery
windows do not stretch local command detection into 10+ second scans.
## beta 1.0.0.1016 - 2026-10-16 ## beta 1.0.0.1016 - 2026-10-16
- Promote JWeBot from alpha to beta after the local, staging, privacy, and - Promote JWeBot from alpha to beta after the local, staging, privacy, and
+3 -2
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.0.1016`,更新日期:2026-10-16。 当前版本:`beta 1.0.2.1016`,更新日期:2026-10-16。
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟金币游戏、多人彩票、21 点、CS Major Steam 状态与时长查询、虚拟金币游戏、多人彩票、21 点、CS Major
@@ -88,6 +88,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/贷款 [金额](/loan amount);/还款 [金额|全部](/repay) /贷款 [金额](/loan amount);/还款 [金额|全部](/repay)
/彩票 开始|报名|当前状态|开奖|取消(/lottery) /彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案 /自走棋开始|报名|开赛|状态|档案
@@ -262,7 +263,7 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
beta 1.0.0.1016 beta 1.0.2.1016
``` ```
Updated: 2026-10-16. Updated: 2026-10-16.
+1 -1
View File
@@ -1 +1 @@
beta 1.0.0.1016 beta 1.0.2.1016
+5 -1
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog # JWeBot Command Catalog
Current baseline: `beta 1.0.0.1016`. Current baseline: `beta 1.0.1.1016`.
This catalog records the reviewed commands shipped with the built-in local This catalog records the reviewed commands shipped with the built-in local
extensions. Runtime ownership is frozen at startup from the generated extensions. Runtime ownership is frozen at startup from the generated
@@ -57,6 +57,10 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. | | `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. |
| `/21点 状态` | `/blackjack status` | Show the current blackjack round. | | `/21点 状态` | `/blackjack status` | Show the current blackjack round. |
| `/21点 结束` | `/blackjack end` | End the current blackjack round. | | `/21点 结束` | `/blackjack end` | End the current blackjack round. |
| `/21点 多人 启动` | - | Open a Taffy-hosted blackjack table and auto-seat the host. |
| `/21点 加入` | - | Join the current blackjack table before betting starts. |
| `/21点 多人 开始` | - | Host-only close joining and start ordered betting prompts. |
| `/21点 放金额` | - | Place the prompted table stake, 1-10000 coins. |
| `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. | | `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. |
| `/游戏帮助` | `/games` | Show local game rules. | | `/游戏帮助` | `/games` | Show local game rules. |
+9
View File
@@ -90,6 +90,15 @@ Steam uses `JWEBOT_EDGE_STEAM_ID64` and
- Host timeout and fallback; - Host timeout and fallback;
- GUI sender timeout. - GUI sender timeout.
The OCR runtime also accepts optional environment overrides for constrained
hosts:
- `WECHAT_OCR_ONNX_INTRA_OP_THREADS` defaults to `1`;
- `WECHAT_OCR_ONNX_INTER_OP_THREADS` defaults to `1`.
These values limit RapidOCR's ONNXRuntime CPU thread pools so service restarts
do not temporarily saturate the host while the OCR cache is cold.
### Debug ### Debug
`components/debug.toml` generates: `components/debug.toml` generates:
+6
View File
@@ -90,6 +90,12 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头, 仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、 例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
账户、局号和游戏记录。 账户、局号和游戏记录。
多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。
其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始`
后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~10000。
所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作;
结算按座位顺序列出每个人的下注、返还、净变化和当前金币。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, 自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖或 21 点开局,多人彩票报名仍可独立进行。 抽奖或 21 点开局,多人彩票报名仍可独立进行。
+2 -2
View File
@@ -14,9 +14,9 @@ Rules:
- `X.Y.Z` is the project version. - `X.Y.Z` is the project version.
- `MMDD` is the delivery date. - `MMDD` is the delivery date.
- Python package versions use PEP 440 pre-release form. For beta builds, - Python package versions use PEP 440 pre-release form. For beta builds,
`beta 1.0.0.1016` maps to `1.0.0b1+1016`. `beta 1.0.1.1016` maps to `1.0.1b1+1016`.
- Normal small updates increment only the patch version, for example - Normal small updates increment only the patch version, for example
`beta 1.0.0.1016` to `beta 1.0.1.1016`. `beta 1.0.1.1016` to `beta 1.0.2.1016`.
- Minor, major, or stable-release changes require explicit user review and - Minor, major, or stable-release changes require explicit user review and
milestone approval. milestone approval.
- Local repository migration does not imply a production deployment. - Local repository migration does not imply a production deployment.
+18
View File
@@ -0,0 +1,18 @@
# coin-taffy
Author: `jwebot`
Taffy金币
Help:
- `/Taffy金币`
Registered commands:
- `/Taffy金币`
- `/taffycoins`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-taffy` Kernel service profile
and returns a structured `ReplyPlan`.
+14
View File
@@ -0,0 +1,14 @@
id = "coin-taffy"
author = "jwebot"
description = "Taffy金币"
help = ["/Taffy金币"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_taffy:plugin"
type = "command"
[capabilities]
commands = ["/Taffy金币", "/taffycoins"]
storage = false
network_profiles = []
service_profiles = ["coin-taffy"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy金币'
HELP_ENTRIES = ('/Taffy金币',)
COMMANDS = ('/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_taffy import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, 'Taffy金币')
self.assertEqual(HELP_ENTRIES, ('/Taffy金币',))
self.assertIn('/Taffy金币', registered)
self.assertEqual(len(registered), 2)
if __name__ == "__main__":
unittest.main()
@@ -9,8 +9,7 @@ Help:
- `/金币 /wallet;/游戏记录 /gamehistory` - `/金币 /wallet;/游戏记录 /gamehistory`
- `/抽奖 [金额];/draw` - `/抽奖 [金额];/draw`
- `/彩票 开始|报名|状态|开奖|取消;/lottery` - `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 开始 [1~500];/blackjack start` - `/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200`
- `/21点 要牌|停牌|状态|结束;/blackjack`
- `/游戏帮助;/games` - `/游戏帮助;/games`
Registered commands: Registered commands:
@@ -1,7 +1,7 @@
id = "single-player-games" id = "single-player-games"
author = "jwebot" author = "jwebot"
description = "金币游戏" description = "金币游戏"
help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"] help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/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 = '金币游戏' DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -20,7 +20,7 @@ class PluginTests(unittest.TestCase):
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币游戏') self.assertEqual(DESCRIPTION, '金币游戏')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')) self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games'))
self.assertIn('/金币', registered) self.assertIn('/金币', registered)
self.assertEqual(len(registered), 15) self.assertEqual(len(registered), 15)
@@ -2,7 +2,7 @@
Author: `official` Author: `official`
提取当前群最近100、200或300条本地文字的上下文 上下文总结
Help: Help:
@@ -1,6 +1,6 @@
id = "context-summary" id = "context-summary"
author = "official" author = "official"
description = "提取当前群最近100、200或300条本地文字的上下文" description = "上下文总结"
help = ["/上下文 [100|200|300];/context [100|200|300]"] help = ["/上下文 [100|200|300];/context [100|200|300]"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary' PROFILE = 'context-summary'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context') COMMANDS = ('/上下文', '/context')
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'official') self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文') self.assertEqual(DESCRIPTION, '上下文总结')
self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',)) self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',))
self.assertIn('/上下文', registered) self.assertIn('/上下文', registered)
self.assertEqual(len(registered), 2) self.assertEqual(len(registered), 2)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "1.0.0b1+1016" version = "1.0.2b1+1016"
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__ = "1.0.0b1+1016" __version__ = "1.0.2b1+1016"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "1.0.0b1+1016" version = "1.0.2b1+1016"
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==1.0.0b1+1016", "jwebot-sdk==1.0.2b1+1016",
"jwebot-kernel==1.0.0b1+1016", "jwebot-kernel==1.0.2b1+1016",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "1.0.0b1+1016" __version__ = "1.0.2b1+1016"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "1.0.0b1+1016" version = "1.0.2b1+1016"
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==1.0.0b1+1016", "jwebot-config==1.0.2b1+1016",
"jwebot-sdk==1.0.0b1+1016", "jwebot-sdk==1.0.2b1+1016",
"jwebot-official-extensions==1.0.0b1+1016", "jwebot-official-extensions==1.0.2b1+1016",
"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__ = "1.0.0b1+1016" __version__ = "1.0.2b1+1016"
File diff suppressed because it is too large Load Diff
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "1.0.0b1+1016" version = "1.0.2b1+1016"
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==1.0.0b1+1016", "jwebot-sdk==1.0.2b1+1016",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "1.0.0b1+1016" __version__ = "1.0.2b1+1016"
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy金币'
HELP_ENTRIES = ('/Taffy金币',)
COMMANDS = ('/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary' PROFILE = 'context-summary'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context') COMMANDS = ('/上下文', '/context')
@@ -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 = '金币游戏' DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -46,6 +46,8 @@ LOCAL_COMMAND_ALIASES = {
"金币排行榜": "coin_ranking", "金币排行榜": "coin_ranking",
"大富翁榜": "coin_ranking", "大富翁榜": "coin_ranking",
"richlist": "coin_ranking", "richlist": "coin_ranking",
"Taffy金币": "taffy_coins",
"taffycoins": "taffy_coins",
"转账": "transfer", "转账": "transfer",
"transfer": "transfer", "transfer": "transfer",
"贷款": "loan", "贷款": "loan",
@@ -143,7 +145,7 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="context-summary", extension_id="context-summary",
author=OFFICIAL_AUTHOR, author=OFFICIAL_AUTHOR,
description="提取当前群最近100、200或300条本地文字的上下文", description="上下文总结",
help_entries=("/上下文 [100|200|300];/context [100|200|300]",), help_entries=("/上下文 [100|200|300];/context [100|200|300]",),
command_kinds=("context_summary",), command_kinds=("context_summary",),
), ),
@@ -155,8 +157,7 @@ LOCAL_EXTENSIONS = (
"/金币 /wallet;/游戏记录 /gamehistory", "/金币 /wallet;/游戏记录 /gamehistory",
"/抽奖 [金额];/draw", "/抽奖 [金额];/draw",
"/彩票 开始|报名|状态|开奖|取消;/lottery", "/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 开始 [1~500];/blackjack start", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200",
"/21点 要牌|停牌|状态|结束;/blackjack",
"/游戏帮助;/games", "/游戏帮助;/games",
), ),
command_kinds=( command_kinds=(
@@ -182,6 +183,13 @@ LOCAL_EXTENSIONS = (
help_entries=("/大富翁榜",), help_entries=("/大富翁榜",),
command_kinds=("coin_ranking",), command_kinds=("coin_ranking",),
), ),
LocalExtensionDescriptor(
extension_id="coin-taffy",
author=JWEBOT_AUTHOR,
description="Taffy金币",
help_entries=("/Taffy金币",),
command_kinds=("taffy_coins",),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="coin-loan", extension_id="coin-loan",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
@@ -157,6 +157,7 @@ class LocalCommandEngine:
if kind in { if kind in {
"wallet", "wallet",
"coin_ranking", "coin_ranking",
"taffy_coins",
"transfer", "transfer",
"loan", "loan",
"repay", "repay",
@@ -16,6 +16,9 @@ MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10 DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1 MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500 MAX_BLACKJACK_BET = 500
MIN_BLACKJACK_TABLE_BET = 1
MAX_BLACKJACK_TABLE_BET = 10_000
MAX_BLACKJACK_TABLE_PLAYERS = 8
MIN_TRANSFER_AMOUNT = 1 MIN_TRANSFER_AMOUNT = 1
MAX_TRANSFER_AMOUNT = 100_000 MAX_TRANSFER_AMOUNT = 100_000
MIN_LOAN_AMOUNT = 1 MIN_LOAN_AMOUNT = 1
@@ -45,6 +48,10 @@ BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?" r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"(?:要牌|hit|停牌|stand)$" r"(?:要牌|hit|停牌|stand)$"
) )
BLACKJACK_TABLE_BET = re.compile(
r"^(?:放|下注|bet)(?P<bet>\d{1,5})$",
re.IGNORECASE,
)
TRANSFER_MARKED = re.compile( TRANSFER_MARKED = re.compile(
r"^(?:给|to)?\s*(?P<target>.+?)\s*" r"^(?:给|to)?\s*(?P<target>.+?)\s*"
r"(?:金额|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$", r"(?:金额|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
@@ -161,6 +168,9 @@ class PlayerGames:
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束" "看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);" "(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"多人21点:/21点 多人 启动 创建牌桌;/21点 加入;"
"主持人 /21点 多人 开始 后按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌|停牌。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值或兑换。" "以上均含本金;虚拟金币不能充值或兑换。"
) )
@@ -177,6 +187,8 @@ class PlayerGames:
return self.group_lottery(row, arguments) return self.group_lottery(row, arguments)
if kind == "coin_ranking": if kind == "coin_ranking":
return self.coin_ranking(row, arguments) return self.coin_ranking(row, arguments)
if kind == "taffy_coins":
return self.taffy_coins(row, arguments)
if kind == "transfer": if kind == "transfer":
return self.transfer(row, arguments) return self.transfer(row, arguments)
if kind in {"loan", "repay"}: if kind in {"loan", "repay"}:
@@ -188,6 +200,15 @@ class PlayerGames:
return "用法:/抽奖 [2~100金币](/draw),默认10金币" return "用法:/抽奖 [2~100金币](/draw),默认10金币"
bet = int(arguments or 10) bet = int(arguments or 10)
elif kind == "blackjack": elif kind == "blackjack":
table_command = self.parse_blackjack_table_command(arguments)
if table_command is not None:
table_action, table_bet = table_command
return self.group_blackjack(
row,
table_action,
table_bet,
arguments,
)
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态" compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
start = BLACKJACK_START.fullmatch(compacted) start = BLACKJACK_START.fullmatch(compacted)
if start is not None: if start is not None:
@@ -209,6 +230,28 @@ class PlayerGames:
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);" "用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略" "开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
) )
group = str(row["group_fingerprint"])
if (
action in {"hit", "stand", "status"}
and self.ledger.has_active_group_blackjack(group)
):
return self.group_blackjack(row, action, 0, arguments)
if (
action == "start"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前已有Taffy的21点多人牌桌;"
"发送 /21点 状态 查看,或等待本桌结束"
)
if (
action == "end"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前是多人21点牌桌;轮到你时请 /21点 停牌,"
"主持人可 /21点 多人 取消"
)
else: else:
bet = 0 bet = 0
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET: if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
@@ -341,6 +384,451 @@ class PlayerGames:
) )
return "\n".join(lines) return "\n".join(lines)
def taffy_coins(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value):
return "用法:/Taffy金币 [人数]"
limit = int(value or 8)
if not 1 <= limit <= 20:
return "Taffy金币人数必须为 1~20"
stats = self.ledger.player_taffy_coin_stats(
str(row["group_fingerprint"]),
limit=limit,
)
if int(stats["records"]) == 0:
return "Taffy金币:当前群暂无已结算游戏记录"
net = int(stats["taffy_net"])
result = f"Taffy累计赢 {net} 金币" if net >= 0 else f"Taffy累计倒贴 {-net} 金币"
lines = [
(
f"Taffy金币:已结算 {stats['records']} 局;"
f"玩家总下注 {stats['stake']},总返还 {stats['payout']};"
f"{result}"
)
]
if stats["players"]:
lines.append("最大上贡者排行(含负数):")
else:
lines.append("最大上贡者排行:暂无已结算玩家明细")
for index, record in enumerate(stats["players"], start=1):
player_net = int(record["taffy_net"])
if player_net > 0:
verdict = f"上贡 {player_net}"
elif player_net < 0:
verdict = f"从Taffy赢走 {-player_net}"
else:
verdict = "打平 0"
lines.append(
f"{index}. {record['display_name']}:{verdict};"
f"下注 {record['stake']},返还 {record['payout']},"
f"{record['records']} 局"
)
return "\n".join(lines)
@staticmethod
def parse_blackjack_table_command(
arguments: str,
) -> tuple[str, int] | None:
compacted = re.sub(r"\s+", "", arguments.casefold())
if not compacted:
return None
if compacted in {"多人", "牌桌", "table", "multiplayer"}:
return "help", 0
if compacted in {
"多人启动",
"多人开桌",
"多人创建",
"牌桌启动",
"牌桌开桌",
"tablestart",
"multiplayerstart",
}:
return "start", 0
if compacted in {"加入", "join"}:
return "join", 0
if compacted in {
"多人开始",
"多人发牌",
"开始多人",
"开桌",
"发牌",
"tabledeal",
"multiplayerdeal",
}:
return "begin", 0
if compacted in {
"多人取消",
"牌桌取消",
"取消牌桌",
"tablecancel",
"multiplayercancel",
}:
return "cancel", 0
bet = BLACKJACK_TABLE_BET.fullmatch(compacted)
if bet is not None:
return "bet", int(bet["bet"])
return None
@staticmethod
def blackjack_players_text(players: list[dict[str, Any]]) -> str:
names = [str(player["display_name"]) for player in players]
return "、".join(names) if names else "暂无"
@staticmethod
def blackjack_hand_text(hand: list[int]) -> str:
if not hand:
return "未发牌"
return (
f"{' '.join(card_label(card) for card in hand)}"
f"({hand_value(hand)}点)"
)
@staticmethod
def blackjack_prompt(player: dict[str, Any]) -> str:
return (
f"请{player['display_name']} 放自己的筹码\n"
"输入/21点 放200(1~10000)"
)
def group_blackjack(
self,
row: dict[str, Any],
action: str,
amount: int,
arguments: str,
) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
command = f"blackjack-table:{arguments}"
if action == "help":
return (
"多人21点用法:/21点 多人 启动;/21点 加入;"
"主持人 /21点 多人 开始;按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌 或 /21点 停牌"
)
if action == "start":
return self.ledger.start_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_start,
)
if action == "join":
return self.ledger.join_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_join,
)
if action == "begin":
return self.ledger.begin_group_blackjack_betting(
group,
sender,
event_id,
command,
self.render_blackjack_table_begin,
)
if action == "cancel":
return self.ledger.cancel_group_blackjack(
group,
sender,
event_id,
command,
self.render_blackjack_table_cancel,
)
if action == "status":
return self.ledger.group_blackjack_status(
group,
sender,
event_id,
command,
self.render_blackjack_table_status,
)
if action == "bet":
if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET:
return "21点多人牌桌筹码必须为 1~10000 的整数金币"
deck = list(range(52))
self.rng.shuffle(deck)
return self.ledger.place_group_blackjack_bet(
group,
sender,
event_id,
command,
amount,
deck,
self.render_blackjack_table_bet,
)
if action in {"hit", "stand"}:
return self.ledger.play_group_blackjack(
group,
sender,
event_id,
command,
action,
self.render_blackjack_table_action,
)
raise ValueError(f"unsupported blackjack table action: {action}")
def render_blackjack_table_start(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
players = data.get("players", [])
prefix = (
"当前已有Taffy 的黑杰克Blackjack 21点牌桌"
if status == "already_open"
else "欢迎来到Taffy 的黑杰克Blackjack 21点牌桌"
)
return (
f"{prefix}\n"
"输入/21点 加入 加入当前游戏!"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"主持人发送 /21点 多人 开始 后,"
"Taffy会按顺序喊每个人放筹码"
)
def render_blackjack_table_join(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "closed":
return "这桌已经停止加入;发送 /21点 状态 查看当前进度"
if status == "full":
return f"这桌已满,最多 {data['max_players']} 人"
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
if status == "already_joined":
return (
f"{data['display_name']} 已经在这桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
f"{data['display_name']} 已加入Taffy的21点牌桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
def render_blackjack_table_begin(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以结束加入"
if status == "not_joining":
return "这桌已经结束加入;发送 /21点 状态 查看当前进度"
if status == "not_enough":
return (
"至少需要 2 名玩家才能开始多人21点;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
"加入结束,Taffy 按顺序收筹码。\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
def render_blackjack_table_status(self, data: dict[str, Any]) -> str:
if data["status"] == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
state = str(data["state"])
table_number = int(data["table_number"])
players = data["players"]
if state == "joining":
return (
f"21点牌桌第 {table_number} 桌正在加入;"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"输入/21点 加入 加入当前游戏;"
"主持人 /21点 多人 开始"
)
if state == "betting":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
lines = [f"21点牌桌第 {table_number} 桌正在下注:"]
for player in players:
bet = int(player["bet"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{bet if bet else '未放'}"
)
if prompt is not None:
lines.append(self.blackjack_prompt(prompt))
return "\n".join(lines)
if state == "playing":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
f"21点牌桌第 {table_number} 桌正在出牌;Taffy {dealer_text}"
]
for player in players:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']};{player['state']}"
)
if prompt is not None:
lines.append(
f"请{prompt['display_name']} 操作:"
"/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
return "当前没有进行中的21点多人牌桌"
def render_blackjack_table_bet(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_betting":
return "现在不是放筹码阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
return self.blackjack_prompt(data["prompt_player"])
if status == "insufficient":
return (
f"{data['display_name']}:金币不足,余额 {data['balance']},"
f"本次需要 {data['bet']}"
)
if status == "bet_accepted":
return (
f"{data['display_name']} 放下 {data['bet']};"
f"金币 {data['balance']}\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
if status == "dealt":
return self.render_blackjack_table_dealt(data)
if status == "settled":
return self.render_blackjack_table_settlement(data)
raise ValueError(f"unsupported blackjack bet status: {status}")
def render_blackjack_table_dealt(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
(
f"{data['display_name']} 放下 {data['bet']};"
"全部筹码已确认。Taffy 发牌。"
),
f"Taffy 明牌 {dealer_text}",
]
for player in data["players"]:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']}"
)
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_action(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_playing":
return "现在不是出牌阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
prompt = data["prompt_player"]
return (
f"现在轮到 {prompt['display_name']};"
"/21点 要牌 或 /21点 停牌"
)
if status == "not_actionable":
return "你这一手已经结束;等待本桌结算"
if status == "settled":
return self.render_blackjack_table_settlement(data)
player = data["player"]
lines: list[str] = []
if status == "hit":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"当前 {self.blackjack_hand_text(player['hand'])}"
)
elif status == "busted":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"{self.blackjack_hand_text(player['hand'])} 爆牌"
)
elif status == "stood":
lines.append(
f"{player['display_name']} 停牌;"
f"{self.blackjack_hand_text(player['hand'])}"
)
else:
raise ValueError(f"unsupported blackjack action status: {status}")
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_settlement(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = self.blackjack_hand_text(dealer)
lines = [
(
"Taffy 的黑杰克Blackjack 21点牌桌"
f"第 {data['table_number']} 桌结算:"
),
f"Taffy 庄家 {dealer_text}",
"这一桌每个人的变化:",
]
for player in data["players"]:
bet = int(player["bet"])
payout = int(player["payout"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{player['outcome']};牌 {self.blackjack_hand_text(player['hand'])};"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};"
f"金币 {player['balance']}"
)
return "\n".join(lines)
def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以取消"
return (
f"21点牌桌第 {data['table_number']} 桌已取消;"
f"{data['refunded']} 人的下注已退回"
)
@staticmethod @staticmethod
def parse_transfer( def parse_transfer(
arguments: str, arguments: str,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "1.0.0b1+1016" version = "1.0.2b1+1016"
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__ = "1.0.0b1+1016" __version__ = "1.0.2b1+1016"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "beta 1.0.0.1016" release = "beta 1.0.2.1016"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
+2 -2
View File
@@ -867,9 +867,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local") self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"]) self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"]) self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 1.0.0.1016", version["text"]) self.assertIn("最新版本:beta 1.0.2.1016", version["text"])
self.assertIn("更新时间:2026-10-16", version["text"]) self.assertIn("更新时间:2026-10-16", version["text"])
self.assertIn("更新摘要:Promote JWeBot", version["text"]) self.assertIn("更新摘要:Cache OCR-negative", version["text"])
self.assertEqual(debug["route"], "local") self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"]) self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local") self.assertEqual(balance["route"], "local")
@@ -92,6 +92,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"coin": JWEBOT_AUTHOR, "coin": JWEBOT_AUTHOR,
"coin-transfer": JWEBOT_AUTHOR, "coin-transfer": JWEBOT_AUTHOR,
"coin-ranking": JWEBOT_AUTHOR, "coin-ranking": JWEBOT_AUTHOR,
"coin-taffy": JWEBOT_AUTHOR,
"coin-loan": JWEBOT_AUTHOR, "coin-loan": JWEBOT_AUTHOR,
"random-number": JWEBOT_AUTHOR, "random-number": JWEBOT_AUTHOR,
"draw-lot": JWEBOT_AUTHOR, "draw-lot": JWEBOT_AUTHOR,
+73
View File
@@ -176,6 +176,56 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("下注 500,返还 500", history) self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501")) self.assertIn("1~500", self.command("/21点开始501"))
def test_multiplayer_blackjack_table_guides_turns_and_settles(self):
self.assertIn("黑杰克Blackjack 21点牌桌", self.command("/21点 多人 启动"))
self.assertIn("目前玩家:alice", self.command("/21点 状态"))
self.assertIn("bob 已加入", self.command("/21点 加入", sender="Bob"))
self.assertIn("请alice 放自己的筹码", self.command("/21点 多人 开始"))
alice_bet = self.command("/21点 放200", event_id="alice-table-bet")
replay = self.command(
"/21点 放200",
sender="a1",
event_id="alice-table-bet",
)
self.assertEqual(replay, alice_bet)
self.assertIn("请bob 放自己的筹码", alice_bet)
self.assertEqual(self.state()["balance"], 800)
def table_deck(cards):
first = [12, 8, 9, 24, 19, 32, 25]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=table_deck,
):
dealt = self.command("/21点 放100", sender="Bob")
self.assertIn("全部筹码已确认", dealt)
self.assertIn("alice #1:K Q(20点);下注 200", dealt)
self.assertIn("bob #1:9 7(16点);下注 100", dealt)
self.assertIn("请alice 操作", dealt)
self.assertEqual(self.state("Bob")["balance"], 900)
alice_stand = self.command("/21点 停牌")
self.assertIn("alice 停牌", alice_stand)
self.assertIn("请bob 操作", alice_stand)
settled = self.command("/21点 要牌", sender="Bob")
self.assertIn("这一桌每个人的变化", settled)
self.assertIn("alice #1:你赢了", settled)
self.assertIn("下注 200,返还 400,净变化 +200;金币 1200", settled)
self.assertIn("bob #1:你输了", settled)
self.assertIn("下注 100,返还 0,净变化 -100;金币 900", settled)
self.assertEqual(self.state()["balance"], 1200)
self.assertEqual(self.state("Bob")["balance"], 900)
self.assertIn("alice #1:21点 你赢了", self.command("/游戏记录"))
self.assertIn(
"bob #1:21点 你输了",
self.command("/游戏记录", sender="Bob"),
)
def test_prize_draw_has_indexed_history(self): def test_prize_draw_has_indexed_history(self):
with mock.patch.object( with mock.patch.object(
self.engine.local.players.rng, self.engine.local.players.rng,
@@ -262,6 +312,29 @@ class Release063GameTests(unittest.TestCase):
self.assertNotIn("Dave", ranking) self.assertNotIn("Dave", ranking)
self.assertIn("金币排行人数必须为 1~20", self.command("/金币排行 0")) self.assertIn("金币排行人数必须为 1~20", self.command("/金币排行 0"))
def test_taffy_coin_stats_sums_all_game_history(self):
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
self.command("/抽奖10")
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=0,
):
self.command("/抽奖10", sender="Bob")
stats = self.command("/Taffy金币")
self.assertIn("玩家总下注 20,总返还 50", stats)
self.assertIn("Taffy累计倒贴 30 金币", stats)
self.assertIn("最大上贡者排行(含负数):", stats)
self.assertIn("alice:上贡 10", stats)
self.assertIn("bob:从Taffy赢走 40", stats)
self.assertIn("Taffy金币人数必须为 1~20", self.command("/Taffy金币 0"))
def test_open_round_and_history_survive_restart(self): def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name) root = Path(self.temp.name)
self.command("/彩票开始") self.command("/彩票开始")
+99
View File
@@ -4,6 +4,7 @@ import importlib.util
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import types
import unittest import unittest
from contextlib import nullcontext from contextlib import nullcontext
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -517,6 +518,64 @@ class GuiCoordinationTests(unittest.TestCase):
class OcrFallbackTests(unittest.TestCase): class OcrFallbackTests(unittest.TestCase):
def test_ocr_engine_limits_onnxruntime_threads_by_default(self):
captured = {}
class FakeRapidOCR:
def __init__(self, **kwargs):
captured.update(kwargs)
fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR)
fake_numpy = types.SimpleNamespace(array=lambda value: value)
with mock.patch.dict(
sys.modules,
{"rapidocr": fake_module, "numpy": fake_numpy},
), mock.patch.dict(
MODULE.os.environ,
{
"WECHAT_OCR_ONNX_INTRA_OP_THREADS": "",
"WECHAT_OCR_ONNX_INTER_OP_THREADS": "",
},
):
MODULE.OcrEngine()
self.assertEqual(
captured["params"],
{
"EngineConfig.onnxruntime.intra_op_num_threads": 1,
"EngineConfig.onnxruntime.inter_op_num_threads": 1,
},
)
def test_ocr_engine_allows_thread_limit_overrides(self):
captured = {}
class FakeRapidOCR:
def __init__(self, **kwargs):
captured.update(kwargs)
fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR)
fake_numpy = types.SimpleNamespace(array=lambda value: value)
with mock.patch.dict(
sys.modules,
{"rapidocr": fake_module, "numpy": fake_numpy},
), mock.patch.dict(
MODULE.os.environ,
{
"WECHAT_OCR_ONNX_INTRA_OP_THREADS": "2",
"WECHAT_OCR_ONNX_INTER_OP_THREADS": "3",
},
):
MODULE.OcrEngine()
self.assertEqual(
captured["params"],
{
"EngineConfig.onnxruntime.intra_op_num_threads": 2,
"EngineConfig.onnxruntime.inter_op_num_threads": 3,
},
)
def test_low_confidence_line_uses_detection_fallback(self): def test_low_confidence_line_uses_detection_fallback(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
bubble = MODULE.Bubble(50, 50, 132, 68, 5000) bubble = MODULE.Bubble(50, 50, 132, 68, 5000)
@@ -630,6 +689,46 @@ class OcrCacheTests(unittest.TestCase):
self.assertEqual(engine.last_cache_hits, 0) self.assertEqual(engine.last_cache_hits, 0)
self.assertEqual(engine.last_cache_misses, 1) self.assertEqual(engine.last_cache_misses, 1)
def test_antialias_drift_reuses_visual_signature_cache(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
bubble = MODULE.Bubble(50, 60, 132, 40, 5000)
first = MODULE.Image.new("RGB", (300, 180), (238, 238, 240))
second = MODULE.Image.new("RGB", (300, 180), (242, 242, 244))
first.putpixel((70, 75), (20, 20, 20))
second.putpixel((70, 75), (35, 35, 35))
with mock.patch.object(
engine,
"_recognize_bubble",
return_value=self.message(60),
) as recognize:
engine.recognize_messages(first, [bubble])
messages, _ = engine.recognize_messages(second, [bubble])
self.assertEqual(recognize.call_count, 1)
self.assertEqual(len(messages), 1)
self.assertEqual(engine.last_cache_hits, 1)
self.assertEqual(engine.last_cache_misses, 0)
def test_unrecognized_bubble_reuses_negative_cache(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
image = MODULE.Image.new("RGB", (300, 180), "white")
bubble = MODULE.Bubble(50, 60, 132, 40, 5000)
with mock.patch.object(
engine,
"_recognize_bubble",
return_value=None,
) as recognize:
first_messages, _ = engine.recognize_messages(image, [bubble])
second_messages, _ = engine.recognize_messages(image, [bubble])
self.assertEqual(first_messages, [])
self.assertEqual(second_messages, [])
self.assertEqual(recognize.call_count, 1)
self.assertEqual(engine.last_cache_hits, 1)
self.assertEqual(engine.last_cache_misses, 0)
def test_periodic_refresh_revalidates_cached_bubbles(self): def test_periodic_refresh_revalidates_cached_bubbles(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
image = MODULE.Image.new("RGB", (300, 180), "white") image = MODULE.Image.new("RGB", (300, 180), "white")
+36 -6
View File
@@ -67,6 +67,8 @@ DAMAGE_HEARTBEAT_SECONDS = 2.0
CAPTURE_BUFFER_FRAMES = 12 CAPTURE_BUFFER_FRAMES = 12
OCR_MESSAGE_CACHE_SIZE = 256 OCR_MESSAGE_CACHE_SIZE = 256
OCR_CACHE_REFRESH_SCANS = 120 OCR_CACHE_REFRESH_SCANS = 120
DEFAULT_OCR_ONNX_INTRA_OP_THREADS = 1
DEFAULT_OCR_ONNX_INTER_OP_THREADS = 1
WINDOW_LAYOUT_SETTLE_SECONDS = 0.25 WINDOW_LAYOUT_SETTLE_SECONDS = 0.25
FOLLOW_LATEST_SETTLE_SECONDS = 0.20 FOLLOW_LATEST_SETTLE_SECONDS = 0.20
FOLLOW_LATEST_MOUSE_X = 1432 FOLLOW_LATEST_MOUSE_X = 1432
@@ -224,6 +226,17 @@ def normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value).strip() return re.sub(r"\s+", " ", value).strip()
def env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
logging.warning("invalid integer environment value name=%s", name)
return default
def bubble_sequence_signature( def bubble_sequence_signature(
sender_crop: Image.Image, sender_crop: Image.Image,
body_crop: Image.Image, body_crop: Image.Image,
@@ -1099,10 +1112,21 @@ class OcrEngine:
from rapidocr import RapidOCR from rapidocr import RapidOCR
self._numpy = np self._numpy = np
self._engine = RapidOCR() self._engine = RapidOCR(
params={
"EngineConfig.onnxruntime.intra_op_num_threads": env_int(
"WECHAT_OCR_ONNX_INTRA_OP_THREADS",
DEFAULT_OCR_ONNX_INTRA_OP_THREADS,
),
"EngineConfig.onnxruntime.inter_op_num_threads": env_int(
"WECHAT_OCR_ONNX_INTER_OP_THREADS",
DEFAULT_OCR_ONNX_INTER_OP_THREADS,
),
}
)
self._message_cache: OrderedDict[ self._message_cache: OrderedDict[
str, str,
tuple[str, str, float, float, float], tuple[str, str, float, float, float] | None,
] = OrderedDict() ] = OrderedDict()
self._scan_count = 0 self._scan_count = 0
self.last_cache_hits = 0 self.last_cache_hits = 0
@@ -1178,7 +1202,7 @@ class OcrEngine:
def _cache(self) -> OrderedDict[ def _cache(self) -> OrderedDict[
str, str,
tuple[str, str, float, float, float], tuple[str, str, float, float, float] | None,
]: ]:
cache = getattr(self, "_message_cache", None) cache = getattr(self, "_message_cache", None)
if cache is None: if cache is None:
@@ -1258,15 +1282,17 @@ class OcrEngine:
bubble_crop = image.crop( bubble_crop = image.crop(
(bubble.x, bubble.y, bubble.right, bubble.bottom) (bubble.x, bubble.y, bubble.right, bubble.bottom)
) )
key = self._cache_key(sender_crop, bubble_crop)
sequence_signature = bubble_sequence_signature( sequence_signature = bubble_sequence_signature(
sender_crop, sender_crop,
bubble_crop, bubble_crop,
) )
cached = cache.get(key) key = sequence_signature
if cached is not None: if key in cache:
cached = cache[key]
hits += 1 hits += 1
cache.move_to_end(key) cache.move_to_end(key)
if cached is None:
continue
messages.append( messages.append(
IncomingMessage( IncomingMessage(
sender=cached[0], sender=cached[0],
@@ -1287,6 +1313,10 @@ class OcrEngine:
visual_signature=sequence_signature, visual_signature=sequence_signature,
) )
if message is None: if message is None:
cache[key] = None
cache.move_to_end(key)
while len(cache) > OCR_MESSAGE_CACHE_SIZE:
cache.popitem(last=False)
continue continue
cache[key] = ( cache[key] = (
message.sender, message.sender,