diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efdc86..e4f813d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## alpha 0.6.1.1015 + +- Add three reviewed plugins: local context summaries for 100/200/300 messages, + person and alias lookup, and single-player virtual coin games. +- Provide corresponding Chinese and English commands, including `/context`, + `/people`, `/person`, `/wallet`, `/draw`, `/lottery`, and `/blackjack`. +- Grant each group-scoped person 1000 virtual coins once; persist account state, + private blackjack decks, timeout/forfeit rules, and win/loss statistics. +- Commit funds, game state and event replay responses in one SQLite transaction; + retries never debit twice, and aliases share the canonical person's account. +- Reconcile the message image after a two-second idle wait so a missed final + XDamage notification does not strand an isolated message; drain buffered Xlib + events before waiting on the socket. Preserve visual deduplication and guards. +- Keep the generated feature list within the sender's 900-character boundary. +- Document local extractive-summary coverage, OCR identity limitations, + game payouts, persistence and rollback behavior. + ## alpha 0.6.0.1015 - Replace repository-local and hardcoded deployment values with independent, diff --git a/README.md b/README.md index 4c4ec7d..7f9ca70 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,11 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`alpha 0.6.0.1015`。 +当前版本:`alpha 0.6.1.1015`。 + +0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 +两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 +[0.6.1 插件说明](docs/local-plugins-0.6.1.md)。 ### 运行流程 @@ -72,6 +76,12 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh ```text /功能列表 +/上下文 [100|200|300](/context) +/人物列表 [页码](/people);/人物 [名称或别名](/person) +/金币(/wallet);/抽奖 [金额](/draw) +/买彩票 [000~999] [金额](/lottery) +/21点 开始|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end) +/游戏帮助(/games) /丢骰子 [NdM] /猜数字 开始 [上限] /石头剪刀布 [开始|加入|出|状态|结算|结束] @@ -240,7 +250,7 @@ visually confirmed GUI bridge. Current version: ```text -alpha 0.6.0.1015 +alpha 0.6.1.1015 ``` ## Runtime Flow @@ -337,6 +347,14 @@ the final separator shown below: /steam时长Dota2人=玩家名 ``` +Version 0.6.1 adds local `/context 100|200|300` extractive summaries, +`/people` and `/person` lookup, and `/wallet`, `/draw`, `/lottery`, `/blackjack` +single-player virtual coin games. Chinese aliases share the same implementations. +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). + Steam prefers private per-person SteamID mappings from the active profile and uses the corresponding group display IDs and aliases. Without a private roster, it discovers the configured account's public friend list at runtime. No real diff --git a/VERSION b/VERSION index c458399..3a14eb6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -alpha 0.6.0.1015 +alpha 0.6.1.1015 diff --git a/docs/command-routing.md b/docs/command-routing.md index dfd91b9..1c3fab8 100644 --- a/docs/command-routing.md +++ b/docs/command-routing.md @@ -25,7 +25,8 @@ error. They never fall through to DeepSeek or OpenClaw. The local command path is: -1. XDamage reports a relevant message-region change. +1. XDamage reports a relevant message-region change, or a two-second idle + reconciliation capture detects a missed final drawing. 2. OpenCV segments incoming bubbles without running OCR over the whole screen. 3. The OCR engine reuses exact visual-fingerprint matches from a bounded LRU. 4. Only new or changed sender/body crops use RapidOCR. @@ -45,6 +46,10 @@ because each bubble position still produces its own `IncomingMessage`. Changed pixels force fresh OCR. The cache is bounded and periodically revalidated. +For the three local plugins added in `alpha 0.6.1.1015`, including bilingual +commands, person identity limitations and atomic single-player game settlement, +see [local plugin rules](local-plugins-0.6.1.md). + ## Bundled Plugin Registration Bundled command metadata has one source: diff --git a/docs/local-plugins-0.6.1.md b/docs/local-plugins-0.6.1.md new file mode 100644 index 0000000..e4262d5 --- /dev/null +++ b/docs/local-plugins-0.6.1.md @@ -0,0 +1,99 @@ +# alpha 0.6.1.1015 本地插件与消息抓取 + +本版本增加三个插件。全部由本机命令引擎执行,不调用 DeepSeek 或 +OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,OCR 丢失 +命令与参数间的空格时仍可解析。正常使用时仍需 @ 当前配置的机器人。 + +| 插件 | 中文指令 | 英文指令 | +|---|---|---| +| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` | +| 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 hit/stand/status/end` | +| single-player-games | `/游戏帮助` | `/games` | +| person-directory | `/人物列表 [页码]`、`/别名列表 [页码]` | `/people [页码]` | +| person-directory | `/人物 [名称或别名]`、`/个人属性 [名称或别名]` | `/person [名称或别名]` | + +命令所有权来自 `descriptors.py`,由同步工具生成三套受审核 SDK adapter。 +每个 adapter 只提交命名 service-profile 请求,由 Kernel 执行数据库操作。 +注册在启动期完成,运行期冻结。`/余额` 保持查询提供商余额; +游戏虚拟余额使用 `/金币`。 + +## 上下文总结 + +默认选择最近 100 条,也可选择 200 或 300。查询限定当前群,排除当前 +请求事件,按观察时间和事件 ID 选出最近记录,再恢复时间顺序。 +不足指定数量时明确报告实际数量;没有记录时返回空历史提示。 + +总结采用本地抽取式方法:统计参与人数、时间范围、重复话题,选出五条 +具有代表性的内容并按时间排列。它保留原话,不生成未经原文支持的决定 +或待办。覆盖范围是本机已经成功记录的文字消息,不包含图片、语音、 +未加载的微信历史或本机没观察到的消息。所有历史只保存在本机私有状态。 +独立的 `context_history` 保留短消息,避免复读排行的长度过滤影响最近 +100/200/300 条计数;启动时从现有复读历史补入旧记录,查询时从 OCR +历史导入已识别发送人的文字。 + +## 人物与账户索引 + +当前 OCR 输入只有显示名,没有微信原生账号 ID。Kernel 将昵称或别名 +解析到私有配置中的 canonical_name,再以“当前群+规范人物名”计算 +person_key。相同人物的已配置别名共用金币和牌局,其他群使用独立账户。 +没有配置的显示名使用独立临时身份;未识别到发送人的消息不能操作账户。 + +`/人物` 默认查看本人;指定参数时只查询已配置人物。输出包括展示名、 +截短的群内索引、别名和经过安全过滤的基本属性,不返回 SteamID 或密钥。 +`/人物列表` 每页五人。真实人物资料仍只存放于 `~/.config/jwebot/`, +仓库没有生产人物或映射文件。 + +昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域; +应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户, +不能充值、转账、赠币或兑换真实资产。 + +## 单人游戏管理 + +每个群内人物首次使用账户时一次性获得 1000 虚拟金币。再次查询、 +重启服务或切换已配置别名都不会重复领取。下注为 2~100 的整数, +余额不足或参数非法不扣款。 + +抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 +彩票默认随机三位号码、下注 10;可指定 000~999,命中返还五百倍。 +所有返还都包含本金,回复同时报告下注、返还、净变化和余额。 + +21 点默认下注 50。牌局使用洗牌后的单副 52 张牌,牌堆、双方手牌、 +下注和最后操作时间持久化。A 可作为 1 或 11;庄家软 17 停牌。 +开始后只公开一张庄家牌;要牌、停牌、认输和查询都仅针对本人牌局。 +自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, +平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 +抽奖、彩票或开局。 + +十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态 +不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自 +独立玩,不能加入或结束你的牌局。旧的群游戏保持独立。 + +Kernel 用一个 `BEGIN IMMEDIATE` 事务同时保存: + +1. `player_accounts` 中的金币、牌局和胜负统计; +2. `player_actions` 中的事件、命令及响应。 + +同一群中的同一 event_id 只结算一次,重试返回原响应。相同事件若变更 +人物或命令则拒绝。事务失败全部回滚,避免扣款后丢失结果。新的独立 +指令会创建新的游戏操作,即使文字相同也不会被当成重试。 +数据库迁移仅新增表,旧版本可以忽略它们;运行文件回滚不会主动删除 +游戏数据。使用协调部署器回滚整库时,以部署前已验证备份为恢复基线。 + +## 单条消息抓取修复 + +独立观察证据表明,新气泡完成绘制后有时没有对应的有效 XDamage +事件;先前截图仍是旧布局,后续消息的绘制才唤醒生产抓取。 + +保留 XDamage 快速路径,同时将无有效变化时的复核等待窗口缩短到两秒。 +空闲超时后走相同截图、跟随最新消息、视觉哈希和缓冲去重流程。 +画面没变化时不进入重复 OCR;最终绘制漏事件时也能补抓。等待 X socket +前先检查 Xlib 已缓冲事件,避免用户态已有事件却阻塞等待另一次通知。 + +新增 `source=reconcile` 遥测用于识别补抓路径。两秒是等待窗口, +不是端到端响应承诺;截图、OCR、生成和 GUI 投递仍有各自耗时。 +短周期复核增加空闲截图开销,上线后需观测实际负载。 +群指纹、窗口就绪、GUI 串行、视觉确认和投递去重规则保持有效。 diff --git a/extensions/jwebot/single-player-games/README.md b/extensions/jwebot/single-player-games/README.md new file mode 100644 index 0000000..aa18e1b --- /dev/null +++ b/extensions/jwebot/single-player-games/README.md @@ -0,0 +1,29 @@ +# single-player-games + +Author: `jwebot` + +本人虚拟金币账户、抽奖、彩票和单人21点 + +Help: + +- `/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery` +- `/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end` +- `/游戏帮助;/games` + +Registered commands: + +- `/金币` +- `/wallet` +- `/抽奖` +- `/draw` +- `/买彩票` +- `/lottery` +- `/21点` +- `/二十一点` +- `/blackjack` +- `/游戏帮助` +- `/games` + +The adapter has no direct Ledger, GUI, environment, shell, or network access. +It delegates to the reviewed `single-player-games` Kernel service profile +and returns a structured `ReplyPlan`. diff --git a/extensions/jwebot/single-player-games/plugin.toml b/extensions/jwebot/single-player-games/plugin.toml new file mode 100644 index 0000000..a92ce33 --- /dev/null +++ b/extensions/jwebot/single-player-games/plugin.toml @@ -0,0 +1,14 @@ +id = "single-player-games" +author = "jwebot" +description = "本人虚拟金币账户、抽奖、彩票和单人21点" +help = ["/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", "/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end", "/游戏帮助;/games"] +version = "1.0.0" +api_version = "1" +entrypoint = "single_player_games:plugin" +type = "command" + +[capabilities] +commands = ["/金币", "/wallet", "/抽奖", "/draw", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏帮助", "/games"] +storage = false +network_profiles = [] +service_profiles = ["single-player-games"] diff --git a/extensions/jwebot/single-player-games/src/single_player_games.py b/extensions/jwebot/single-player-games/src/single_player_games.py new file mode 100644 index 0000000..29ac9b0 --- /dev/null +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -0,0 +1,37 @@ +"""Generated reviewed adapter for single-player-games.""" + +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') +COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') + + +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(), + }, + ) + 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)) diff --git a/extensions/jwebot/single-player-games/tests/test_plugin.py b/extensions/jwebot/single-player-games/tests/test_plugin.py new file mode 100644 index 0000000..5eae541 --- /dev/null +++ b/extensions/jwebot/single-player-games/tests/test_plugin.py @@ -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 single_player_games 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, '本人虚拟金币账户、抽奖、彩票和单人21点') + self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games')) + self.assertIn('/金币', registered) + self.assertEqual(len(registered), 11) + + +if __name__ == "__main__": + unittest.main() diff --git a/extensions/official/context-summary/README.md b/extensions/official/context-summary/README.md new file mode 100644 index 0000000..ee08aab --- /dev/null +++ b/extensions/official/context-summary/README.md @@ -0,0 +1,18 @@ +# context-summary + +Author: `official` + +提取当前群最近100、200或300条本地文字的上下文 + +Help: + +- `/上下文 [100|200|300];/context [100|200|300]` + +Registered commands: + +- `/上下文` +- `/context` + +The adapter has no direct Ledger, GUI, environment, shell, or network access. +It delegates to the reviewed `context-summary` Kernel service profile +and returns a structured `ReplyPlan`. diff --git a/extensions/official/context-summary/plugin.toml b/extensions/official/context-summary/plugin.toml new file mode 100644 index 0000000..5cd899f --- /dev/null +++ b/extensions/official/context-summary/plugin.toml @@ -0,0 +1,14 @@ +id = "context-summary" +author = "official" +description = "提取当前群最近100、200或300条本地文字的上下文" +help = ["/上下文 [100|200|300];/context [100|200|300]"] +version = "1.0.0" +api_version = "1" +entrypoint = "context_summary:plugin" +type = "command" + +[capabilities] +commands = ["/上下文", "/context"] +storage = false +network_profiles = [] +service_profiles = ["context-summary"] diff --git a/extensions/official/context-summary/src/context_summary.py b/extensions/official/context-summary/src/context_summary.py new file mode 100644 index 0000000..bc95305 --- /dev/null +++ b/extensions/official/context-summary/src/context_summary.py @@ -0,0 +1,37 @@ +"""Generated reviewed adapter for context-summary.""" + +from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext + + +PROFILE = 'context-summary' +AUTHOR = 'official' +DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' +HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) +COMMANDS = ('/上下文', '/context') + + +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(), + }, + ) + 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)) diff --git a/extensions/official/context-summary/tests/test_plugin.py b/extensions/official/context-summary/tests/test_plugin.py new file mode 100644 index 0000000..21d9bc0 --- /dev/null +++ b/extensions/official/context-summary/tests/test_plugin.py @@ -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 context_summary 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, 'official') + self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文') + self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',)) + self.assertIn('/上下文', registered) + self.assertEqual(len(registered), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/extensions/official/person-directory/README.md b/extensions/official/person-directory/README.md new file mode 100644 index 0000000..da453f7 --- /dev/null +++ b/extensions/official/person-directory/README.md @@ -0,0 +1,22 @@ +# person-directory + +Author: `official` + +查询当前群已配置人物、基本属性和别名 + +Help: + +- `/人物列表 [页码] /people;/人物 [名称或别名] /person` + +Registered commands: + +- `/人物列表` +- `/别名列表` +- `/people` +- `/人物` +- `/个人属性` +- `/person` + +The adapter has no direct Ledger, GUI, environment, shell, or network access. +It delegates to the reviewed `person-directory` Kernel service profile +and returns a structured `ReplyPlan`. diff --git a/extensions/official/person-directory/plugin.toml b/extensions/official/person-directory/plugin.toml new file mode 100644 index 0000000..37c24e1 --- /dev/null +++ b/extensions/official/person-directory/plugin.toml @@ -0,0 +1,14 @@ +id = "person-directory" +author = "official" +description = "查询当前群已配置人物、基本属性和别名" +help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"] +version = "1.0.0" +api_version = "1" +entrypoint = "person_directory:plugin" +type = "command" + +[capabilities] +commands = ["/人物列表", "/别名列表", "/people", "/人物", "/个人属性", "/person"] +storage = false +network_profiles = [] +service_profiles = ["person-directory"] diff --git a/extensions/official/person-directory/src/person_directory.py b/extensions/official/person-directory/src/person_directory.py new file mode 100644 index 0000000..e7681d9 --- /dev/null +++ b/extensions/official/person-directory/src/person_directory.py @@ -0,0 +1,37 @@ +"""Generated reviewed adapter for person-directory.""" + +from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext + + +PROFILE = 'person-directory' +AUTHOR = 'official' +DESCRIPTION = '查询当前群已配置人物、基本属性和别名' +HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',) +COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person') + + +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(), + }, + ) + 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)) diff --git a/extensions/official/person-directory/tests/test_plugin.py b/extensions/official/person-directory/tests/test_plugin.py new file mode 100644 index 0000000..68a64aa --- /dev/null +++ b/extensions/official/person-directory/tests/test_plugin.py @@ -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 person_directory 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, 'official') + self.assertEqual(DESCRIPTION, '查询当前群已配置人物、基本属性和别名') + self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)) + self.assertIn('/人物列表', registered) + self.assertEqual(len(registered), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index 8f140ec..aa9ddbf 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.0a0+1015" +version = "0.6.1a0+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 689baef..21aacd0 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.0a0+1015" +__version__ = "0.6.1a0+1015" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index f65a6b3..6b48868 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.0a0+1015" +version = "0.6.1a0+1015" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.0a0+1015", - "jwebot-kernel==0.6.0a0+1015", + "jwebot-sdk==0.6.1a0+1015", + "jwebot-kernel==0.6.1a0+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 9f08f88..f19aab8 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.0a0+1015" +__version__ = "0.6.1a0+1015" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 7c17442..29af93f 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.0a0+1015" +version = "0.6.1a0+1015" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==0.6.0a0+1015", - "jwebot-sdk==0.6.0a0+1015", - "jwebot-official-extensions==0.6.0a0+1015", + "jwebot-config==0.6.1a0+1015", + "jwebot-sdk==0.6.1a0+1015", + "jwebot-official-extensions==0.6.1a0+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 4a8363a..a3c5570 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.0a0+1015" +__version__ = "0.6.1a0+1015" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index 2f919f7..ce8e9b3 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -941,6 +941,34 @@ class Ledger: observed_at TEXT NOT NULL, created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS context_history ( + event_id TEXT PRIMARY KEY, + group_fingerprint TEXT NOT NULL, + display_name TEXT NOT NULL, + text TEXT NOT NULL, + observed_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS context_history_group_time + ON context_history(group_fingerprint, observed_at, event_id); + INSERT OR IGNORE INTO context_history + SELECT event_id, group_fingerprint, display_name, text, observed_at + FROM repeat_history; + CREATE TABLE IF NOT EXISTS player_accounts ( + group_fingerprint TEXT NOT NULL, + person_key TEXT NOT NULL, + state_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (group_fingerprint, person_key) + ); + CREATE TABLE IF NOT EXISTS player_actions ( + group_fingerprint TEXT NOT NULL, + event_id TEXT NOT NULL, + person_key TEXT NOT NULL, + command TEXT NOT NULL, + response_text TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (group_fingerprint, event_id) + ); CREATE INDEX IF NOT EXISTS events_group_created ON events(group_fingerprint, created_at); CREATE INDEX IF NOT EXISTS outbox_due @@ -2713,14 +2741,20 @@ class Ledger: ) -> bool: content = bounded_text(text, limit=1_500) repeat_key = repeat_text_key(content) + if not event_id or not group_fingerprint or not content: + return False + person = self.resolve_person(group_fingerprint, sender) + with self.lock: + self.connection.execute( + "INSERT OR IGNORE INTO context_history VALUES (?, ?, ?, ?, ?)", + (event_id, group_fingerprint, person_display_label(person["display_name"]), + content, observed_at), + ) if ( - not event_id - or not group_fingerprint - or not repeat_key + not repeat_key or len(repeat_key) < 2 ): return False - person = self.resolve_person(group_fingerprint, sender) with self.lock, self.connection: cursor = self.connection.execute( """ @@ -2846,6 +2880,102 @@ class Ledger: ).fetchall() return [dict(row) for row in rows] + def recent_context_messages( + self, group: str, limit: int, *, exclude_event_id: str = "", + ) -> list[dict[str, Any]]: + if limit not in {100, 200, 300}: + raise LocalCommandError("上下文数量只支持 100、200、300") + with self.lock: + rows = self.connection.execute( + """ + SELECT event_id, display_name, text, observed_at + FROM context_history + WHERE group_fingerprint = ? AND event_id != ? + ORDER BY observed_at DESC, event_id DESC LIMIT ? + """, (group, exclude_event_id, limit), + ).fetchall() + return [dict(row) for row in reversed(rows)] + + def person_directory(self, group: str) -> list[dict[str, Any]]: + self.seed_person_aliases(group) + with self.lock: + rows = self.connection.execute( + """ + SELECT canonical_name, alias_key, match_kind FROM person_aliases + WHERE group_fingerprint = ? + ORDER BY canonical_name, match_kind, alias_key + """, (group,), + ).fetchall() + people: dict[str, dict[str, Any]] = {} + for row in rows: + name = str(row["canonical_name"]) + person = people.setdefault(name, { + "display_name": person_display_label(name), + "person_key": person_scope_key(group, name), + "aliases": [], "prefixes": [], + }) + person["prefixes" if row["match_kind"] == "prefix" else "aliases"].append( + str(row["alias_key"])) + # Include profiles with a canonical name even when no alias was configured. + for template in PERSON_ALIAS_TEMPLATES: + name = str(template["canonical_name"]) + people.setdefault(name, { + "display_name": person_display_label(name), + "person_key": person_scope_key(group, name), + "aliases": [], "prefixes": [], + }) + return list(people.values()) + + def apply_player_action( + self, group: str, sender: str, event_id: str, command: str, + action: Callable[[dict[str, Any]], str], + ) -> str: + """Commit identity-scoped funds, game state and replay response together.""" + if not group or not event_id or not sender or sender == "unknown": + raise LocalCommandError("无法确认本人身份,不能操作金币账户") + person = self.resolve_person(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + previous = self.connection.execute( + """SELECT person_key, command, response_text FROM player_actions + WHERE group_fingerprint = ? AND event_id = ?""", + (group, event_id), + ).fetchone() + if previous is not None: + if previous["person_key"] != person_key or previous["command"] != command: + raise LocalCommandError("同一事件不能变更账户或游戏指令") + self.connection.execute("COMMIT") + return str(previous["response_text"]) + stored = self.connection.execute( + """SELECT state_json FROM player_accounts + WHERE group_fingerprint = ? AND person_key = ?""", + (group, person_key), + ).fetchone() + account = json.loads(stored["state_json"]) if stored else { + "balance": 1000, "game": None, "wins": 0, "losses": 0, "ties": 0, + } + response = action(account) + if type(account["balance"]) is not int or not 0 <= account["balance"] <= 1_000_000_000: + raise LocalCommandError("金币结算超出允许范围") + timestamp = utc_now() + self.connection.execute( + """INSERT INTO player_accounts VALUES (?, ?, ?, ?) + ON CONFLICT(group_fingerprint, person_key) DO UPDATE + SET state_json = excluded.state_json, updated_at = excluded.updated_at""", + (group, person_key, json.dumps(account, ensure_ascii=False), timestamp), + ) + self.connection.execute( + "INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)", + (group, event_id, person_key, command, response, timestamp), + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + def status(self) -> dict[str, Any]: with self.lock: events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0] diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index a48743e..c3c153f 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.0a0+1015" +version = "0.6.1a0+1015" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.0a0+1015", + "jwebot-sdk==0.6.1a0+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 d2eb661..244c06c 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.0a0+1015" +__version__ = "0.6.1a0+1015" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py new file mode 100644 index 0000000..bc95305 --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py @@ -0,0 +1,37 @@ +"""Generated reviewed adapter for context-summary.""" + +from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext + + +PROFILE = 'context-summary' +AUTHOR = 'official' +DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' +HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) +COMMANDS = ('/上下文', '/context') + + +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(), + }, + ) + 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)) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py new file mode 100644 index 0000000..e7681d9 --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py @@ -0,0 +1,37 @@ +"""Generated reviewed adapter for person-directory.""" + +from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext + + +PROFILE = 'person-directory' +AUTHOR = 'official' +DESCRIPTION = '查询当前群已配置人物、基本属性和别名' +HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',) +COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person') + + +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(), + }, + ) + 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)) 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 new file mode 100644 index 0000000..29ac9b0 --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py @@ -0,0 +1,37 @@ +"""Generated reviewed adapter for single-player-games.""" + +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') +COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') + + +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(), + }, + ) + 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)) 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 c47aa57..3cbd23c 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -38,6 +38,25 @@ LOCAL_COMMAND_ALIASES = { "debug": "telemetry_debug", "余额": "deepseek_balance", "steam": "steam_friends", + "上下文": "context_summary", + "context": "context_summary", + "金币": "wallet", + "wallet": "wallet", + "抽奖": "prize_draw", + "draw": "prize_draw", + "买彩票": "lottery", + "lottery": "lottery", + "21点": "blackjack", + "二十一点": "blackjack", + "blackjack": "blackjack", + "游戏帮助": "game_rules", + "games": "game_rules", + "人物列表": "person_directory", + "别名列表": "person_directory", + "people": "person_directory", + "人物": "person_info", + "个人属性": "person_info", + "person": "person_info", } @@ -91,6 +110,31 @@ DIRECT_KERNEL_COMMAND_KINDS = frozenset( ) LOCAL_EXTENSIONS = ( + LocalExtensionDescriptor( + extension_id="context-summary", + author=OFFICIAL_AUTHOR, + description="提取当前群最近100、200或300条本地文字的上下文", + help_entries=("/上下文 [100|200|300];/context [100|200|300]",), + command_kinds=("context_summary",), + ), + LocalExtensionDescriptor( + extension_id="single-player-games", + author=JWEBOT_AUTHOR, + description="本人虚拟金币账户、抽奖、彩票和单人21点", + help_entries=( + "/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", + "/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end", + "/游戏帮助;/games", + ), + command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"), + ), + LocalExtensionDescriptor( + extension_id="person-directory", + author=OFFICIAL_AUTHOR, + description="查询当前群已配置人物、基本属性和别名", + help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",), + command_kinds=("person_directory", "person_info"), + ), LocalExtensionDescriptor( extension_id="person-memory", author=OFFICIAL_AUTHOR, @@ -237,7 +281,6 @@ def render_feature_list() -> str: ) for descriptor in LOCAL_EXTENSIONS: lines.append( - f"[{descriptor.author}/{descriptor.extension_id}] " f"{descriptor.description}:" + ";".join(descriptor.help_entries) ) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py index ec1cc7d..d9e93e6 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py @@ -9,11 +9,13 @@ import json import re import subprocess import time +from collections import Counter from datetime import datetime, timezone from typing import Any, Callable from zoneinfo import ZoneInfo from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list +from .player_games import PlayerGames LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60 @@ -104,6 +106,7 @@ class LocalCommandEngine: self.person_display_label = person_display_label self.profile_fact_is_safe = profile_fact_is_safe self.profile_expiry = profile_expiry + self.players = PlayerGames(ledger, person_display_label) def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str: question = str(row["question"]) @@ -112,6 +115,12 @@ class LocalCommandEngine: raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能") if kind == "feature_list": return self.reply_cleaner(self.feature_list()) + if kind == "context_summary": + return self.reply_cleaner(self.context_summary(row, arguments)) + if kind in {"person_directory", "person_info"}: + return self.reply_cleaner(self.person_index(row, kind, arguments)) + if kind in {"wallet", "prize_draw", "lottery", "blackjack", "game_rules"}: + return self.reply_cleaner(self.players.handle(row, kind, arguments)) if kind == "dice": return self.reply_cleaner(self.dice(row, arguments)) if kind == "dice_challenge": @@ -229,6 +238,82 @@ class LocalCommandEngine: ) return f"本地记录命中“{query}”:{listing}" + def context_summary(self, row: dict[str, Any], arguments: str) -> str: + arguments = arguments.strip() + if arguments.strip() not in {"", "100", "200", "300"}: + return "用法:/上下文 100|200|300(/context),默认100条" + limit = int(arguments or 100) + self.ledger.bootstrap_repeat_history() + records = self.ledger.recent_context_messages( + str(row["group_fingerprint"]), limit, + exclude_event_id=str(row["event_id"])) + if not records: + return "当前群还没有可总结的本地文字记录" + # Local extractive summary: rank representative messages, retaining chronology. + stop = {"这个", "那个", "就是", "然后", "现在", "我们", "你们", "可以", + "不是", "一个", "什么", "怎么", "感觉", "还是", "已经", "没有", + "the", "and", "you", "that", "this", "jwebot"} + texts = [re.sub(r"^@\S+\s+", "", normalize_text(record["text"])) for record in records] + tokens = [set(re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}|[\u4e00-\u9fff]{2}", text.casefold())) - stop + for text in texts] + frequency = Counter(token for message in tokens for token in message) + topics = [token for token, count in frequency.most_common(6) if count > 1] + scored = sorted(range(len(records)), key=lambda i: ( + -sum(frequency[token] for token in tokens[i]), + -int(bool(re.search(r"决定|计划|问题|需要|建议|修复|明天|[??]", texts[i]))), + -i)) + selected: list[int] = [] + seen: set[str] = set() + for index in scored: + if texts[index] in seen or texts[index].lstrip().startswith("/"): + continue + selected.append(index) + seen.add(texts[index]) + if len(selected) == 5: + break + if not selected: + selected = list(range(max(0, len(records) - 3), len(records))) + people = len({record["display_name"] for record in records}) + lines = [f"上下文总结(本地提取):请求最近{limit}条,实际{len(records)}条,{people}人。", + f"范围 {records[0]['observed_at'][:16]} ~ {records[-1]['observed_at'][:16]}。", + "主要话题:" + ("、".join(topics) if topics else "话题分散,以下为代表性内容。")] + for index in sorted(selected): + record = records[index] + lines.append(f"• {record['display_name']}:{texts[index][:100]}") + lines.append("仅覆盖本机已记录的当前群文字;问题或计划按原话提取。") + return "\n".join(lines) + + def person_index(self, row: dict[str, Any], kind: str, arguments: str) -> str: + group = str(row["group_fingerprint"]) + directory = self.ledger.person_directory(group) + if kind == "person_directory": + if arguments and not re.fullmatch(r"[1-9]\d{0,2}", arguments): + return "用法:/人物列表 [页码](/people),每页5人" + page = int(arguments or 1) + pages = max(1, (len(directory) + 4) // 5) + if page > pages: + return f"只有 {pages} 页;/人物列表 1" + lines = [f"当前群人物与别名({page}/{pages},共{len(directory)}人)"] + for person in directory[(page - 1) * 5:page * 5]: + aliases = "、".join(person["aliases"])[:80] or "未配置" + prefixes = "、".join(person["prefixes"])[:40] + lines.append(f"{person['display_name']}:{aliases}" + (f";前缀 {prefixes}*" if prefixes else "")) + lines.append("/人物 [名称或别名](/person)查看基本属性;/人物列表 页码 翻页") + return "\n".join(lines) + requested = normalize_text(arguments) or str(row["sender"]) + person = self.ledger.resolve_person(group, requested) + entry = next((item for item in directory if item["person_key"] == person["person_key"]), None) + if arguments and entry is None: + return "当前群没有这个已配置人物或别名;/人物列表 查看索引" + facts = str(person.get("identity_fact", "")) + if facts and not self.profile_fact_is_safe(facts): + facts = "" + aliases = "、".join(entry["aliases"])[:160] if entry else "未配置" + return (f"人物:{self.person_display_label(person['display_name'])};" + f"群内索引 {person['person_key'][:12]};别名:{aliases or '未配置'}。\n" + f"基本属性:{facts or '暂无配置'}。\n" + "本人金币 /金币;个人记忆 /我的记忆。身份由本地昵称映射解析,无法读取微信原生账号ID。") + @staticmethod def service_state(service_name: str) -> str: try: 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 new file mode 100644 index 0000000..2f89a95 --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py @@ -0,0 +1,180 @@ +"""Single-player virtual currency rules, invoked by the reviewed Kernel service.""" + +from __future__ import annotations + +import random +import re +import time +from typing import Any + + +INITIAL_COINS = 1000 +MAX_BET = 100 +GAME_TIMEOUT_SECONDS = 600 + + +def card_label(card: int) -> str: + return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13] + + +def hand_value(cards: list[int]) -> int: + total = sum(min(card % 13 + 1, 10) if card % 13 else 11 for card in cards) + aces = sum(card % 13 == 0 for card in cards) + while total > 21 and aces: + total -= 10 + aces -= 1 + return total + + +class PlayerGames: + def __init__(self, ledger: Any, display_label: Any) -> None: + self.ledger = ledger + self.display_label = display_label + self.rng = random.SystemRandom() + + @staticmethod + def rules() -> str: + return ( + "单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n" + "/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;" + "/21点 开始 [金额] /blackjack start。\n" + "21点:/21点 要牌|停牌|状态|结束(hit|stand|status|end);" + "每人最多一局,10分钟无操作视为认输,结束不退下注。\n" + "抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;" + "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" + "以上均含本金;虚拟金币不能充值、转账或兑换。" + ) + + def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str: + arguments = arguments.strip() + if kind == "game_rules": + return self.rules() + if arguments.casefold() in {"帮助", "规则", "help", "rules"}: + return self.rules() + if kind == "wallet" and arguments: + return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则" + if kind == "prize_draw": + if arguments and not re.fullmatch(r"\d{1,3}", arguments): + return "用法:/抽奖 [2~100金币](/draw),默认10金币" + bet = int(arguments or 10) + elif kind == "lottery": + match = re.fullmatch(r"(?:(\d{3})(?:\s+(\d{1,3}))?)?", arguments) + if match is None: + return "用法:/买彩票 [三位号码000~999] [2~100金币](/lottery),默认随机号码、10金币" + 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 + else: + bet = 0 + else: + bet = 0 + if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start": + if not 2 <= bet <= MAX_BET: + return "下注必须为 2~100 的整数金币" + + def execute(account: dict[str, Any]) -> str: + expired = "" + game = account.get("game") + if game and time.time() - float(game["updated_at"]) >= GAME_TIMEOUT_SECONDS: + account["game"] = None + account["losses"] += 1 + expired = "上一局已超时,按认输结束;" + if kind == "wallet": + state = "进行中" if account.get("game") else "无进行中的牌局" + return (f"{expired}金币 {account['balance']};21点 {state};" + 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) + if account.get("game"): + return expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束" + if account["balance"] < bet: + return expired + f"金币不足:余额 {account['balance']},本次需要 {bet}" + account["balance"] -= bet + if kind == "blackjack": + deck = list(range(52)) + self.rng.shuffle(deck) + game = {"bet": bet, "deck": deck[4:], "player": deck[:2], + "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) + if kind == "prize_draw": + roll = self.rng.randrange(100) + payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0) + result = "大奖" if roll < 5 else "小奖" if roll < 25 else "未中奖" + else: + selected = self.rng.randrange(1000) if number is None else number + winning = self.rng.randrange(1000) + payout = bet * 500 if selected == winning else 0 + result = f"号码 {selected:03d};开奖号 {winning:03d};" + ("中奖" if payout else "未中奖") + account["balance"] += payout + account["wins" if payout > bet else "losses"] += 1 + return expired + f"{result};下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}" + + return self.ledger.apply_player_action( + str(row["group_fingerprint"]), str(row["sender"]), + str(row["event_id"]), kind + ":" + arguments, execute) + + @staticmethod + def show_hand(game: dict[str, Any]) -> str: + player = " ".join(card_label(card) for card in game["player"]) + return (f"21点:你的牌 {player}({hand_value(game['player'])}点);" + f"庄家 {card_label(game['dealer'][0])} + 暗牌;下注 {game['bet']}。\n" + "/21点 要牌(hit)或 /21点 停牌(stand)") + + def play_blackjack(self, account: dict[str, Any], action: str) -> str: + game = account.get("game") + if not game: + return "本人没有进行中的21点;/21点 开始 [金额](/blackjack start)" + if action == "status": + return self.show_hand(game) + if action == "end": + account["game"] = None + account["losses"] += 1 + return f"本人的21点已认输结束,不退下注;金币 {account['balance']}" + game["updated_at"] = time.time() + if action == "hit": + game["player"].append(game["deck"].pop()) + if hand_value(game["player"]) > 21: + return self.finish_blackjack(account) + return self.show_hand(game) + while hand_value(game["dealer"]) < 17: + game["dealer"].append(game["deck"].pop()) + return self.finish_blackjack(account) + + @staticmethod + def finish_blackjack(account: dict[str, Any], *, natural: bool = False) -> str: + game = account["game"] + player = hand_value(game["player"]) + dealer = hand_value(game["dealer"]) + bet = game["bet"] + if player > 21 or natural and dealer == 21 and player != 21: + outcome, payout, stat = "你输了", 0, "losses" + elif player == dealer: + outcome, payout, stat = "平局", bet, "ties" + elif natural and player == 21: + outcome, payout, stat = "自然21点获胜", bet * 5 // 2, "wins" + elif dealer > 21 or player > dealer: + outcome, payout, stat = "你赢了", bet * 2, "wins" + else: + outcome, payout, stat = "你输了", 0, "losses" + account["balance"] += payout + account[stat] += 1 + account["game"] = None + return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点)," + f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);" + f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}") diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index cd2c716..ef3e446 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.0a0+1015" +version = "0.6.1a0+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 ffd26fe..dd211c1 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.0a0+1015" +__version__ = "0.6.1a0+1015" diff --git a/pyproject.toml b/pyproject.toml index 4fd185f..7745d0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "alpha 0.6.0.1015" +release = "alpha 0.6.1.1015" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_capture_reconcile.py b/tests/test_capture_reconcile.py new file mode 100644 index 0000000..367fa96 --- /dev/null +++ b/tests/test_capture_reconcile.py @@ -0,0 +1,93 @@ +"""Lost final-draw events must not strand an isolated incoming message.""" + +import unittest +from unittest import mock + +from test_wechat_local_ocr import MODULE + + +class ReconcileTests(unittest.TestCase): + def test_final_drawing_without_damage_is_captured_and_duplicates_are_suppressed(self): + class Monitor: + window_id = 42 + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def wait_for_message_change(self): + return next(self.changes) + + changes = iter((True, False, False)) + + buffer = MODULE.CaptureFrameBuffer(5) + outcomes = [] + offer = buffer.offer + + def observe(frame): + outcome = offer(frame) + outcomes.append(outcome) + if len(outcomes) == 3: + buffer.close() + return outcome + + images = [MODULE.Image.new("RGB", (2, 2), color) for color in ("white", "white", "black")] + recorder = MODULE.Recorder.__new__(MODULE.Recorder) + with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \ + mock.patch.object(MODULE, "window_id", return_value="42"), \ + mock.patch.object(MODULE, "capture_image", side_effect=images) as captures, \ + mock.patch.object(MODULE, "message_view_at_loaded_bottom", return_value=True), \ + mock.patch.object(MODULE, "message_image", side_effect=lambda image: image), \ + mock.patch.object(MODULE, "incoming_frame_hashes", side_effect=[ + ("old-layout", "old-visual"), ("old-layout", "old-visual"), ("new-layout", "new-visual")]), \ + mock.patch.object(buffer, "offer", side_effect=observe), \ + mock.patch.object(MODULE, "telemetry_event") as telemetry: + recorder.capture_damage_frames(buffer) + self.assertEqual(captures.call_count, 3) + self.assertEqual(outcomes, ["enqueued", "duplicate", "enqueued"]) + self.assertEqual(buffer.get().visual_hash, "old-visual") + self.assertEqual(buffer.get().visual_hash, "new-visual") + self.assertEqual([call.kwargs["source"] for call in telemetry.call_args_list + if call.args == ("ocr.capture",)], ["damage", "reconcile", "reconcile"]) + self.assertLessEqual(MODULE.DAMAGE_HEARTBEAT_SECONDS, 2) + + def test_reconciliation_does_not_bypass_window_identity_check(self): + class Monitor: + window_id = 42 + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def wait_for_message_change(self): + return False + + buffer = MODULE.CaptureFrameBuffer(2) + recorder = MODULE.Recorder.__new__(MODULE.Recorder) + with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \ + mock.patch.object(MODULE, "window_id", return_value="43"), \ + mock.patch.object(MODULE, "capture_image") as capture: + recorder.capture_damage_frames(buffer) + capture.assert_not_called() + with self.assertRaises(MODULE.CaptureProducerError): + buffer.get() + + def test_xlib_buffered_event_is_drained_before_waiting_for_socket(self): + monitor = MODULE.XDamageMonitor.__new__(MODULE.XDamageMonitor) + monitor._display = object() + monitor._descriptor = 42 + monitor._x11 = mock.Mock() + monitor._x11.XPending.side_effect = [1, 0] + with mock.patch.object(monitor, "_drain_events", return_value=True) as drain, \ + mock.patch.object(MODULE.select, "select", return_value=([], [], [])) as select: + self.assertTrue(monitor.wait_for_message_change()) + drain.assert_called_once() + select.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jwebot_edge.py b/tests/test_jwebot_edge.py index dbbfb84..162c1bf 100644 --- a/tests/test_jwebot_edge.py +++ b/tests/test_jwebot_edge.py @@ -704,7 +704,7 @@ class LedgerAndApplicationTests(unittest.TestCase): self.assertEqual(response["route"], "local") self.assertEqual(response["execution_path"], "local-command") self.assertIn("由内核和插件描述自动生成", response["text"]) - self.assertIn("[jwebot/dice]", response["text"]) + self.assertIn("/丢骰子", response["text"]) self.assertIn("骰子和多人骰子挑战", response["text"]) self.assertIn("/steam", response["text"]) self.assertIn("[DeepSeek]", response["text"]) diff --git a/tests/test_official_extension_inventory.py b/tests/test_official_extension_inventory.py index d47d820..83b98ac 100644 --- a/tests/test_official_extension_inventory.py +++ b/tests/test_official_extension_inventory.py @@ -69,7 +69,6 @@ class LocalExtensionInventoryTests(unittest.TestCase): with self.subTest(extension=extension.extension_id): self.assertTrue(extension.description) self.assertTrue(extension.help_entries) - self.assertIn(extension.extension_id, help_text) self.assertIn(extension.description, help_text) for entry in extension.help_entries: self.assertIn(entry, help_text) @@ -81,6 +80,9 @@ class LocalExtensionInventoryTests(unittest.TestCase): for extension in LOCAL_EXTENSIONS }, { + "context-summary": OFFICIAL_AUTHOR, + "person-directory": OFFICIAL_AUTHOR, + "single-player-games": JWEBOT_AUTHOR, "person-memory": OFFICIAL_AUTHOR, "server-status": OFFICIAL_AUTHOR, "jwebot-version": OFFICIAL_AUTHOR, @@ -108,6 +110,8 @@ class LocalExtensionInventoryTests(unittest.TestCase): self.assertEqual( combined, { + "single-player-games": ("wallet", "prize_draw", "lottery", "blackjack", "game_rules"), + "person-directory": ("person_directory", "person_info"), "person-memory": ( "remember", "my_memory", diff --git a/tests/test_release_061.py b/tests/test_release_061.py new file mode 100644 index 0000000..d0fe2aa --- /dev/null +++ b/tests/test_release_061.py @@ -0,0 +1,212 @@ +"""Behavioral coverage for local context, identity and single-player funds.""" + +import json +import random +import tempfile +import threading +import unittest +from pathlib import Path +from unittest import mock + +from test_jwebot_edge import MODULE, config +from jwebot_official_extensions.local_commands import local_command_parts +from jwebot_official_extensions.player_games import hand_value + + +class Release061Tests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.engine = MODULE.EdgeApplication(config(self.root)) + self.local = self.engine.local + self.counter = 0 + + def tearDown(self): + self.engine.ledger.connection.close() + self.temp.cleanup() + + def command(self, question, *, sender="Alice", group="group-a", event_id=None): + self.counter += 1 + row = {"question": question, "sender": sender, "group_fingerprint": group, + "event_id": event_id or f"test-{self.counter}"} + return self.local.handle(row, {}) + + def state(self, sender="Alice", group="group-a"): + key = self.engine.ledger.resolve_person(group, sender)["person_key"] + row = self.engine.ledger.connection.execute( + "SELECT state_json FROM player_accounts WHERE group_fingerprint=? AND person_key=?", + (group, key)).fetchone() + return json.loads(row[0]) + + def test_aliases_are_corresponding_and_case_insensitive(self): + pairs = [("上下文", "Context"), ("金币", "wallet"), ("抽奖", "draw"), + ("买彩票", "lottery"), ("21点", "blackjack"), + ("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")] + for chinese, english in pairs: + self.assertEqual(local_command_parts("/" + chinese), local_command_parts("/" + english)) + self.assertEqual(local_command_parts("/Context200"), ("context_summary", "200")) + + def test_context_scope_count_order_and_current_request_exclusion(self): + ledger = self.engine.ledger + for i in range(305): + ledger.record_repeat_history(event_id=f"history-{i:04d}", group_fingerprint="group-a", + sender="Alice", text=f"项目计划讨论编号 {i}", + observed_at=f"2026-10-15T00:{i // 60:02d}:{i % 60:02d}+00:00") + ledger.record_repeat_history(event_id="private-other", group_fingerprint="group-b", + sender="Bob", text="隔离群秘密话题", observed_at="2026-10-15T01:00:00+00:00") + for count in (100, 200, 300): + rows = ledger.recent_context_messages("group-a", count, exclude_event_id="history-0304") + self.assertEqual(len(rows), count) + self.assertEqual(rows[-1]["event_id"], "history-0303") + self.assertLess(rows[0]["observed_at"], rows[-1]["observed_at"]) + with mock.patch.object(ledger, "bootstrap_repeat_history"): + result = self.command(f"/context {count}") + self.assertIn(f"实际{count}条", result) + self.assertNotIn("隔离群", result) + self.assertIn("用法", self.command("/context 101")) + + def test_person_lookup_uses_alias_but_does_not_expose_steam_ids(self): + self.assertIn("alice", self.command("/people")) + self.assertIn("Alice is a configured test profile", self.command("/person a1")) + self.assertIn("群内索引", self.command("/个人属性")) + self.assertIn("没有", self.command("/person unconfigured")) + self.assertIn("只有", self.command("/people 99")) + + def test_context_keeps_short_messages_omitted_from_repeat_statistics(self): + ledger = self.engine.ledger + self.assertFalse(ledger.record_repeat_history( + event_id="short", group_fingerprint="group-a", sender="Alice", + text="好", observed_at="2026-10-15T00:00:00+00:00")) + records = ledger.recent_context_messages("group-a", 100) + self.assertEqual([row["text"] for row in records], ["好"]) + self.assertEqual(ledger.search_repeat_history("group-a", "好"), []) + + def test_initial_funds_aliases_groups_and_replay(self): + self.assertIn("金币 1000", self.command("/wallet", event_id="wallet-1")) + with mock.patch.object(self.local.players.rng, "randrange", return_value=99): + first = self.command("/draw 10", event_id="draw-1") + replay = self.command("/draw 10", sender="a1", event_id="draw-1") + self.assertEqual(first, replay) + self.assertEqual(self.state("a1")["balance"], 990) + self.assertIn("金币 1000", self.command("/wallet", sender="Bob")) + self.assertIn("金币 1000", self.command("/wallet", group="group-b")) + self.engine.ledger.connection.close() + self.engine = MODULE.EdgeApplication(config(self.root)) + self.local = self.engine.local + self.assertIn("金币 990", self.command("/wallet", event_id="restart")) + + def test_concurrent_replay_debits_once_and_rolls_back_failure(self): + replies = [] + with mock.patch.object(self.local.players.rng, "randrange", return_value=99): + threads = [threading.Thread(target=lambda: replies.append( + self.command("/draw 100", event_id="concurrent"))) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(len(replies), 8) + self.assertEqual(len(set(replies)), 1) + self.assertEqual(self.state()["balance"], 900) + def broken(state): + state["balance"] = -1 + return "bad" + with self.assertRaises(MODULE.LocalCommandError): + self.engine.ledger.apply_player_action("group-a", "Alice", "broken", "test", broken) + self.assertEqual(self.state()["balance"], 900) + with self.assertRaises(MODULE.LocalCommandError): + self.command("/draw 100", sender="Bob", event_id="concurrent") + + def test_invalid_bets_insufficient_funds_and_lottery(self): + for question in ("/draw -1", "/draw 0", "/draw 101", "/lottery xyz", + "/blackjack start -1", "/blackjack hit 10"): + self.assertRegex(self.command(question), "用法|下注") + with mock.patch.object(self.local.players.rng, "randrange", return_value=99): + for _ in range(10): + self.command("/draw 100") + self.assertIn("金币不足", self.command("/draw 2")) + self.assertEqual(self.state()["balance"], 0) + with mock.patch.object(self.local.players.rng, "randrange", return_value=123): + reply = self.command("/买彩票 123 10", sender="Bob") + self.assertIn("返还 5000", reply) + self.assertEqual(self.state("Bob")["balance"], 5990) + + def test_blackjack_is_private_persistent_and_settles_exactly_once(self): + # Player 10+7, dealer 9+8: standing produces a tie. + def deck(cards): + ordered = [9, 6, 8, 7] + [card for card in cards if card not in {9, 6, 8, 7}] + cards[:] = ordered + with mock.patch.object(self.local.players.rng, "shuffle", side_effect=deck): + start = self.command("/21点 开始 50") + self.assertIn("暗牌", start) + self.assertEqual(self.state()["balance"], 950) + self.assertIn("没有进行中", self.command("/blackjack status", sender="Bob")) + self.assertIn("已有一局", self.command("/draw")) + self.engine.ledger.connection.close() + self.engine = MODULE.EdgeApplication(config(self.root)) + self.local = self.engine.local + settled = self.command("/blackjack stand", sender="a1", event_id="stand-1") + self.assertIn("平局", settled) + self.assertEqual(self.state()["balance"], 1000) + self.assertEqual(self.command("/blackjack stand", event_id="stand-1"), settled) + self.assertIsNone(self.state()["game"]) + self.assertEqual(self.state()["ties"], 1) + + def test_aces_natural_bust_timeout_and_end(self): + self.assertEqual(hand_value([0, 13, 9]), 12) + self.assertEqual(hand_value([0, 9]), 21) + def natural(cards): + first = [0, 9, 8, 7] + cards[:] = first + [card for card in cards if card not in first] + with mock.patch.object(self.local.players.rng, "shuffle", side_effect=natural): + reply = self.command("/blackjack start 50") + self.assertIn("自然21点获胜", reply) + self.assertEqual(self.state()["balance"], 1075) + self.local.players.rng = random.Random(4) + self.command("/blackjack start 50") + if self.state()["game"]: + with mock.patch("jwebot_official_extensions.player_games.time.time", return_value=10**10): + self.assertIn("超时", self.command("/wallet")) + self.assertIsNone(self.state()["game"]) + self.local.players.rng = random.Random(4) + self.command("/blackjack start 50") + if self.state()["game"]: + self.assertIn("不退下注", self.command("/blackjack end")) + self.assertIsNone(self.state()["game"]) + + def test_adapter_and_direct_paths_agree_for_new_plugins(self): + for index, question in enumerate(("/games", "/wallet", "/people", "/person a1", "/context 200")): + kind, _ = local_command_parts(question) + row = {"question": question, "sender": "Alice", "group_fingerprint": "group-a", + "event_id": f"adapter-{index}"} + with mock.patch.object(self.engine.ledger, "bootstrap_repeat_history"): + direct = self.local.handle(row, {}) + adapter = self.engine.extensions.handle(row, {}) + self.assertEqual(adapter, direct) + + def test_blackjack_bust_cannot_be_acted_on_by_another_player(self): + def bust_deck(cards): + first = [9, 22, 8, 7] + cards[:] = first + [card for card in cards if card not in first and card != 35] + [35] + with mock.patch.object(self.local.players.rng, "shuffle", side_effect=bust_deck): + self.command("/blackjack start 100") + self.assertIn("没有进行中", self.command("/blackjack hit", sender="Bob")) + self.assertEqual(len(self.state()["game"]["player"]), 2) + result = self.command("/blackjack hit", event_id="bust") + self.assertIn("你输了", result) + self.assertEqual(self.state()["balance"], 900) + self.assertIsNone(self.state()["game"]) + self.assertEqual(self.command("/blackjack hit", event_id="bust"), result) + self.assertEqual(self.state()["losses"], 1) + + def test_simultaneous_natural_blackjack_returns_stake(self): + def tied_deck(cards): + first = [0, 9, 13, 22] + cards[:] = first + [card for card in cards if card not in first] + with mock.patch.object(self.local.players.rng, "shuffle", side_effect=tied_deck): + self.assertIn("平局", self.command("/blackjack start 50")) + self.assertEqual(self.state()["balance"], 1000) + self.assertIsNone(self.state()["game"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/wechat_local_ocr.py b/wechat_local_ocr.py index 2ac011c..be3341d 100755 --- a/wechat_local_ocr.py +++ b/wechat_local_ocr.py @@ -63,7 +63,7 @@ POLL_SECONDS = 1.0 STABLE_FRAMES = 2 DAMAGE_DEBOUNCE_SECONDS = 0.35 DAMAGE_MAX_BURST_SECONDS = 1.0 -DAMAGE_HEARTBEAT_SECONDS = 60.0 +DAMAGE_HEARTBEAT_SECONDS = 2.0 CAPTURE_BUFFER_FRAMES = 12 OCR_MESSAGE_CACHE_SIZE = 256 OCR_CACHE_REFRESH_SCANS = 120 @@ -1060,7 +1060,11 @@ class XDamageMonitor: timeout = max(0.0, deadline - now) if timeout == 0: return first_relevant is not None - readable, _, _ = select.select([self._descriptor], [], [], timeout) + # Xlib may already hold events even when its socket is no longer readable. + if self._x11.XPending(self._display): + readable = [self._descriptor] + else: + readable, _, _ = select.select([self._descriptor], [], [], timeout) if not readable: return first_relevant is not None if not self._drain_events(): @@ -1728,10 +1732,8 @@ class Recorder: buffer.capacity, ) while not buffer.closed: - if not monitor.wait_for_message_change(): - if int(window_id()) != monitor.window_id: - raise RuntimeError("WeChat window changed") - continue + changed = monitor.wait_for_message_change() + source = "damage" if changed else "reconcile" identifier = window_id() if int(identifier) != monitor.window_id: raise RuntimeError("WeChat window changed") @@ -1746,7 +1748,7 @@ class Recorder: * 1000, outcome="captured", phase="initial_capture", - source="damage", + source=source, ) latest_synced = message_view_at_loaded_bottom(image) if not latest_synced: @@ -1757,7 +1759,7 @@ class Recorder: elapsed_ms=(time.monotonic() - follow_started) * 1000, outcome="followed" if followed else "lock_busy", phase="follow_latest", - source="damage", + source=source, ) if followed: recapture_started = time.monotonic() @@ -1770,7 +1772,7 @@ class Recorder: * 1000, outcome="captured", phase="recapture", - source="damage", + source=source, ) latest_synced = message_view_at_loaded_bottom(image) crop = message_image(image) @@ -1801,7 +1803,7 @@ class Recorder: elapsed_ms=(time.monotonic() - capture_started) * 1000, outcome=outcome, depth=stats["depth"], - source="damage", + source=source, ) if outcome == "closed": return