diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a90617..252d266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## alpha 0.6.3.1015 + +- Replace instant personal lottery tickets with one persistent multiplayer + lottery round per group: start, register/update, status, draw, and cancel. +- Treat each ticket as three distinct digits from 0-9. Sort digits before + storage so `347` and `743` represent the same selection. +- Accept spaced and compact OCR forms. Ticket stakes are 1-500 coins with a + default of 10 when omitted. +- Return 0%, 50%, 120%, or 300% of the stake for matching zero, one, two, or + three drawn digits. All settlement values remain integer coins. +- Commit entry updates, stake differences, all-player payouts, cancellation + refunds, and event replay responses in serialized SQLite transactions. +- Raise the blackjack stake range to 1-500 coins. +- Add per-game personal sequence numbers and `/游戏记录` (`/gamehistory`) for + prize draws, lottery rounds, and blackjack hands. +- Prefix game output with the configured player label and game number, such as + `Jacky #1:...`; aliases share the same counter and account. + ## alpha 0.6.2.1015 - Bound direct DeepSeek SSE reads to 30 seconds of total wall time in addition diff --git a/README.md b/README.md index 79cafe0..50328fb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`alpha 0.6.2.1015`。 +当前版本:`alpha 0.6.3.1015`。 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 @@ -17,6 +17,9 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 占满生成 worker;21 点开局金额明确作为本局下注,并兼容 OCR 丢失空格。 21 点牌面和结算以当前玩家展示名开头。快速 ACK、重试和严格顺序投递 规则保持不变。 +0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消; +抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。 +完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。 ### 运行流程 @@ -83,8 +86,9 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh /上下文 [100|200|300](/context) /人物列表 [页码](/people);/人物 [名称或别名](/person) /金币(/wallet);/抽奖 [金额](/draw) -/买彩票 [000~999] [金额](/lottery) -/21点 开始 [金额];开局后 /21点 要牌|停牌|状态|结束 +/彩票 开始|报名|当前状态|开奖|取消(/lottery) +/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 +/游戏记录 [页码](/gamehistory) /游戏帮助(/games) /丢骰子 [NdM] /猜数字 开始 [上限] @@ -254,7 +258,7 @@ visually confirmed GUI bridge. Current version: ```text -alpha 0.6.2.1015 +alpha 0.6.3.1015 ``` ## Runtime Flow @@ -364,6 +368,9 @@ unfinished upstream response cannot occupy both generation workers for about forms are accepted, while hit/stand remain explicit follow-up commands. Blackjack hand and settlement replies start with the current player's configured display label. ACKs, retries, and ordered final delivery remain unchanged. +Version 0.6.3 replaces instant lottery tickets with persistent multiplayer +group rounds, adds indexed personal game history, and raises blackjack stakes +to 1-500 coins. See [multiplayer lottery and game records](docs/group-lottery-0.6.3.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, diff --git a/VERSION b/VERSION index 13964d1..3a6b344 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -alpha 0.6.2.1015 +alpha 0.6.3.1015 diff --git a/docs/group-lottery-0.6.3.md b/docs/group-lottery-0.6.3.md new file mode 100644 index 0000000..1640453 --- /dev/null +++ b/docs/group-lottery-0.6.3.md @@ -0,0 +1,68 @@ +# alpha 0.6.3.1015 多人彩票与游戏记录 + +## 一把彩票怎么玩 + +1. 任意群友发送 `/彩票 开始`。发起人是本轮主持人。 +2. 每个人发送 `/彩票 报名 3 4 7 100`,表示选择 3、4、7,下注 100 + 金币。也可简写为 `/彩票347100`;省略金额时默认下注 10。 +3. 发送 `/彩票 当前状态` 查看本轮主持人、报名人数、每个人的号码和下注。 +4. 主持人发送 `/彩票 开奖`。机器人从 0~9 中抽取三个互不重复的数字, + 统一排序后结算。 +5. 主持人也可在开奖前发送 `/彩票 取消`,所有报名者全额退款。 + +命令关键词和数字之间的空格均可省略。`/买彩票` 和 `/lottery` 保留为 +对应别名。 + +## 选号与报名 + +- 每张票必须是三个互不重复的 0~9 数字。 +- 数字顺序不影响结果:`347`、`743` 和 `3 4 7` 都保存为 `3 4 7`。 +- 每人每把只有一张票。再次报名会更新号码和下注,不增加个人局号。 +- 更新到更高下注时只补扣差额;降低下注时退回差额。 +- 下注范围是 1~500,省略时为 10。 +- 没有进行中的彩票时不能报名;没有报名者时不能开奖。 +- 只有创建本轮的主持人可以开奖或取消,状态查询对群内所有人开放。 + +支持的报名示例: + +```text +/彩票 347 +/彩票 743 100 +/彩票 报名 3 4 7 100 +/彩票报名347金额100 +/彩票347100 +/lottery join 347 100 +``` + +## 返还规则 + +报名时先扣除下注。开奖号和玩家号码按集合比较,返还包含本金: + +| 命中数 | 返还 | +|---|---:| +| 0 个 | 0% | +| 1 个 | 50% | +| 2 个 | 120% | +| 3 个 | 300% | + +金币只使用整数;百分比计算的小数部分向下取整。例如下注 100,命中两个 +返还 120,净变化为 `+20`。 + +## 个人局号与记录 + +抽奖、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示 +`Jacky #1:...`,下一把显示 `Jacky #2:...`。配置别名解析到同一人物时, +共用同一账户、局号和历史。 + +发送 `/游戏记录 [页码]`、`/个人记录 [页码]` 或 +`/gamehistory [page]` 查看最近记录,每页五条。记录包含游戏、个人局号、 +结果、下注、返还和净变化,最多保留最近 50 条。 + +21 点下注范围同步调整为 1~500。`/21点 开始 100` 只表示本局下注 100; +发牌后再单独发送 `/21点 要牌` 或 `/21点 停牌`。 + +## 持久化与并发 + +彩票轮次、报名和结算保存在 Edge SQLite。报名/改注、所有玩家的开奖返还、 +取消退款和事件重放响应都在 `BEGIN IMMEDIATE` 事务中提交。重复事件不会 +重复扣款、发奖或增加局号;进程重启后未开奖轮次和个人记录仍然保留。 diff --git a/docs/local-plugins-0.6.1.md b/docs/local-plugins-0.6.1.md index decb99c..6ea50ce 100644 --- a/docs/local-plugins-0.6.1.md +++ b/docs/local-plugins-0.6.1.md @@ -9,9 +9,10 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写, | 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 | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` | | single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | | single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` | +| single-player-games | `/游戏记录 [页码]` | `/gamehistory [page]` | | single-player-games | `/游戏帮助` | `/games` | | person-directory | `/人物列表 [页码]`、`/别名列表 [页码]` | `/people [页码]` | | person-directory | `/人物 [名称或别名]`、`/个人属性 [名称或别名]` | `/person [名称或别名]` | @@ -51,27 +52,29 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使 应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户, 不能充值、转账、赠币或兑换真实资产。 -## 单人游戏管理 +## 虚拟金币与游戏管理 每个群内人物首次使用账户时一次性获得 1000 虚拟金币。再次查询、 -重启服务或切换已配置别名都不会重复领取。下注为 2~100 的整数, -余额不足或参数非法不扣款。 +重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和 +21 点下注为 1~500;余额不足或参数非法不扣款。 抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 -彩票默认随机三位号码、下注 10;可指定 000~999,命中返还五百倍。 -所有返还都包含本金,回复同时报告下注、返还、净变化和余额。 +alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关, +下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 +[多人彩票与游戏记录](group-lottery-0.6.3.md)。 -21 点默认下注 50。`/21点 开始 100` 表示拿 100 金币作为本局下注进场, +21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 金币作为本局下注进场, 等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌, 然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在 同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌, 牌堆、双方手牌、下注和最后操作时间持久化。A 可作为 1 或 11; 庄家软 17 停牌。开始后只公开一张庄家牌;要牌、停牌、认输和查询都 仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头, -例如 `jacky:你的牌 ...`;别名解析到同一人物时沿用相同展示名和账户。 +例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、 +账户、局号和游戏记录。 自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, 平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 -抽奖、彩票或开局。 +抽奖或 21 点开局,多人彩票报名仍可独立进行。 十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态 不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自 diff --git a/extensions/jwebot/single-player-games/README.md b/extensions/jwebot/single-player-games/README.md index c1189f9..8280e60 100644 --- a/extensions/jwebot/single-player-games/README.md +++ b/extensions/jwebot/single-player-games/README.md @@ -2,12 +2,13 @@ Author: `jwebot` -本人虚拟金币账户、抽奖、彩票和单人21点 +虚拟金币账户、抽奖、群内多人彩票和单人21点 Help: -- `/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery` -- `/21点 开始 [金额](金额=本局下注,默认50,空格可省略)` +- `/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory` +- `/彩票 开始|报名|当前状态|开奖|取消;/lottery` +- `/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)` - `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end` - `/游戏帮助;/games` @@ -17,11 +18,15 @@ Registered commands: - `/wallet` - `/抽奖` - `/draw` +- `/彩票` - `/买彩票` - `/lottery` - `/21点` - `/二十一点` - `/blackjack` +- `/游戏记录` +- `/个人记录` +- `/gamehistory` - `/游戏帮助` - `/games` diff --git a/extensions/jwebot/single-player-games/plugin.toml b/extensions/jwebot/single-player-games/plugin.toml index b155e44..4d0412f 100644 --- a/extensions/jwebot/single-player-games/plugin.toml +++ b/extensions/jwebot/single-player-games/plugin.toml @@ -1,14 +1,14 @@ id = "single-player-games" author = "jwebot" -description = "本人虚拟金币账户、抽奖、彩票和单人21点" -help = ["/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", "/21点 开始 [金额](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"] +description = "虚拟金币账户、抽奖、群内多人彩票和单人21点" +help = ["/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack 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"] +commands = ["/金币", "/wallet", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/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 index d9801c8..b5af704 100644 --- a/extensions/jwebot/single-player-games/src/single_player_games.py +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -5,9 +5,9 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' -DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点' -HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') -COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') +DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' +HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') +COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') def _handler(context: SingleGroupContext, command: str): diff --git a/extensions/jwebot/single-player-games/tests/test_plugin.py b/extensions/jwebot/single-player-games/tests/test_plugin.py index f9572da..013ad2a 100644 --- a/extensions/jwebot/single-player-games/tests/test_plugin.py +++ b/extensions/jwebot/single-player-games/tests/test_plugin.py @@ -19,10 +19,10 @@ class PluginTests(unittest.TestCase): 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点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')) + self.assertEqual(DESCRIPTION, '虚拟金币账户、抽奖、群内多人彩票和单人21点') + self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')) self.assertIn('/金币', registered) - self.assertEqual(len(registered), 11) + self.assertEqual(len(registered), 15) if __name__ == "__main__": diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index e60b462..c6dbe83 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.2a0+1015" +version = "0.6.3a0+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 475b38e..81f0acc 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.2a0+1015" +__version__ = "0.6.3a0+1015" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index e4f7441..d3c2445 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.2a0+1015" +version = "0.6.3a0+1015" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.2a0+1015", - "jwebot-kernel==0.6.2a0+1015", + "jwebot-sdk==0.6.3a0+1015", + "jwebot-kernel==0.6.3a0+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 ff190f2..e441828 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.2a0+1015" +__version__ = "0.6.3a0+1015" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 32e4300..e6c23b6 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.2a0+1015" +version = "0.6.3a0+1015" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==0.6.2a0+1015", - "jwebot-sdk==0.6.2a0+1015", - "jwebot-official-extensions==0.6.2a0+1015", + "jwebot-config==0.6.3a0+1015", + "jwebot-sdk==0.6.3a0+1015", + "jwebot-official-extensions==0.6.3a0+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 4a60599..c60bf2f 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.2a0+1015" +__version__ = "0.6.3a0+1015" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index bc2e267..c219be3 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -68,6 +68,11 @@ from jwebot_official_extensions.steam import ( SteamClient, SteamUnavailable, ) +from jwebot_official_extensions.player_games import ( + append_game_record, + next_game_index, + normalize_player_account, +) from jwebot_sdk import Event, PlanKind @@ -1026,6 +1031,45 @@ class Ledger: created_at TEXT NOT NULL, PRIMARY KEY (group_fingerprint, event_id) ); + CREATE TABLE IF NOT EXISTS group_lottery_rounds ( + group_fingerprint TEXT NOT NULL, + round_number INTEGER NOT NULL, + state TEXT NOT NULL, + host_person_key TEXT NOT NULL, + host_display_name TEXT NOT NULL, + drawn_numbers_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (group_fingerprint, round_number) + ); + CREATE TABLE IF NOT EXISTS group_lottery_entries ( + group_fingerprint TEXT NOT NULL, + round_number INTEGER NOT NULL, + person_key TEXT NOT NULL, + display_name TEXT NOT NULL, + player_game_index INTEGER NOT NULL, + numbers_json TEXT NOT NULL, + stake INTEGER NOT NULL, + matches INTEGER NOT NULL DEFAULT 0, + payout INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY ( + group_fingerprint, round_number, person_key + ), + FOREIGN KEY (group_fingerprint, round_number) + REFERENCES group_lottery_rounds ( + group_fingerprint, round_number + ) + ON DELETE CASCADE + ); + CREATE UNIQUE INDEX IF NOT EXISTS group_lottery_one_open + ON group_lottery_rounds(group_fingerprint) + WHERE state = 'open'; + CREATE INDEX IF NOT EXISTS group_lottery_entries_round + ON group_lottery_entries( + group_fingerprint, round_number, created_at, person_key + ); CREATE INDEX IF NOT EXISTS events_group_created ON events(group_fingerprint, created_at); CREATE INDEX IF NOT EXISTS outbox_due @@ -2983,49 +3027,606 @@ class Ledger: }) return list(people.values()) + def _player_identity(self, group: str, sender: str) -> dict[str, str]: + if not group or not sender or sender == "unknown": + raise LocalCommandError("无法确认本人身份,不能操作金币账户") + person = self.resolve_person(group, sender) + person["display_name"] = person_display_label(person["display_name"]) + return person + + def _player_action_replay( + self, + group: str, + event_id: str, + person_key: str, + command: str, + ) -> str | None: + 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 None: + return None + if previous["person_key"] != person_key or previous["command"] != command: + raise LocalCommandError("同一事件不能变更账户或游戏指令") + return str(previous["response_text"]) + + def _load_player_account( + self, + group: str, + person_key: str, + ) -> dict[str, Any]: + stored = self.connection.execute( + """ + SELECT state_json FROM player_accounts + WHERE group_fingerprint = ? AND person_key = ? + """, + (group, person_key), + ).fetchone() + if stored is None: + return normalize_player_account({}) + try: + account = json.loads(str(stored["state_json"])) + except json.JSONDecodeError as error: + raise LocalCommandError("金币账户状态无效") from error + if not isinstance(account, dict): + raise LocalCommandError("金币账户状态无效") + return normalize_player_account(account) + + def _save_player_account( + self, + group: str, + person_key: str, + account: dict[str, Any], + timestamp: str, + ) -> None: + normalize_player_account(account) + if ( + type(account["balance"]) is not int + or not 0 <= account["balance"] <= 1_000_000_000 + ): + raise LocalCommandError("金币结算超出允许范围") + 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, + ), + ) + + def _store_player_action( + self, + group: str, + event_id: str, + person_key: str, + command: str, + response: str, + timestamp: str, + ) -> None: + self.connection.execute( + "INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)", + (group, event_id, person_key, command, response, timestamp), + ) + + def _open_group_lottery(self, group: str) -> sqlite3.Row | None: + return self.connection.execute( + """ + SELECT * FROM group_lottery_rounds + WHERE group_fingerprint = ? AND state = 'open' + LIMIT 1 + """, + (group,), + ).fetchone() + + def _group_lottery_entries( + self, + group: str, + round_number: int, + ) -> list[dict[str, Any]]: + rows = self.connection.execute( + """ + SELECT * FROM group_lottery_entries + WHERE group_fingerprint = ? AND round_number = ? + ORDER BY created_at, person_key + """, + (group, round_number), + ).fetchall() + entries: list[dict[str, Any]] = [] + for row in rows: + entry = dict(row) + try: + numbers = json.loads(str(entry["numbers_json"])) + except json.JSONDecodeError: + numbers = [] + entry["numbers"] = [ + int(value) + for value in numbers + if type(value) is int and 0 <= value <= 9 + ] + entries.append(entry) + return entries + + def start_group_lottery( + self, + group: str, + sender: str, + event_id: str, + command: str, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_lottery(group) + status = "already_open" + if row is None: + round_number = int( + self.connection.execute( + """ + SELECT COALESCE(MAX(round_number), 0) + 1 + FROM group_lottery_rounds + WHERE group_fingerprint = ? + """, + (group,), + ).fetchone()[0] + ) + timestamp = utc_now() + self.connection.execute( + """ + INSERT INTO group_lottery_rounds ( + group_fingerprint, round_number, state, + host_person_key, host_display_name, + drawn_numbers_json, created_at, updated_at + ) VALUES (?, ?, 'open', ?, ?, '[]', ?, ?) + """, + ( + group, + round_number, + person_key, + person["display_name"], + timestamp, + timestamp, + ), + ) + row = self._open_group_lottery(group) + status = "started" + if row is None: + raise LocalCommandError("彩票创建失败") + data = dict(row) + data["status"] = status + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def group_lottery_status( + self, + group: str, + sender: str, + event_id: str, + command: str, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_lottery(group) + if row is None: + data: dict[str, Any] = { + "status": "no_round", + "entries": [], + } + else: + data = dict(row) + data["status"] = "open" + data["entries"] = self._group_lottery_entries( + group, int(row["round_number"]) + ) + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def register_group_lottery( + self, + group: str, + sender: str, + event_id: str, + command: str, + numbers: tuple[int, int, int], + stake: int, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_lottery(group) + if row is None: + data: dict[str, Any] = { + "status": "no_round", + "display_name": person["display_name"], + } + else: + round_number = int(row["round_number"]) + existing = self.connection.execute( + """ + SELECT * FROM group_lottery_entries + WHERE group_fingerprint = ? AND round_number = ? + AND person_key = ? + """, + (group, round_number, person_key), + ).fetchone() + account = self._load_player_account(group, person_key) + old_stake = int(existing["stake"]) if existing else 0 + delta = stake - old_stake + if delta > int(account["balance"]): + data = { + "status": "insufficient", + "display_name": person["display_name"], + "balance": int(account["balance"]), + } + else: + account["balance"] -= delta + index = ( + int(existing["player_game_index"]) + if existing + else next_game_index(account, "lottery") + ) + timestamp = utc_now() + self._save_player_account( + group, person_key, account, timestamp + ) + self.connection.execute( + """ + INSERT INTO group_lottery_entries ( + group_fingerprint, round_number, person_key, + display_name, player_game_index, numbers_json, + stake, matches, payout, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) + ON CONFLICT ( + group_fingerprint, round_number, person_key + ) DO UPDATE SET + display_name = excluded.display_name, + numbers_json = excluded.numbers_json, + stake = excluded.stake, + matches = 0, + payout = 0, + updated_at = excluded.updated_at + """, + ( + group, + round_number, + person_key, + person["display_name"], + index, + canonical_json(list(numbers)), + stake, + timestamp, + timestamp, + ), + ) + data = { + "status": "updated" if existing else "registered", + "display_name": person["display_name"], + "player_game_index": index, + "round_number": round_number, + "numbers": list(numbers), + "stake": stake, + "balance": int(account["balance"]), + } + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def draw_group_lottery( + self, + group: str, + sender: str, + event_id: str, + command: str, + drawn_numbers: tuple[int, int, int], + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + if ( + len(drawn_numbers) != 3 + or len(set(drawn_numbers)) != 3 + or any(type(value) is not int or not 0 <= value <= 9 for value in drawn_numbers) + ): + raise LocalCommandError("彩票开奖号无效") + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_lottery(group) + if row is None: + data: dict[str, Any] = {"status": "no_round"} + elif str(row["host_person_key"]) != person_key: + data = { + "status": "not_host", + "host_display_name": str(row["host_display_name"]), + } + else: + round_number = int(row["round_number"]) + entries = self._group_lottery_entries( + group, round_number + ) + if not entries: + data = { + "status": "empty", + "round_number": round_number, + } + else: + timestamp = utc_now() + settled: list[dict[str, Any]] = [] + drawn = set(drawn_numbers) + rates = {0: 0, 1: 50, 2: 120, 3: 300} + for entry in entries: + key = str(entry["person_key"]) + account = self._load_player_account(group, key) + matches = len(drawn.intersection(entry["numbers"])) + stake = int(entry["stake"]) + payout = stake * rates[matches] // 100 + account["balance"] += payout + account[ + "wins" if payout > stake else "losses" + ] += 1 + append_game_record( + account, + kind="lottery", + index=int(entry["player_game_index"]), + outcome=f"命中{matches}个", + stake=stake, + payout=payout, + summary=( + "选号 " + + " ".join(map(str, entry["numbers"])) + + ";开奖号 " + + " ".join(map(str, drawn_numbers)) + ), + ) + self._save_player_account( + group, key, account, timestamp + ) + self.connection.execute( + """ + UPDATE group_lottery_entries + SET matches = ?, payout = ?, updated_at = ? + WHERE group_fingerprint = ? + AND round_number = ? AND person_key = ? + """, + ( + matches, + payout, + timestamp, + group, + round_number, + key, + ), + ) + result = dict(entry) + result.update( + { + "matches": matches, + "payout": payout, + "balance": int(account["balance"]), + } + ) + settled.append(result) + self.connection.execute( + """ + UPDATE group_lottery_rounds + SET state = 'drawn', drawn_numbers_json = ?, + updated_at = ? + WHERE group_fingerprint = ? AND round_number = ? + """, + ( + canonical_json(list(drawn_numbers)), + timestamp, + group, + round_number, + ), + ) + data = { + "status": "drawn", + "round_number": round_number, + "drawn_numbers": list(drawn_numbers), + "entries": settled, + } + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def cancel_group_lottery( + self, + group: str, + sender: str, + event_id: str, + command: str, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_lottery(group) + if row is None: + data: dict[str, Any] = {"status": "no_round"} + elif str(row["host_person_key"]) != person_key: + data = { + "status": "not_host", + "host_display_name": str(row["host_display_name"]), + } + else: + round_number = int(row["round_number"]) + entries = self._group_lottery_entries( + group, round_number + ) + timestamp = utc_now() + for entry in entries: + key = str(entry["person_key"]) + stake = int(entry["stake"]) + account = self._load_player_account(group, key) + account["balance"] += stake + append_game_record( + account, + kind="lottery", + index=int(entry["player_game_index"]), + outcome="本轮取消", + stake=stake, + payout=stake, + summary="主持人取消,下注已全额退回", + ) + self._save_player_account( + group, key, account, timestamp + ) + self.connection.execute( + """ + UPDATE group_lottery_rounds + SET state = 'cancelled', updated_at = ? + WHERE group_fingerprint = ? AND round_number = ? + """, + (timestamp, group, round_number), + ) + data = { + "status": "cancelled", + "round_number": round_number, + "entries": entries, + } + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def player_game_history( + self, + group: str, + sender: str, + ) -> dict[str, Any]: + person = self._player_identity(group, sender) + with self.lock: + account = self._load_player_account( + group, str(person["person_key"]) + ) + history = sorted( + account["game_history"], + key=lambda entry: float(entry.get("completed_at", 0)), + reverse=True, + ) + return { + "display_name": person["display_name"], + "history": history, + "game_counts": dict(account["game_counts"]), + } + 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 = self._player_identity(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), + replay = self._player_action_replay( + group, event_id, person_key, command ) - self.connection.execute( - "INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)", - (group, event_id, person_key, command, response, timestamp), + if replay is not None: + self.connection.execute("COMMIT") + return replay + account = self._load_player_account(group, person_key) + response = action(account) + timestamp = utc_now() + self._save_player_account( + group, person_key, account, timestamp + ) + self._store_player_action( + group, + event_id, + person_key, + command, + response, + timestamp, ) self.connection.execute("COMMIT") return response diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index 5ed0b89..11e0c3c 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.2a0+1015" +version = "0.6.3a0+1015" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.2a0+1015", + "jwebot-sdk==0.6.3a0+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 79843da..ec7b657 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.2a0+1015" +__version__ = "0.6.3a0+1015" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py index d9801c8..b5af704 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py @@ -5,9 +5,9 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' -DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点' -HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') -COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') +DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' +HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') +COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') def _handler(context: SingleGroupContext, command: str): 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 43956a2..bbaaa04 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -44,11 +44,15 @@ LOCAL_COMMAND_ALIASES = { "wallet": "wallet", "抽奖": "prize_draw", "draw": "prize_draw", + "彩票": "lottery", "买彩票": "lottery", "lottery": "lottery", "21点": "blackjack", "二十一点": "blackjack", "blackjack": "blackjack", + "游戏记录": "game_history", + "个人记录": "game_history", + "gamehistory": "game_history", "游戏帮助": "game_rules", "games": "game_rules", "人物列表": "person_directory", @@ -120,14 +124,22 @@ LOCAL_EXTENSIONS = ( LocalExtensionDescriptor( extension_id="single-player-games", author=JWEBOT_AUTHOR, - description="本人虚拟金币账户、抽奖、彩票和单人21点", + description="虚拟金币账户、抽奖、群内多人彩票和单人21点", help_entries=( - "/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", - "/21点 开始 [金额](金额=本局下注,默认50,空格可省略)", + "/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", + "/彩票 开始|报名|当前状态|开奖|取消;/lottery", + "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games", ), - command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"), + command_kinds=( + "wallet", + "prize_draw", + "lottery", + "blackjack", + "game_history", + "game_rules", + ), ), LocalExtensionDescriptor( extension_id="person-directory", 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 d9e93e6..cd6bf1c 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 @@ -119,7 +119,14 @@ class LocalCommandEngine: 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"}: + if kind in { + "wallet", + "prize_draw", + "lottery", + "blackjack", + "game_history", + "game_rules", + }: return self.reply_cleaner(self.players.handle(row, kind, arguments)) if kind == "dice": return self.reply_cleaner(self.dice(row, arguments)) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py index bf77063..c318a02 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py @@ -9,8 +9,21 @@ from typing import Any INITIAL_COINS = 1000 -MAX_BET = 100 +MIN_DRAW_BET = 2 +MAX_DRAW_BET = 100 +MIN_LOTTERY_BET = 1 +MAX_LOTTERY_BET = 500 +DEFAULT_LOTTERY_BET = 10 +MIN_BLACKJACK_BET = 1 +MAX_BLACKJACK_BET = 500 GAME_TIMEOUT_SECONDS = 600 +GAME_HISTORY_LIMIT = 50 +GAME_HISTORY_PAGE_SIZE = 5 +GAME_LABELS = { + "prize_draw": "抽奖", + "lottery": "彩票", + "blackjack": "21点", +} BLACKJACK_ACTIONS = { "要牌": "hit", "hit": "hit", @@ -30,6 +43,68 @@ BLACKJACK_COMBINED_ACTION = re.compile( ) +def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: + account.setdefault("balance", INITIAL_COINS) + account.setdefault("game", None) + account.setdefault("wins", 0) + account.setdefault("losses", 0) + account.setdefault("ties", 0) + counts = account.get("game_counts") + if not isinstance(counts, dict): + counts = {} + account["game_counts"] = counts + for kind in GAME_LABELS: + value = counts.get(kind, 0) + counts[kind] = value if type(value) is int and value >= 0 else 0 + history = account.get("game_history") + if not isinstance(history, list): + history = [] + account["game_history"] = [ + entry for entry in history if isinstance(entry, dict) + ][-GAME_HISTORY_LIMIT:] + return account + + +def next_game_index(account: dict[str, Any], kind: str) -> int: + normalize_player_account(account) + counts = account["game_counts"] + counts[kind] = int(counts.get(kind, 0)) + 1 + return int(counts[kind]) + + +def append_game_record( + account: dict[str, Any], + *, + kind: str, + index: int, + outcome: str, + stake: int, + payout: int, + summary: str, +) -> None: + normalize_player_account(account) + history = [ + entry + for entry in account["game_history"] + if not ( + entry.get("kind") == kind + and int(entry.get("index", 0)) == index + ) + ] + history.append( + { + "kind": kind, + "index": index, + "outcome": outcome[:80], + "stake": stake, + "payout": payout, + "summary": summary[:240], + "completed_at": time.time(), + } + ) + account["game_history"] = history[-GAME_HISTORY_LIMIT:] + + def card_label(card: int) -> str: return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13] @@ -52,13 +127,15 @@ class PlayerGames: @staticmethod def rules() -> str: return ( - "单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n" - "/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;" - "21点先用 /21点 开始 [金额] 进场,金额是本局下注,默认50;空格可省略。\n" + "每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n" + "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" + "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" + "/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;" + "命中1/2/3个返下注的50%/120%/300%。\n" + "21点先用 /21点 开始 [1~500] 进场,金额是本局下注,默认50;空格可省略。\n" "看到牌后再单独发送 /21点 要牌|停牌|状态|结束" "(/blackjack hit|stand|status|end);" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n" - "抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" "以上均含本金;虚拟金币不能充值、转账或兑换。" ) @@ -69,18 +146,16 @@ class PlayerGames: return self.rules() if arguments.casefold() in {"帮助", "规则", "help", "rules"}: return self.rules() + if kind == "game_history": + return self.game_history(row, arguments) + if kind == "lottery": + return self.group_lottery(row, arguments) 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": compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态" start = BLACKJACK_START.fullmatch(compacted) @@ -100,27 +175,48 @@ class PlayerGames: "/21点 要牌 或 /21点 停牌" ) return ( - "用法:先 /21点 开始 [2~100](金额是本局下注,默认50);" + "用法:先 /21点 开始 [1~500](金额是本局下注,默认50);" "开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略" ) else: bet = 0 - if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start": - if not 2 <= bet <= MAX_BET: - return "下注必须为 2~100 的整数金币" + if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET: + return "抽奖下注必须为 2~100 的整数金币" + if ( + kind == "blackjack" + and action == "start" + and not MIN_BLACKJACK_BET <= bet <= MAX_BLACKJACK_BET + ): + return "21点下注必须为 1~500 的整数金币" person = self.ledger.resolve_person( str(row["group_fingerprint"]), str(row["sender"]), ) player_label = self.display_label(person["display_name"]) - def blackjack_reply(text: str) -> str: - return f"{player_label}:{text}" + def player_reply(text: str, index: int | None = None) -> str: + suffix = f" #{index}" if index is not None else "" + return f"{player_label}{suffix}:{text}" def execute(account: dict[str, Any]) -> str: + normalize_player_account(account) expired = "" game = account.get("game") + if game and ( + type(game.get("index")) is not int + or int(game["index"]) < 1 + ): + game["index"] = next_game_index(account, "blackjack") if game and time.time() - float(game["updated_at"]) >= GAME_TIMEOUT_SECONDS: + append_game_record( + account, + kind="blackjack", + index=int(game["index"]), + outcome="超时认输", + stake=int(game["bet"]), + payout=0, + summary="10分钟无有效操作,按认输结束", + ) account["game"] = None account["losses"] += 1 expired = "上一局已超时,按认输结束;" @@ -130,44 +226,285 @@ class PlayerGames: f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。" "首次赠送1000仅发一次;/游戏帮助 查看规则") if kind == "blackjack" and action in {"hit", "stand", "status", "end"}: - return blackjack_reply( - expired + self.play_blackjack(account, action) + active = account.get("game") + index = int(active["index"]) if active else None + return player_reply( + expired + self.play_blackjack(account, action), + index, ) if account.get("game"): message = expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束" - return blackjack_reply(message) if kind == "blackjack" else message + return ( + player_reply(message, int(account["game"]["index"])) + if kind == "blackjack" + else message + ) if account["balance"] < bet: message = expired + f"金币不足:余额 {account['balance']},本次需要 {bet}" - return blackjack_reply(message) if kind == "blackjack" else message + return player_reply(message) if kind == "blackjack" else message account["balance"] -= bet if kind == "blackjack": + index = next_game_index(account, "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()} + "dealer": deck[2:4], "updated_at": time.time(), + "index": index} account["game"] = game if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21: - return blackjack_reply( - expired + self.finish_blackjack(account, natural=True) + return player_reply( + expired + self.finish_blackjack(account, natural=True), + index, ) - return blackjack_reply(expired + self.show_hand(game)) - if kind == "prize_draw": - roll = self.rng.randrange(100) - payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0) - 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 "未中奖") + return player_reply(expired + self.show_hand(game), index) + index = next_game_index(account, "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 "未中奖" account["balance"] += payout account["wins" if payout > bet else "losses"] += 1 - return expired + f"{result};下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}" + append_game_record( + account, + kind="prize_draw", + index=index, + outcome=result, + stake=bet, + payout=payout, + summary=f"抽奖结果 {result}", + ) + return player_reply( + expired + + f"{result};下注 {bet},返还 {payout}," + f"净变化 {payout - bet:+d};金币 {account['balance']}", + index, + ) return self.ledger.apply_player_action( str(row["group_fingerprint"]), str(row["sender"]), str(row["event_id"]), kind + ":" + arguments, execute) + @staticmethod + def parse_lottery_entry( + arguments: str, + ) -> tuple[tuple[int, int, int], int] | None: + value = arguments.strip().casefold() + for prefix in ("报名", "join"): + if value.startswith(prefix): + value = value[len(prefix):].strip() + break + if not value: + return None + marked = re.fullmatch( + r"([0-9])\s*([0-9])\s*([0-9])" + r"(?:金额|下注|bet)(\d{1,3})", + re.sub(r"[,,、]", "", value), + ) + stake: int + if marked is not None: + digits = tuple(int(marked[index]) for index in range(1, 4)) + stake = int(marked[4]) + else: + tokens = re.sub(r"[,,、]", " ", value).split() + digits: tuple[int, int, int] + if len(tokens) == 1 and tokens[0].isdigit(): + compact = tokens[0] + if not 3 <= len(compact) <= 6: + return None + digits = tuple(int(item) for item in compact[:3]) + stake = int(compact[3:] or DEFAULT_LOTTERY_BET) + elif ( + len(tokens) == 2 + and re.fullmatch(r"\d{3}", tokens[0]) + and re.fullmatch(r"\d{1,3}", tokens[1]) + ): + digits = tuple(int(item) for item in tokens[0]) + stake = int(tokens[1]) + elif ( + len(tokens) in {3, 4} + and all(re.fullmatch(r"\d", item) for item in tokens[:3]) + and ( + len(tokens) == 3 + or re.fullmatch(r"\d{1,3}", tokens[3]) + ) + ): + digits = tuple(int(item) for item in tokens[:3]) + stake = ( + int(tokens[3]) + if len(tokens) == 4 + else DEFAULT_LOTTERY_BET + ) + else: + return None + if len(set(digits)) != 3: + raise ValueError("彩票的三个数字必须互不重复") + if not MIN_LOTTERY_BET <= stake <= MAX_LOTTERY_BET: + raise ValueError("彩票下注必须为 1~500 的整数金币") + return tuple(sorted(digits)), stake + + def group_lottery(self, row: dict[str, Any], arguments: str) -> str: + group = str(row["group_fingerprint"]) + sender = str(row["sender"]) + event_id = str(row["event_id"]) + command = "lottery:" + arguments + compacted = re.sub(r"\s+", "", arguments.casefold()) + + def status_text(data: dict[str, Any]) -> str: + if data["status"] == "no_round": + return "当前没有进行中的彩票;发送 /彩票 开始 创建新一把" + entries = data["entries"] + lines = [ + f"彩票第 {data['round_number']} 把正在报名;" + f"主持人 {data['host_display_name']};已报名 {len(entries)} 人" + ] + for entry in entries: + lines.append( + f"{entry['display_name']} #{entry['player_game_index']}:" + f"{' '.join(map(str, entry['numbers']))},下注 {entry['stake']}" + ) + if not entries: + lines.append("暂无报名;/彩票 报名 3 4 7 [金额]") + return "\n".join(lines) + + if compacted in {"开始", "start"}: + return self.ledger.start_group_lottery( + group, + sender, + event_id, + command, + lambda data: ( + f"彩票第 {data['round_number']} 把" + + ("已经在报名中" if data["status"] == "already_open" else "已开始") + + f";主持人 {data['host_display_name']}。\n" + "报名:/彩票 3 4 7 [金额](金额1~500,默认10,空格可省略);" + "查询:/彩票 当前状态;主持人开奖:/彩票 开奖" + ), + ) + if compacted in {"当前状态", "状态", "status"}: + return self.ledger.group_lottery_status( + group, + sender, + event_id, + command, + status_text, + ) + if compacted in {"开奖", "draw"}: + drawn = tuple(sorted(self.rng.sample(range(10), 3))) + + def draw_text(data: dict[str, Any]) -> str: + if data["status"] == "no_round": + return "当前没有可以开奖的彩票" + if data["status"] == "not_host": + return ( + f"只有本轮主持人 {data['host_display_name']} 可以开奖" + ) + if data["status"] == "empty": + return "本轮还没有人报名,不能开奖" + lines = [ + f"彩票第 {data['round_number']} 把开奖号:" + f"{' '.join(map(str, data['drawn_numbers']))}" + ] + for entry in data["entries"]: + lines.append( + f"{entry['display_name']} #{entry['player_game_index']}:" + f"命中 {entry['matches']} 个;下注 {entry['stake']}," + f"返还 {entry['payout']},净变化 " + f"{entry['payout'] - entry['stake']:+d};" + f"金币 {entry['balance']}" + ) + return "\n".join(lines) + + return self.ledger.draw_group_lottery( + group, + sender, + event_id, + command, + drawn, + draw_text, + ) + if compacted in {"取消", "cancel"}: + return self.ledger.cancel_group_lottery( + group, + sender, + event_id, + command, + lambda data: ( + "当前没有进行中的彩票" + if data["status"] == "no_round" + else ( + f"只有本轮主持人 {data['host_display_name']} 可以取消" + if data["status"] == "not_host" + else ( + f"彩票第 {data['round_number']} 把已取消;" + f"{len(data['entries'])} 人的下注已全额退回" + ) + ) + ), + ) + try: + parsed = self.parse_lottery_entry(arguments) + except ValueError as error: + return str(error) + if parsed is None: + return ( + "用法:先 /彩票 开始;再 /彩票 报名 3 4 7 [金额]" + "(也可 /彩票347,金额1~500、默认10);" + "/彩票 当前状态;主持人 /彩票 开奖" + ) + numbers, stake = parsed + + def register_text(data: dict[str, Any]) -> str: + if data["status"] == "no_round": + return "当前没有进行中的彩票;先发送 /彩票 开始" + if data["status"] == "insufficient": + return ( + f"{data['display_name']}:金币不足,余额 {data['balance']}," + f"报名需要 {stake}" + ) + action = "报名已更新" if data["status"] == "updated" else "报名成功" + return ( + f"{data['display_name']} #{data['player_game_index']}:" + f"彩票第 {data['round_number']} 把{action};号码 " + f"{' '.join(map(str, data['numbers']))};下注 {data['stake']};" + f"金币 {data['balance']}" + ) + + return self.ledger.register_group_lottery( + group, + sender, + event_id, + command, + numbers, + stake, + register_text, + ) + + def game_history(self, row: dict[str, Any], arguments: str) -> str: + if arguments and not re.fullmatch(r"\d{1,3}", arguments): + return "用法:/游戏记录 [页码](/gamehistory),每页5条" + page = int(arguments or 1) + if page < 1: + return "游戏记录页码必须从 1 开始" + data = self.ledger.player_game_history( + str(row["group_fingerprint"]), + str(row["sender"]), + ) + records = data["history"] + pages = max(1, (len(records) + GAME_HISTORY_PAGE_SIZE - 1) // GAME_HISTORY_PAGE_SIZE) + if page > pages: + return f"{data['display_name']} 只有 {pages} 页游戏记录" + start = (page - 1) * GAME_HISTORY_PAGE_SIZE + lines = [f"{data['display_name']} 的游戏记录 {page}/{pages}:"] + for record in records[start:start + GAME_HISTORY_PAGE_SIZE]: + label = GAME_LABELS.get(str(record["kind"]), str(record["kind"])) + lines.append( + f"{data['display_name']} #{record['index']}:{label} {record['outcome']};" + f"下注 {record['stake']},返还 {record['payout']}," + f"净变化 {record['payout'] - record['stake']:+d}" + ) + if not records: + lines.append("暂无已结算记录") + return "\n".join(lines) + @staticmethod def show_hand(game: dict[str, Any]) -> str: player = " ".join(card_label(card) for card in game["player"]) @@ -182,6 +519,17 @@ class PlayerGames: if action == "status": return self.show_hand(game) if action == "end": + index = int(game["index"]) + bet = int(game["bet"]) + append_game_record( + account, + kind="blackjack", + index=index, + outcome="主动认输", + stake=bet, + payout=0, + summary="主动结束牌局,不退下注", + ) account["game"] = None account["losses"] += 1 return f"本人的21点已认输结束,不退下注;金币 {account['balance']}" @@ -213,6 +561,17 @@ class PlayerGames: outcome, payout, stat = "你输了", 0, "losses" account["balance"] += payout account[stat] += 1 + append_game_record( + account, + kind="blackjack", + index=int(game["index"]), + outcome=outcome, + stake=int(bet), + payout=int(payout), + summary=( + f"玩家{player}点,庄家{dealer}点" + ), + ) account["game"] = None return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点)," f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);" diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index b37a4d2..c661b86 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.2a0+1015" +version = "0.6.3a0+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 4aed2b0..a79ddaa 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.2a0+1015" +__version__ = "0.6.3a0+1015" diff --git a/pyproject.toml b/pyproject.toml index 603f6f0..33809ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "alpha 0.6.2.1015" +release = "alpha 0.6.3.1015" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_official_extension_inventory.py b/tests/test_official_extension_inventory.py index 83b98ac..e81f353 100644 --- a/tests/test_official_extension_inventory.py +++ b/tests/test_official_extension_inventory.py @@ -110,7 +110,14 @@ class LocalExtensionInventoryTests(unittest.TestCase): self.assertEqual( combined, { - "single-player-games": ("wallet", "prize_draw", "lottery", "blackjack", "game_rules"), + "single-player-games": ( + "wallet", + "prize_draw", + "lottery", + "blackjack", + "game_history", + "game_rules", + ), "person-directory": ("person_directory", "person_info"), "person-memory": ( "remember", diff --git a/tests/test_release_061.py b/tests/test_release_061.py index d0fe2aa..8fdd8bf 100644 --- a/tests/test_release_061.py +++ b/tests/test_release_061.py @@ -125,10 +125,6 @@ class Release061Tests(unittest.TestCase): 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. diff --git a/tests/test_release_062.py b/tests/test_release_062.py index aea2c55..1f97ded 100644 --- a/tests/test_release_062.py +++ b/tests/test_release_062.py @@ -56,7 +56,7 @@ class Release062BlackjackTests(unittest.TestCase): side_effect=self.hit_deck, ): start = self.command("/21点开始100", event_id="compact-start") - self.assertTrue(start.startswith("alice:")) + self.assertTrue(start.startswith("alice #1:")) self.assertIn("下注 100", start) self.assertEqual(self.state()["balance"], 900) self.assertEqual(len(self.state()["game"]["player"]), 2) @@ -69,7 +69,7 @@ class Release062BlackjackTests(unittest.TestCase): ) self.assertEqual(replay, first) - self.assertTrue(first.startswith("alice:")) + self.assertTrue(first.startswith("alice #1:")) self.assertIn("13点", first) self.assertEqual(self.state()["balance"], 900) self.assertEqual(len(self.state()["game"]["player"]), 3) @@ -92,7 +92,7 @@ class Release062BlackjackTests(unittest.TestCase): started = self.command(command, sender=sender) self.assertTrue( started.startswith( - "alice:" if sender == "Alice" else "bob:" + "alice #1:" if sender == "Alice" else "bob #1:" ) ) self.assertIn("下注 50", started) @@ -109,7 +109,7 @@ class Release062BlackjackTests(unittest.TestCase): ): reply = self.command("/21点开始") - self.assertTrue(reply.startswith("alice:")) + self.assertTrue(reply.startswith("alice #1:")) self.assertIn("下注 50", reply) self.assertEqual(self.state()["balance"], 950) diff --git a/tests/test_release_063.py b/tests/test_release_063.py new file mode 100644 index 0000000..4da2bb0 --- /dev/null +++ b/tests/test_release_063.py @@ -0,0 +1,252 @@ +"""Behavioral coverage for multiplayer lottery and indexed game records.""" + +import json +import tempfile +import threading +import unittest +from pathlib import Path +from unittest import mock + +from test_jwebot_edge import MODULE, config + + +class Release063GameTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.engine = MODULE.EdgeApplication(config(Path(self.temp.name))) + self.counter = 0 + + def tearDown(self): + self.engine.ledger.connection.close() + self.temp.cleanup() + + def command( + self, + question, + *, + 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.engine.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_lottery_number_forms_sort_and_validate(self): + parse = self.engine.local.players.parse_lottery_entry + + self.assertEqual(parse("347"), ((3, 4, 7), 10)) + self.assertEqual(parse("743"), ((3, 4, 7), 10)) + self.assertEqual(parse("报名 3 4 7 100"), ((3, 4, 7), 100)) + self.assertEqual(parse("报名347100"), ((3, 4, 7), 100)) + self.assertEqual(parse("743金额500"), ((3, 4, 7), 500)) + with self.assertRaisesRegex(ValueError, "互不重复"): + parse("337") + with self.assertRaisesRegex(ValueError, "1~500"): + parse("347501") + + def test_multiplayer_lottery_status_draw_and_payouts(self): + self.assertIn("第 1 把已开始", self.command("/彩票开始")) + self.assertIn("alice #1", self.command("/彩票347100")) + self.assertIn( + "bob #1", + self.command("/彩票 报名 9 3 7 200", sender="Bob"), + ) + self.assertIn( + "carol #1", + self.command("/彩票 1 2 3 100", sender="Carol"), + ) + self.assertIn( + "Dave #1", + self.command("/彩票045100", sender="Dave"), + ) + + status = self.command("/彩票当前状态", sender="Bob") + self.assertIn("已报名 4 人", status) + self.assertIn("alice #1:3 4 7,下注 100", status) + self.assertIn("bob #1:3 7 9,下注 200", status) + self.assertIn("只有本轮主持人 alice", self.command( + "/彩票开奖", sender="Bob")) + + with mock.patch.object( + self.engine.local.players.rng, + "sample", + return_value=[7, 3, 9], + ): + result = self.command("/彩票开奖", event_id="draw-round-1") + + self.assertIn("开奖号:3 7 9", result) + self.assertIn("alice #1:命中 2 个", result) + self.assertIn("返还 120", result) + self.assertIn("bob #1:命中 3 个", result) + self.assertIn("返还 600", result) + self.assertIn("carol #1:命中 1 个", result) + self.assertIn("返还 50", result) + self.assertIn("Dave #1:命中 0 个", result) + self.assertEqual(self.state()["balance"], 1020) + self.assertEqual(self.state("Bob")["balance"], 1400) + self.assertEqual(self.state("Carol")["balance"], 950) + self.assertEqual(self.state("Dave")["balance"], 900) + self.assertIn("命中2个", self.command("/游戏记录")) + self.assertIn("命中3个", self.command( + "/个人记录", sender="Bob")) + self.assertIn("没有进行中", self.command("/彩票当前状态")) + + def test_registration_update_keeps_index_and_cancel_refunds(self): + self.command("/彩票 开始") + first = self.command("/彩票 347 100") + updated = self.command("/彩票 743 200", sender="a1") + + self.assertIn("alice #1", first) + self.assertIn("alice #1", updated) + self.assertIn("报名已更新", updated) + self.assertEqual(self.state()["balance"], 800) + status = self.command("/彩票 状态") + self.assertIn("已报名 1 人", status) + self.assertIn("3 4 7,下注 200", status) + self.assertIn("只有本轮主持人 alice", self.command( + "/彩票 取消", sender="Bob")) + self.assertIn("已全额退回", self.command("/彩票取消")) + self.assertEqual(self.state()["balance"], 1000) + self.assertIn("净变化 +0", self.command("/游戏记录")) + + self.command("/彩票开始") + second = self.command("/彩票347") + self.assertIn("alice #2", second) + + def test_concurrent_registration_replay_debits_once(self): + self.command("/彩票开始") + replies = [] + errors = [] + + def register(): + try: + replies.append( + self.command("/彩票347500", event_id="same-entry") + ) + except BaseException as error: + errors.append(error) + + threads = [threading.Thread(target=register) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertFalse(errors) + self.assertEqual(len(set(replies)), 1) + self.assertEqual(self.state()["balance"], 500) + self.assertIn("已报名 1 人", self.command("/彩票状态")) + + def test_blackjack_uses_1_to_500_and_indexed_history(self): + def tied_deck(cards): + first = [9, 6, 8, 7] + cards[:] = first + [card for card in cards if card not in first] + + with mock.patch.object( + self.engine.local.players.rng, + "shuffle", + side_effect=tied_deck, + ): + started = self.command("/21点开始500") + self.assertTrue(started.startswith("alice #1:")) + self.assertEqual(self.state()["balance"], 500) + settled = self.command("/21点停牌") + self.assertTrue(settled.startswith("alice #1:")) + self.assertIn("平局", settled) + self.assertEqual(self.state()["balance"], 1000) + history = self.command("/游戏记录") + self.assertIn("alice #1:21点 平局", history) + self.assertIn("下注 500,返还 500", history) + self.assertIn("1~500", self.command("/21点开始501")) + + def test_prize_draw_has_indexed_history(self): + with mock.patch.object( + self.engine.local.players.rng, + "randrange", + return_value=99, + ): + first = self.command("/抽奖10") + second = self.command("/抽奖10") + + self.assertTrue(first.startswith("alice #1:")) + self.assertTrue(second.startswith("alice #2:")) + history = self.command("/gamehistory") + self.assertIn("alice #2:抽奖 未中奖", history) + self.assertIn("alice #1:抽奖 未中奖", history) + + def test_open_round_and_history_survive_restart(self): + root = Path(self.temp.name) + self.command("/彩票开始") + self.command("/彩票347100") + self.engine.ledger.connection.close() + self.engine = MODULE.EdgeApplication(config(root)) + + self.assertIn("alice #1:3 4 7", self.command("/彩票状态")) + with mock.patch.object( + self.engine.local.players.rng, + "sample", + return_value=[3, 4, 7], + ): + self.command("/彩票开奖") + self.engine.ledger.connection.close() + self.engine = MODULE.EdgeApplication(config(root)) + + history = self.command("/游戏记录") + self.assertIn("alice #1:彩票 命中3个", history) + self.assertEqual(self.state()["balance"], 1200) + + def test_registration_rolls_back_when_response_building_fails(self): + ledger = self.engine.ledger + ledger.start_group_lottery( + "group-a", + "Alice", + "start", + "lottery:start", + lambda _: "started", + ) + + def fail(_): + raise RuntimeError("render failed") + + with self.assertRaisesRegex(RuntimeError, "render failed"): + ledger.register_group_lottery( + "group-a", + "Alice", + "register", + "lottery:347100", + (3, 4, 7), + 100, + fail, + ) + + account = ledger.connection.execute( + "SELECT COUNT(*) FROM player_accounts" + ).fetchone()[0] + entries = ledger.connection.execute( + "SELECT COUNT(*) FROM group_lottery_entries" + ).fetchone()[0] + actions = ledger.connection.execute( + "SELECT COUNT(*) FROM player_actions WHERE event_id = 'register'" + ).fetchone()[0] + self.assertEqual((account, entries, actions), (0, 0, 0)) + + +if __name__ == "__main__": + unittest.main()